chore: import gti-maquina-comercial history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: gti-maquina-comercial
git-subtree-mainline: 93ca82979e
git-subtree-split: b77484946f
This commit is contained in:
Gustavo
2026-09-28 15:50:08 -03:00
co-authored by Claude Sonnet 5
110 changed files with 27501 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "gti-servidor",
"runtimeExecutable": "node",
"runtimeArgs": ["scripts/servidor.mjs"],
"port": 3900
}
]
}
+38
View File
@@ -0,0 +1,38 @@
# GTI Maquina Comercial - configuracao local (copie para .env se precisar mudar algo)
# Porta do aplicativo local (padrao 3900)
#GTI_PORT=3900
# Caminho do banco SQLite (padrao data/gti.db)
#GTI_DB_PATH=data/gti.db
# Pasta com os CSVs da base CNPJ extraida (padrao prospeccao/cnpj)
#CNPJ_BASE_DIR=prospeccao/cnpj
# Necessaria APENAS para a fonte Google Places (paga)
#GOOGLE_MAPS_API_KEY=
# Senha pedida para confirmar a limpeza total da base (Configuracoes > Zona de risco)
#GTI_RESET_PASSWORD=troque-esta-senha
# Credenciais da API externa do Z-PRO (Admin > API no painel do Z-PRO), usadas
# pela automacao de envio WhatsApp. Normalmente configuradas direto na tela
# (Configuracoes > Credenciais Z-PRO) — essas variaveis aqui sao so um
# fallback (util em deploy via Docker/Proxmox) e so valem se os campos da
# tela estiverem vazios. Sem credenciais em nenhum dos dois lugares a
# automacao fica sem efeito mesmo com outreach.ativo=true.
#ZPRO_BASE_URL=seu-dominio-zpro.com.br
#ZPRO_API_ID=
#ZPRO_BEARER_TOKEN=
#ZPRO_WHATSAPP_ID=
# Endpoints das fontes gratuitas (padroes ja funcionam; mude so se precisar)
#OVERPASS_API_URL=https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter
#NOMINATIM_API_URL=https://nominatim.openstreetmap.org/search
#WIKIDATA_API_URL=https://query.wikidata.org/sparql
#BRASILAPI_URL=https://brasilapi.com.br/api
# Ritmo das consultas ao OpenStreetMap (ms)
#OVERPASS_REQUEST_DELAY_MS=5000
#OVERPASS_TIMEOUT_MS=15000
#OVERPASS_TEXT_SEARCH=false
+11
View File
@@ -0,0 +1,11 @@
node_modules/
.env
.env.local
data/
prospeccao/cnpj/rfb-raw/
prospeccao/cnpj/*.csv
prospeccao/cnpj/*.ndjson
*.log
GTI-Comercial.exe
launcher/publish/
.worktrees/
+10
View File
@@ -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"
)
+192
View File
@@ -0,0 +1,192 @@
# GTI Maquina Comercial
Aplicativo local do Grupo GTI para capturar, qualificar, tratar e acionar leads B2B via WhatsApp. O sistema roda no computador do usuario, usa SQLite como banco local e integra com Z-PRO/GChat para envio manual ou automatico.
URL local padrao: `http://127.0.0.1:3900`
Login inicial: `admin` / `admin`
## Uso Diario
1. Abra `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
2. Acesse `http://127.0.0.1:3900` e faca login.
3. Use a aba `Capturar Leads` para escolher UF, cidades, nichos e fontes.
4. Use a aba `Tratar Leads` para filtrar, priorizar, abrir detalhes e exportar leads.
5. Use o botao de WhatsApp na primeira coluna da tabela para iniciar uma mensagem para um lead especifico.
6. Use `Configuracoes` para ajustar Z-PRO, templates WABA, limites de envio, usuarios e exportacoes.
## Principais Recursos
- Captura de leads B2B por UF, cidade e nicho.
- Base CNPJ da Receita Federal como fonte principal.
- Fontes complementares: OpenStreetMap/Overpass, Nominatim, Wikidata, BrasilAPI e Google Places.
- Deduplicacao por CNPJ, telefone/WhatsApp ou empresa+cidade.
- Scores de qualidade e potencial comercial.
- Dashboard com totais, rankings, funil e maior oportunidade.
- Tabela de tratamento com filtros, paginação e detalhe completo do lead.
- Acao rapida de WhatsApp por lead, fixa na primeira coluna da tabela.
- Envio manual por Z-PRO para numero especifico.
- Envio oficial por template WABA aprovado.
- Sincronizacao de templates WABA do GChat.
- Pre-visualizacao de templates com variaveis resolvidas por lead.
- Automacao de envio por produto recomendado, com limite diario.
- Exportacoes para Excel/CSV, oportunidades Z-PRO e disparo em massa.
- Login local com usuarios `admin` e `comercial`.
## Fluxo Comercial
1. Capture ou importe a base de leads.
2. Filtre por cidade, nicho, qualidade, WhatsApp, prioridade ou status de exportacao.
3. Revise o lead na tabela ou no modal de detalhes.
4. Clique no botao de WhatsApp do lead para abrir o envio manual com o numero preenchido.
5. Escolha texto livre ou template oficial.
6. Se usar template, selecione o modelo salvo e confira a pre-visualizacao.
7. Envie pelo Z-PRO e acompanhe o log de envios.
Ao abrir o envio manual a partir de um lead, o app busca os dados completos desse cliente. Variaveis como `{{cnpj}}`, `{{empresa}}`, `{{businessName}}`, `{{produto}}`, `{{cidade}}`, `{{uf}}`, `{{segmento}}`, `{{whatsapp}}` e `{{email}}` sao preenchidas com os dados reais do lead na pre-visualizacao e no envio.
## WhatsApp, Z-PRO e GChat
O app suporta dois tipos de envio:
| Canal | Uso | Observacao |
|---|---|---|
| Nao oficial/texto livre | Mensagem manual simples | Usa endpoint externo do Z-PRO para enviar texto |
| Oficial/template WABA | Mensagem com template aprovado | Usa template Meta/WABA via Z-PRO |
Configuracoes importantes ficam em `Configuracoes`:
- Base URL Z-PRO.
- API ID Z-PRO.
- Token/Bearer token.
- Rota do template (`template`, `templateBody` ou equivalente configurado).
- URL de templates do GChat.
- Token GChat opcional para sincronizar modelos.
- Limite diario de envios.
- Templates automaticos por produto.
O seletor de templates WABA permite carregar modelos sincronizados do GChat. O sistema identifica variaveis posicionais como `{{1}}`, `{{2}}`, `{{3}}` no corpo do template e mostra os campos necessarios.
## Variaveis Suportadas
As mensagens e templates podem usar variaveis internas da Maquina Comercial/GChat:
| Variavel | Valor esperado |
|---|---|
| `{{name}}` | Nome do contato quando existir; fallback para empresa |
| `{{empresa}}` | Nome da empresa |
| `{{businessName}}` | Nome da empresa no padrao GChat |
| `{{firstName}}` | Primeiro nome |
| `{{lastName}}` | Sobrenome, quando existir |
| `{{produto}}` | Produto recomendado/interesse |
| `{{protocol}}` | Assunto/protocolo; fallback para produto |
| `{{cidade}}` | Cidade |
| `{{uf}}` | UF |
| `{{segmento}}` | Nicho/segmento |
| `{{cnpj}}` | CNPJ |
| `{{email}}` | E-mail |
| `{{phoneNumber}}` | WhatsApp/telefone |
| `{{whatsapp}}` | WhatsApp/telefone |
| `{{kanban}}` | Produto/interesse |
| `{{user}}` | Remetente |
| `{{userEmail}}` | E-mail do remetente |
| `{{greeting}}` | Saudacao |
Tambem existem placeholders legados: `{empresa}`, `{cidade}`, `{produto}` e `{segmento}`.
## Exportacoes
| Tipo | Saida | Para que serve |
|---|---|---|
| Planilha | CSV + XLS | Analise comercial e tratamento externo |
| Z-PRO Oportunidades | JSONL | Payloads para criacao de oportunidades |
| Z-PRO Disparo em massa | CSV | `dataInput` para disparo em massa |
Leads exportados ficam marcados. Use o filtro de nao exportados para evitar retrabalho.
## Score e Priorizacao
O app calcula scores para ajudar o time comercial a decidir quem abordar primeiro.
Regras principais:
- WhatsApp/telefone valido aumenta a prioridade.
- CNPJ valido aumenta a confiabilidade.
- Empresa valida, cidade, nicho, CNAE, website e e-mail melhoram o score.
- Nichos com maior aderencia comercial recebem mais peso.
- Leads sem WhatsApp podem continuar cadastrados, mas aparecem com prioridade menor.
- Duplicidade segue a ordem CNPJ, telefone, empresa+cidade.
## Fontes de Captura
- Base CNPJ da Receita Federal: fonte principal para empresas ativas por UF.
- OpenStreetMap/Overpass: fonte aberta complementar.
- Nominatim: geocodificacao e apoio geografico.
- Wikidata: fonte aberta de baixo volume.
- BrasilAPI: enriquecimento de dados cadastrais.
- Google Places: fonte opcional paga, exige `GOOGLE_MAPS_API_KEY` no `.env`.
## Comandos
```bash
npm start
npm test
npm run capture
npm run extract
npm run recalc-scores
npm run outreach
npm run build:launcher
```
Exemplos:
```bash
node scripts/servidor.mjs
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
node scripts/extrair-base-cnpj.mjs --uf=SP
node scripts/enviar-outreach.mjs
```
## Estrutura
```text
GTI-Comercial.exe Launcher local do app
INICIAR-GTI.bat Inicializacao alternativa no Windows
scripts/ Entradas de linha de comando
src/ Codigo do servidor e regras de negocio
src/server/ HTTP, rotas, sessoes e autenticacao
src/capture/ Pipeline de captura
src/sources/ Fontes de dados
src/treatment/ Consultas e dashboard de tratamento
src/export/ Exportacoes Excel/CSV/Z-PRO
src/outreach/ Z-PRO, templates WABA e automacao WhatsApp
public/ Interface HTML/CSS/JS
tests/ Testes automatizados
data/gti.db Banco SQLite local, nao versionado
data/exports/ Arquivos exportados, nao versionado
prospeccao/cnpj/ Base CNPJ e dados brutos da Receita
docs/ Documentacao complementar
.env Segredos locais, nao versionado
```
## Requisitos
- Windows.
- Node.js 24 ou superior.
- Para extrair novas UFs, `unzip`, Git Bash ou `tar` nativo do Windows.
## Seguranca e Dados
- O banco principal fica em `data/gti.db`.
- Segredos locais devem ficar no `.env` ou nas configuracoes locais do app.
- Tokens Z-PRO/GChat nao devem ser commitados.
- A zona de risco em Configuracoes apaga leads, capturas e exportacoes; a acao e irreversivel.
- Nao ha backup automatico do banco local.
## Problemas Comuns
- Se a tela nao abrir, reinicie por `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
- Se preferir depurar, rode `node scripts/servidor.mjs` no terminal.
- Log do servidor: `data/server.log`.
- Se a sincronizacao de templates GChat falhar com erro de token, gere/salve um token GChat valido novamente.
- Se um template WABA falhar no Z-PRO, teste outro template aprovado e confira rota, idioma e variaveis obrigatorias.
@@ -0,0 +1,62 @@
# Visão Geral
O Grupo GTI opera uma **máquina comercial local**: um aplicativo (GTI-Comercial.exe)
que captura leads B2B das fontes públicas — principalmente a base oficial de CNPJs
da Receita Federal — e ajuda a tratá-los e exportá-los para as campanhas de
WhatsApp no Z-PRO.
## Soluções vendidas
- **ERP e sistemas de gestão** para comércio, serviços, restaurantes, mercados e distribuidoras.
- **Gestão de TI** para empresas com suporte, infraestrutura, servidores, redes, backup e segurança.
- **Autoatendimento para WhatsApp** com triagem, atendimento automático, vendas e agendamento.
Cada nicho do catálogo (57 nichos) já vem associado ao interesse provável e às dores
típicas, que alimentam a personalização das mensagens.
## Arquitetura (simples de propósito)
```
GTI-Comercial.exe → servidor Node local (porta 3900) → navegador
│
├── Captura: base CNPJ por UF + OpenStreetMap + Wikidata (+ Google opcional)
├── Banco SQLite local (data/gti.db): leads, score, dedup, histórico
└── Tratamento: dashboard, filtros, relatórios e exportações Z-PRO
```
Sem n8n, sem serviços externos obrigatórios: tudo roda no computador da GTI hoje
(e vai rodar num container Docker no Proxmox da empresa no futuro, sem mudança de
código). O primeiro contato via WhatsApp pode ser automático (ver
[08-automacao-envio.md](08-automacao-envio.md)); campanhas maiores e o
acompanhamento seguem por fora, no Z-PRO, a partir dos arquivos gerados pelo
módulo de tratamento.
## Premissas
- Foco inicial na Bahia (Santo Antônio de Jesus e região + capitais), mas o app
funciona para **qualquer UF/cidade do Brasil** sem mudança de interface.
- A equipe não tem braço para operação manual intensa: o app faz a triagem
(score de qualidade dos dados + score comercial por nicho, combinados numa
prioridade P1-P4) e evita retrabalho (leads exportados ficam marcados).
- O WhatsApp é o canal central de conversão — leads com telefone válido têm prioridade.
## Resultado esperado
- Base grande e organizada de leads com WhatsApp (97%+ de cobertura via base CNPJ).
- Relatórios por nicho/cidade prontos para campanhas segmentadas.
- Histórico de tudo que já foi exportado/enviado, sem duplicar contato.
- Dashboard de gestão comercial na tela de Tratamento: percentual da base
pronta para abordagem imediata (Potencial Comercial), cobertura da base
já trabalhada, distribuição visual por prioridade, produtos recomendados
por cidade e meta diária de exportações da equipe.
- Interface explica o "porquê" de cada score (composição detalhada no modal
do lead), destaca a cidade+produto de maior oportunidade, mostra a
evolução diária de exportações e oferece atalhos de um clique para gerar
listas de prospecção (só P1, só um produto, só não exportados etc.).
- Acesso protegido por login (usuário/senha): o primeiro acesso vem com
`admin`/`admin`, que pode cadastrar novos usuários (comercial ou admin)
pela tela de Usuários.
- Automação de primeiro contato via WhatsApp (opcional, desligada por padrão):
manda uma mensagem com o template do produto recomendado pra cada lead ainda
não exportado, registra o que foi mandado e aplica tags no contato do Z-PRO —
ver [08-automacao-envio.md](08-automacao-envio.md).
@@ -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.
```
@@ -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.
@@ -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-<UF>.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.
@@ -0,0 +1,133 @@
# Automação de envio WhatsApp (Z-PRO)
Manda automaticamente a primeira mensagem de WhatsApp pro produto/serviço
recomendado de cada lead, usando um template por produto, e registra tudo
(quem recebeu, qual produto, quando) pra nunca mandar a mesma coisa duas
vezes pro mesmo lead.
## Como funciona
- Roda **sozinha, dentro do próprio servidor**, a cada 20 minutos, enquanto
o app estiver ligado — não depende de Agendador de Tarefas do Windows nem
de cron. Isso é de propósito: o projeto hoje roda local, mas vai ser
migrado pra um container Docker no Proxmox da empresa, e um agendador do
sistema operacional teria que ser reconfigurado nesse dia. O timer interno
funciona idêntico nos dois ambientes.
- Só considera leads **ainda não exportados** (`exported_at` vazio). Assim
que um lead vira oportunidade manual no Z-PRO (exportação normal da tela
de Tratamento), a automação para de mandar mensagem pra ele — quem assumiu
o lead foi um vendedor, não o robô.
- Cada lead tem uma lista de produtos recomendados por nicho (ex.: ERP 5⭐,
G-Delivery 3⭐). Hoje a automação só tenta o **produto principal** (o de
maior estrela), uma única vez por lead. Existe uma segunda etapa pronta
(mas desligada) pra tentar o próximo produto da lista se não houver
resposta depois de alguns dias — ver `outreach.tentarProximoProduto`
abaixo.
- Manda uma mensagem por número, com controle nosso de quem já recebeu o
quê — não pelo `bulkSendMessageWithVariable` usado na exportação manual
em massa, que depende de um CSV com substituição posicional de variável.
Cada produto tem um **canal** próprio (ver seção abaixo).
- Depois de mandar, aplica duas tags no contato do Z-PRO:
`GTI P1`/`GTI P2`/`GTI P3`/`GTI P4` (prioridade do lead) e
`GTI Oferta: <produto>` (o que foi ofertado) — pra quem for atender depois
já ver esse contexto direto na conversa.
## Canal oficial x não-oficial
Cada produto/template tem um seletor de canal em Configurações:
- **Não-oficial (texto livre)** — usa `bulkIndividual`, funciona numa
conexão QR Code/Baileys do Z-PRO. Manda o texto do template com
`{empresa}`/`{cidade}`/`{segmento}` substituídos. É o padrão, e o risco
aqui é o de sempre com WhatsApp não-oficial: possibilidade de bloqueio do
número por comportamento automatizado (por isso o limite diário e o
intervalo aleatório entre envios).
- **Oficial (Meta, template aprovado)** — usa a API oficial do WhatsApp
(Cloud API/WABA) via Z-PRO. Aqui **não dá pra mandar texto livre**: o
WhatsApp só permite iniciar uma conversa com um **template pré-aprovado**
pelo Meta Business Manager (fora da janela de 24h de uma conversa já
iniciada pelo cliente). Por isso o campo vira "Nome do template aprovado"
(tem que ser **exatamente** o nome cadastrado no Meta) + "Idioma" (ex.:
`pt_BR`).
**A aprovação do template em si é feita fora deste app** — no Meta
Business Manager (ou no painel do Z-PRO, se ele tiver essa tela) — o
Z-PRO só expõe o *envio* usando um template que já existe, não a criação.
Cadastre e espere aprovar lá antes de configurar o nome aqui.
Limitação atual: o envio oficial aqui é só nome+idioma, sem variáveis
dentro do corpo do template aprovado (a API do Z-PRO tem endpoints
`templateBody`/`templateMarketingBody` que parecem aceitar variáveis, mas
o formato exato não está documentado na collection — se for necessário,
precisa testar contra a instância real antes de automatizar).
## Configurar pela primeira vez
1. **Credenciais do Z-PRO**: no painel do Z-PRO, vá em Admin > API e gere um
token (Base URL, API ID, Token e WhatsApp ID). Na tela, abra
**⚙️ Configurações > Credenciais Z-PRO (WhatsApp)**, preencha os 4 campos
e clique em **Testar conexão** (salva e faz uma chamada real e barata pra
confirmar que as credenciais estão certas, sem mandar nenhuma mensagem).
Sem essas 4 credenciais a automação não manda nada, mesmo ligada.
Alternativa pra quem prefere não guardar o token no banco local (ex.: em
produção no Docker/Proxmox, gerenciando segredo por variável de
ambiente): deixe os 4 campos da tela em branco e defina
`ZPRO_BASE_URL`/`ZPRO_API_ID`/`ZPRO_BEARER_TOKEN`/`ZPRO_WHATSAPP_ID` no
`.env` — a tela vazia cai automaticamente pro `.env`. Se a tela tiver
algum valor preenchido, ele tem prioridade sobre o `.env`.
2. Na mesma tela, em **Automação de envio (WhatsApp)**: revise o texto de
cada template (um por produto — `{empresa}`, `{cidade}` e `{segmento}`
são substituídos automaticamente), ajuste o **limite por dia** se
quiser, e marque **Ativada**. Salve.
Antes de ativar, vale mandar um teste manual (próxima seção) pro seu
próprio WhatsApp.
## Configurações (`settings`, chave → o que faz)
| Chave | Padrão | O que faz |
|---|---|---|
| `outreach.ativo` | `false` | Interruptor geral. Começa desligado de propósito. |
| `outreach.mensagensPorDia` | `10` | Máximo de envios automáticos por dia (soma de todos os produtos). |
| `outreach.diasEntreProdutos` | `5` | Dias de espera antes de tentar o próximo produto (só usado se a cascata estiver ligada). |
| `outreach.tentarProximoProduto` | `false` | Liga a cascata: quando `false`, só tenta o produto principal uma vez. |
| `outreach.horaInicio` / `outreach.horaFim` | `08:00` / `18:00` | Só manda mensagem dentro desse horário. |
Tudo isso é editável pela tela de Configurações (os dois primeiros) ou
diretamente via `PUT /api/settings` (todos).
## Disparo manual (fora do ciclo automático)
Pra testar ou forçar um lote sem esperar o próximo ciclo de 20 min:
```bash
npm run outreach
```
Respeita as mesmas regras do ciclo automático (`outreach.ativo`, horário
comercial, limite diário) — não manda nada que a tela não mostraria.
## Enviar pra um número específico
Em **Configurações > Enviar mensagem manual**: digite o número (com DDI+DDD,
ex.: `5511999999999`), escolha o canal (texto livre ou template oficial já
aprovado) e clique em Enviar. Não depende de lead cadastrado nem de produto.
No canal **Não-oficial (texto livre)**, o app usa `createTicket` para criar uma
conversa visível no painel do Z-PRO. O ticket usa o canal informado em
`WhatsApp ID` e o usuário de `Responsável` nas configurações.
No canal **Oficial**, o app usa o endpoint de template aprovado (`template`) e
também espera um `ticketId` na resposta.
Toda tentativa fica registrada no mesmo histórico dos envios automáticos
(marcado como origem "manual") e **conta pro limite diário**, já que o risco de
bloqueio é do número de WhatsApp usado, não de quem disparou a mensagem.
## Onde fica o histórico
Toda tentativa de envio (sucesso ou erro) vira uma linha na tabela
`outreach_sends` (lead, produto, mensagem exata mandada, status, quando).
Um resumo do dia (enviados hoje, por produto, últimos 20 envios) fica
disponível em `GET /api/outreach/status`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
# Campanhas de Outreach — GTI Comercial
Data: 2026-08-01
Status: aprovado pelo usuário, pendente de plano de implementação
## Contexto e motivação
A Máquina Comercial Pessoal tem um subsistema de campanhas de outreach (fila com
preview/aprovação, variantes A/B/C, escalonamento semanal de limite diário, pausa
automática por saúde, classificação de estágio comercial, agendamento de
demonstração) construído sobre o envio direto via Baileys. A GTI Comercial já tem
sua própria automação de outreach (`src/outreach/run.mjs` + `scheduler.mjs`),
que dispara um lote a cada 20 minutos, respeitando `produto_principal` de cada
lead, canal oficial (WABA/template aprovado) ou não-oficial (texto livre via
Z-PRO), e grava tudo em `outreach_sends`.
O pedido: portar a mesma metodologia de campanhas da Pessoal para a Comercial,
mas adaptada a duas diferenças reais entre os projetos:
1. **Meio de conexão**: a Comercial não fala com o WhatsApp diretamente (Baileys);
ela passa pelo Z-PRO (`src/outreach/zpro-client.mjs`), que oferece dois canais
distintos — não-oficial (`sendText`/`bulkIndividual`, texto livre) e oficial
(`sendTemplateWaba`, só templates pré-aprovados no Meta Business Manager, sem
texto livre nem variáveis além das que o próprio template já define).
2. **Catálogo de negócio**: a Pessoal tem 2-3 campanhas fixas, hardcoded em
`campaign-catalog.mjs`, cada uma amarrada a um segmento específico. A
Comercial tem 57 segmentos e 4 produtos (G-Chat, Sistema de Ponto, ERP,
G-Delivery) — catálogo fixo não cabe nessa escala. Aqui, campanha precisa ser
um registro que o operador cria pela tela (produto + segmentos + textos),
não código.
Decisões já validadas com o usuário:
- Campanhas rodam **ao lado** do `runOutreachBatch` existente, sem substituí-lo.
- Variantes A/B/C só funcionam no canal **não-oficial** (o oficial não aceita
texto livre, então não há o que variar).
- Pacote completo: escalonamento semanal, fila/preview/aprovação, variantes com
eleição de vencedora, pausa automática por saúde, classificação de estágio
comercial, agendamento de demonstração.
- Classificação de estágio e registro de resposta são **manuais** por enquanto —
não existe endpoint de webhook de resposta do Z-PRO implementado nesta base.
## Arquitetura
Novo diretório `src/outreach/campaign-*.mjs`, paralelo a `run.mjs`/`scheduler.mjs`
— nenhum dos dois é modificado. Único ponto de integração: `outreach_sends`
ganha um novo valor de `origem` (`'campanha'`, além dos já existentes
`'automatico'`/`'manual'`) e a seleção de candidatos de campanha exclui leads
recentemente contatados para o mesmo produto **por qualquer origem**, evitando
contato duplicado entre a automação existente e as campanhas novas.
```
src/outreach/
run.mjs (existente, intocado)
scheduler.mjs (existente, intocado)
zpro-client.mjs (existente, intocado — reusado)
templates.mjs (existente, intocado — renderTemplate reusado)
campaign-catalog.mjs (novo — CRUD de outreach_campaigns/variants)
campaign-queue.mjs (novo — seleção de candidatos, preview, aprovação)
campaign-worker.mjs (novo — envio das linhas aprovadas)
campaign-metrics.mjs (novo — saúde, auto-pausa, comparação de variantes)
campaign-scheduler.mjs (novo — timer próprio, chama campaign-worker)
```
## Modelo de dados
Migração nova em `src/db.mjs` (mesmo padrão das migrações v5-v7 já existentes:
`ALTER TABLE`/`CREATE TABLE IF NOT EXISTS` guardado por versão do schema).
```sql
CREATE TABLE outreach_campaigns (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
produto TEXT NOT NULL,
segmentos_json TEXT NOT NULL, -- ["Oficina", "Restaurante", ...]
status TEXT NOT NULL DEFAULT 'rascunho', -- rascunho|rodando|pausado|concluido
pause_reason TEXT,
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
semana_iniciada_em TEXT, -- controla quando escalar o limite
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_campaign_variants (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
code TEXT NOT NULL, -- A|B|C
mensagem TEXT NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE outreach_campaign_queue (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
lead_id INTEGER NOT NULL REFERENCES leads(id),
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
segmento TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'preview', -- preview|approved|cancelled|reserved|sent|unknown|error
outreach_send_id INTEGER REFERENCES outreach_sends(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_lead_states (
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
stage TEXT NOT NULL, -- interessado|sem_interesse|opt_out|venda_fechada|...
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_stage_events (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
campaign_id INTEGER REFERENCES outreach_campaigns(id),
stage TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_appointments (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
kind TEXT NOT NULL, -- demonstracao|teste_g_delivery|...
scheduled_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_opt_out (
numero TEXT PRIMARY KEY,
motivo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE outreach_sends ADD COLUMN campaign_queue_id INTEGER REFERENCES outreach_campaign_queue(id);
```
`outreach_opt_out` não existe hoje na Comercial (a Pessoal tem um equivalente
mais complexo por causa da equivalência de números 8/9 dígitos). Aqui basta uma
tabela simples por `numero` já normalizado (`onlyDigits`), sem a complexidade de
variantes de número da Pessoal — a Comercial já normaliza números na validação
(`validatePhone`/`onlyDigits`). Está incluída porque uma fila que recontata o
mesmo lead ao longo de semanas sem checar opt-out é um risco real de spam/banimento,
não porque foi pedida explicitamente — sinalizando aqui para confirmação.
## Fluxo
1. **Criar campanha**: operador escolhe produto, segmentos (lista vindo de
`SELECT DISTINCT segmento, COUNT(*) FROM leads WHERE produto_principal = ?
GROUP BY segmento` — mesmo padrão de facet já usado em outros lugares do
projeto), escreve 1-3 variantes de mensagem (texto livre, mesmas variáveis
de `templates.mjs`: `{empresa}`, `{produto}`, `{cidade}`, `{segmento}`),
define limite diário inicial.
2. **Gerar fila**: candidatos = leads do produto/segmentos escolhidos, com
`telefone_valido=1`, sem envio recente (`outreach_sends`, qualquer origem)
para o mesmo produto, sem opt-out. Distribuição round-robin entre variantes
ativas. Linhas entram como `preview` — nada é enviado.
3. **Revisar e aprovar**: tela lista a fila em preview (empresa, telefone,
variante, mensagem já renderizada); operador aprova em lote ou individualmente
→ status `approved`. Só linhas aprovadas entram no worker.
4. **Envio**: `campaign-worker.mjs`, rodando no `campaign-scheduler.mjs` (timer
próprio, mesmo padrão do `scheduler.mjs` existente — `setInterval` com
`.unref()`), pega linhas `approved` respeitando `limite_diario_atual` da
campanha, revalida o lead na hora (ainda não opt-out, ainda no segmento),
envia via `zproClient.sendText`, grava em `outreach_sends`
(`origem='campanha'`, `campaign_queue_id` preenchido), atualiza a linha da
fila para `sent`/`error`.
5. **Escalonamento semanal**: a cada 7 dias corridos sem pausa por saúde desde
`semana_iniciada_em`, `limite_diario_atual` dobra (10→20→40...), até um teto
configurável.
6. **Saúde/auto-pausa** (`campaign-metrics.mjs`, mesmos 3 critérios da Pessoal,
adaptados às tabelas daqui):
- taxa de erro de envio > 10% com pelo menos 10 tentativas no dia → pausa
`high_failure_rate`
- taxa de estágio negativo/opt-out > 10% com pelo menos 20 contatos → pausa
`high_negative_rate`
- taxa de resposta < 5% com pelo menos 50 contatos → pausa `low_reply_rate`
(mesmos limiares numéricos já usados em `campaign-metrics.mjs` da Pessoal)
Pausa muda `status` para `pausado`; o worker para de pegar novas linhas
dessa campanha até o operador retomar manualmente.
7. **Variantes e eleição de vencedora**: após um mínimo de 40 envios por
variante, a tela mostra taxa de resposta por variante e sinaliza a de melhor
desempenho — indicativo, não desliga variantes sozinho.
8. **Classificação de estágio e agendamento**: 100% manual por enquanto —
operador marca o estágio do lead pela tela (gera `outreach_stage_events` +
atualiza `outreach_lead_states`) e cria agendamentos (`outreach_appointments`)
manualmente. Sem webhook de resposta do Z-PRO nesta fase.
## Prevenção de contato duplicado com a automação existente
`runOutreachBatch` (`run.mjs`) já verifica `lastEnviado` antes de mandar. A
seleção de candidatos de campanha faz a checagem simétrica: exclui qualquer
lead com `outreach_sends` para o mesmo produto nos últimos N dias, **indepen-
dente de `origem`**. Isso garante que uma campanha nunca contata quem o lote
automático já contatou recentemente para aquele produto, e vice-versa — os
dois sistemas competem pelo mesmo "direito de contato" por produto, não
disparam em paralelo para o mesmo lead.
## Fora de escopo (explicitamente adiado)
- Webhook de resposta do Z-PRO / classificação automática de estágio.
- Envio de variantes pelo canal oficial (impossível hoje — WABA não aceita
texto livre).
- Equivalência de números 8/9 dígitos ou detecção de número compartilhado
(não existe na Comercial hoje e não foi pedido).
## Testes
Mesma disciplina da Pessoal: um arquivo de teste por módulo novo
(`campaign-catalog`, `campaign-queue`, `campaign-worker`, `campaign-metrics`),
cobrindo pelo menos: geração de fila respeita segmentos/produto/exclusões,
aprovação só libera linhas aprovadas para o worker, worker respeita limite
diário e revalida antes de enviar, auto-pausa dispara nos 3 critérios e
bloqueia novas linhas, escalonamento semanal avança e não avança durante
pausa, prevenção de contato duplicado entre `origem='automatico'` e
`origem='campanha'`.
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GTI-Comercial</AssemblyName>
<RootNamespace>GtiComercialLauncher</RootNamespace>
</PropertyGroup>
</Project>
+198
View File
@@ -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;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "gti-maquina-comercial",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gti-maquina-comercial",
"version": "2.0.0",
"engines": {
"node": ">=24"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "gti-maquina-comercial",
"version": "2.0.0",
"private": true,
"type": "module",
"description": "GTI Maquina Comercial: aplicativo local de captura e tratamento de leads B2B (base CNPJ da Receita Federal + fontes abertas).",
"scripts": {
"start": "node scripts/servidor.mjs",
"test": "node --test tests/*.test.mjs",
"capture": "node scripts/capturar-leads.mjs",
"extract": "node scripts/extrair-base-cnpj.mjs",
"recalc-scores": "node scripts/recalcular-scores.mjs",
"outreach": "node scripts/enviar-outreach.mjs",
"build:launcher": "powershell -File scripts/build-launcher.ps1"
},
"engines": {
"node": ">=24"
}
}
@@ -0,0 +1,33 @@
Coloque aqui arquivos CSV extraidos da base publica de CNPJ da Receita Federal ou uma planilha ja tratada.
O captador aceita CSV com cabecalho e tenta reconhecer colunas comuns como:
- `cnpj`
- `razao_social` ou `razao social`
- `nome_fantasia` ou `nome fantasia`
- `telefone`, `telefone 1`, `ddd`, `ddd 1`
- `municipio` ou `cidade`
- `uf`
- `cnae`, `cnae_fiscal` ou `cnae principal`
- `logradouro`, `numero`, `bairro`
Comando para extrair a base de uma UF a partir dos zips brutos da Receita
(gera o CSV `estabelecimentos-<UF>.csv` usado pela captura):
```bash
npm run extract -- --uf=BA
```
Comando para capturar leads usando a base já extraída:
```bash
npm run capture -- --sources=cnpj-base --uf=BA --cities="Ilheus"
```
Com enriquecimento pela BrasilAPI, inclua a fonte `brasilapi`:
```bash
npm run capture -- --sources=cnpj-base,brasilapi --uf=BA --cities="Ilheus"
```
O modo `brasilapi` usa os CNPJs encontrados nos CSVs locais para consultar dados atualizados por CNPJ.
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
/* GTI Maquina Comercial - interface principal */
:root {
--page: #f5f7fb;
--page-2: #eef2f7;
--surface: #ffffff;
--surface-2: #f9fafc;
--ink: #162033;
--ink-2: #4c5870;
--muted: #7b8495;
--grid: #dce2ec;
--border: rgba(22, 32, 51, 0.11);
--accent: #f06a25;
--accent-2: #1f6feb;
--accent-ink: #ffffff;
--bar: #f06a25;
--good: #10a66a;
--good-text: #057046;
--warning: #d99a1f;
--critical: #d63d4a;
--critical-bg: #fff1f2;
--radius-sm: 10px;
--radius: 16px;
--radius-lg: 24px;
--shadow: 0 14px 40px rgba(22, 32, 51, 0.08);
--shadow-soft: 0 8px 24px rgba(22, 32, 51, 0.06);
}
@media (prefers-color-scheme: dark) {
:root {
--page: #0d1118;
--page-2: #111827;
--surface: #171c27;
--surface-2: #1d2430;
--ink: #f5f7fb;
--ink-2: #c8d0dd;
--muted: #8e98aa;
--grid: #2c3443;
--border: rgba(255, 255, 255, 0.11);
--accent: #ff7a33;
--accent-2: #60a5fa;
--bar: #ff7a33;
--good-text: #55d99c;
--critical-bg: rgba(214, 61, 74, 0.12);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.22);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
color: var(--ink);
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 34rem),
radial-gradient(circle at 86% -6%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 30rem),
linear-gradient(180deg, var(--page), var(--page-2));
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
/* topo */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto auto auto auto;
align-items: center;
gap: 12px;
padding: 12px 22px;
background: color-mix(in srgb, var(--surface) 88%, transparent);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 30px rgba(22, 32, 51, 0.04);
backdrop-filter: blur(14px);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: max-content; }
.brand-mark { height: 32px; width: auto; display: block; border-radius: 8px; }
.brand-mark-lg { height: 62px; }
.brand-name { font-weight: 800; letter-spacing: -0.02em; color: var(--ink); }
.tabs {
display: inline-flex;
gap: 6px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
width: fit-content;
}
.tab {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-2);
cursor: pointer;
font-weight: 750;
padding: 8px 14px;
transition: 0.18s ease;
}
.tab:hover { color: var(--ink); background: color-mix(in srgb, var(--accent) 9%, transparent); }
.tab.active { background: var(--ink); color: var(--page); box-shadow: var(--shadow-soft); }
main { max-width: 1320px; margin: 0 auto; padding: 24px 22px 70px; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* layout */
.captura-wrap { max-width: 820px; margin: 0 auto; }
.card {
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--surface) 96%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 20px;
margin-bottom: 18px;
}
.card h2 { margin: 0 0 14px; font-size: 18px; letter-spacing: -0.02em; }
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
/* formularios */
.field { margin-bottom: 18px; }
.field > label:first-child,
.field > summary,
label.settings-title {
display: block;
margin-bottom: 7px;
color: var(--ink);
font-weight: 800;
letter-spacing: -0.01em;
}
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row.space-between { justify-content: space-between; }
.row.wrap { flex-wrap: wrap; }
label { color: var(--ink-2); }
label > input,
label > select,
label > textarea { display: block; margin-top: 5px; }
.radio {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: var(--ink-2);
cursor: pointer;
user-select: none;
}
input[type="radio"], input[type="checkbox"] { accent-color: var(--accent); }
select, input, textarea {
width: auto;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--grid);
border-radius: 12px;
padding: 10px 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
textarea { width: 100%; resize: vertical; min-height: 84px; }
select:hover, input:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--grid)); }
select:focus, input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent);
}
input[type="number"] { width: 96px; }
details summary { cursor: pointer; }
/* botoes */
.btn, .btn-ghost, .btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
border-radius: 12px;
padding: 9px 15px;
border: 1px solid var(--grid);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font-weight: 800;
text-decoration: none;
transition: 0.18s ease;
}
.btn:hover, .btn-ghost:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--shadow-soft); }
.btn-primary { background: linear-gradient(135deg, var(--accent), #ff8b47); border-color: transparent; color: var(--accent-ink); }
.btn-primary:hover { filter: saturate(1.08); }
.btn-lg { width: 100%; min-height: 48px; font-size: 15px; }
.btn-sm { min-height: 32px; padding: 6px 10px; font-size: 12.5px; }
.btn-ghost { border-color: transparent; background: transparent; color: var(--ink-2); }
.btn-danger { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 45%, var(--grid)); background: color-mix(in srgb, var(--critical) 6%, transparent); }
.btn-danger:hover { background: var(--critical); color: #fff; border-color: var(--critical); }
button:disabled { opacity: 0.55; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
/* configuracoes */
#modal-settings { max-width: 900px; }
#modal-settings .modal-body { max-height: min(86vh, 900px); overflow-y: auto; }
.settings-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.settings-header h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.03em; }
.settings-header p { margin: 0; }
#outreach-status { cursor: default; white-space: nowrap; }
.settings-card,
.settings-section {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.34) inset;
margin-bottom: 12px;
}
.settings-card { padding: 16px; }
.settings-principal {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface-2));
}
.settings-controls { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; justify-content: flex-end; }
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid var(--grid);
border-radius: 999px;
padding: 8px 13px;
background: var(--surface);
color: var(--ink);
font-weight: 800;
}
.settings-section { padding: 0 16px 16px; }
.settings-section > summary {
position: relative;
list-style: none;
padding: 15px 30px 15px 0;
color: var(--ink);
font-weight: 850;
letter-spacing: -0.01em;
}
.settings-section > summary::-webkit-details-marker { display: none; }
.settings-section > summary::after {
content: "+";
position: absolute;
right: 0;
top: 13px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--grid);
}
.settings-section[open] > summary::after { content: "-"; }
.settings-section > .muted { margin-top: -4px; }
.settings-section .lista { margin-top: 10px; }
.settings-section input[type="text"],
.settings-section input[type="password"],
.settings-section textarea,
.settings-section select { min-width: 220px; }
.manual-send-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.manual-send-card .full { grid-column: 1 / -1; }
.manual-channel {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.manual-channel .radio {
border: 1px solid var(--grid);
border-radius: 999px;
padding: 8px 12px;
background: var(--surface-2);
font-weight: 750;
}
#manual-oficial-campos {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
#manual-oficial-campos.hidden { display: none !important; }
#manual-template-nome { min-width: 0; width: 100%; }
.template-vars {
padding: 12px;
border: 1px dashed var(--grid);
border-radius: var(--radius);
background: var(--surface-2);
}
.template-vars > label { color: var(--ink); font-weight: 800; }
.template-vars p { margin: 2px 0 10px; }
.variable-helper {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.variable-helper select, .variable-picker { min-width: min(100%, 320px); }
.template-vars-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.template-vars-grid label:last-child { grid-column: 1 / -1; }
.template-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
color: var(--ink-2);
white-space: pre-wrap;
}
.manual-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
#manual-status:not(:empty) {
display: block;
width: 100%;
padding: 10px 12px;
border-radius: 12px;
background: var(--surface-2);
border: 1px solid var(--border);
}
#manual-status:not(:empty)::first-letter { font-weight: 900; }
#manual-status:not(:empty) { color: var(--ink-2); }
#manual-status.status-ok { color: var(--good-text); background: color-mix(in srgb, var(--good) 10%, var(--surface)); border-color: color-mix(in srgb, var(--good) 28%, var(--border)); }
#manual-status.status-error { color: var(--critical); background: var(--critical-bg); border-color: color-mix(in srgb, var(--critical) 30%, var(--border)); }
#btn-toggle-outreach-log { justify-self: start; margin-top: 2px; }
.outreach-template { flex-direction: column; align-items: stretch; gap: 8px; }
.outreach-template .row.space-between { align-items: center; }
.outreach-template textarea { width: 100%; margin-top: 2px; }
.outreach-template-oficial { display: flex; flex-direction: column; gap: 8px; }
.outreach-template-vars { margin-top: 2px; }
.outreach-template-vars .template-vars-grid { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.outreach-template-vars .template-vars-grid label:last-child { grid-column: auto; }
.outreach-template-link { color: var(--accent-2); font-size: 12.5px; font-weight: 750; text-decoration: none; }
.outreach-template-link:hover { text-decoration: underline; }
.outreach-template-aviso { color: var(--critical); font-size: 12.5px; margin: 0; }
#outreach-limite { width: 82px; }
.zona-risco { border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); background: color-mix(in srgb, var(--critical) 5%, var(--surface)); }
.zona-risco summary { color: var(--critical); }
.template-sync-box {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--grid);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.45fr);
gap: 12px;
}
.template-sync-box .row { grid-column: 1 / -1; }
.template-sync-inline {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
/* login */
.login-body {
display: flex;
min-height: 100vh;
background:
radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 28rem),
radial-gradient(circle at 82% 4%, color-mix(in srgb, var(--accent-2) 18%, transparent), transparent 26rem),
linear-gradient(135deg, var(--page), var(--page-2));
}
.login-wrap { margin: auto; width: 100%; max-width: 410px; padding: 22px; }
.login-card { display: flex; flex-direction: column; gap: 14px; padding: 28px; }
.login-brand { justify-content: center; margin-bottom: 6px; }
.login-erro { color: var(--critical); min-height: 1em; margin: 0; }
.erro { color: var(--critical); margin: 4px 0 0; font-weight: 750; }
.login-card .field input { width: 100%; }
/* chips, avisos e listas */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink-2);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 800;
}
.chip.ok { background: color-mix(in srgb, var(--good) 12%, var(--surface)); color: var(--good-text); border-color: color-mix(in srgb, var(--good) 26%, var(--border)); }
.chip.warn { background: color-mix(in srgb, var(--warning) 14%, var(--surface)); color: var(--ink); border-color: color-mix(in srgb, var(--warning) 32%, var(--border)); }
.notice {
margin-top: 12px;
padding: 14px;
border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
}
.notice p { margin: 0 0 10px; }
.lista { display: flex; flex-direction: column; gap: 8px; }
.lista .item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.lista .item .sub { color: var(--muted); font-size: 12px; }
/* pickers */
.picker { margin-top: 10px; }
.picker input[type="search"], .cnae-avulso input[type="search"] { width: 100%; margin-bottom: 8px; }
.sugestoes { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; }
.sugestoes button {
text-align: left;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--ink);
cursor: pointer;
padding: 8px 10px;
}
.sugestoes button:hover { background: var(--surface-2); }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
.chips .chip { cursor: pointer; }
.chips .chip::after { content: " x"; color: var(--muted); }
.chips .chip:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
.segmento-lista { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
.segmento-opcao {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink-2);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.nichos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 4px 12px;
max-height: 320px;
overflow-y: auto;
margin-top: 10px;
}
.nichos-grid label { display: flex; gap: 7px; align-items: center; padding: 6px 7px; border-radius: 10px; cursor: pointer; }
.nichos-grid label:hover { background: var(--surface-2); }
.cnae-avulso { margin-top: 14px; border-top: 1px dashed var(--grid); padding-top: 12px; }
.cnae-avulso summary { color: var(--ink-2); font-size: 13px; font-weight: 800; }
.cnae-avulso-hint { margin: 7px 0 9px; font-size: 12.5px; }
.cnae-avulso .sugestoes button { display: flex; justify-content: space-between; gap: 8px; }
.cnae-avulso .sugestoes .cod { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
.fontes { display: flex; flex-direction: column; gap: 8px; }
.fonte {
display: flex;
gap: 11px;
align-items: flex-start;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 11px 12px;
background: var(--surface);
cursor: pointer;
}
.fonte:hover { border-color: var(--accent); box-shadow: var(--shadow-soft); }
.fonte input { margin-top: 3px; }
.fonte .desc { color: var(--ink-2); font-size: 12.5px; }
.fonte.indisponivel { opacity: 0.58; }
/* progresso */
.progress-bar { height: 10px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 12px 0 9px; }
.progress-fill { height: 100%; width: 3%; background: linear-gradient(90deg, var(--accent), #ffb36d); border-radius: 999px; transition: width 0.4s; }
.progress-msg { color: var(--ink-2); margin: 4px 0 11px; min-height: 1.4em; }
.stat-row { display: flex; gap: 18px; margin-bottom: 12px; flex-wrap: wrap; }
.stat-row .mini { display: flex; flex-direction: column; min-width: 96px; }
.stat-row .mini b { font-size: 23px; letter-spacing: -0.03em; }
.stat-row .mini span { color: var(--muted); font-size: 12px; }
.log {
max-height: 170px;
overflow-y: auto;
white-space: pre-wrap;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0f172a;
color: #dbeafe;
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* tratamento */
.filtros-card { padding: 14px; }
.filtros { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 9px; align-items: center; }
.filtros select, .filtros input { width: 100%; min-width: 0; }
#f-busca { grid-column: span 2; }
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 18px; }
.tile {
position: relative;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-soft);
}
.tile::after {
content: "";
position: absolute;
inset: auto -20px -28px auto;
width: 96px;
height: 96px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.tile .valor { font-size: 29px; font-weight: 900; letter-spacing: -0.04em; }
.tile .rotulo { color: var(--ink-2); font-size: 12.5px; font-weight: 750; margin-top: 2px; }
.tile .detalhe { color: var(--muted); font-size: 12px; }
.tile.t-good .valor { color: var(--good-text); }
.tile.tile-wide { grid-column: span 2; }
.meta-input { width: 78px; text-align: right; padding: 5px 7px; }
.ranking, .funil { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
.rank-row, .funil-etapa {
display: grid;
grid-template-columns: 150px 1fr 70px;
align-items: center;
gap: 9px;
padding: 4px 6px;
border-radius: 10px;
}
.funil-etapa { grid-template-columns: 190px 1fr 70px; }
.rank-row { cursor: pointer; }
.rank-row:hover, .oportunidade-clicavel:hover { background: var(--surface-2); }
.rank-row.ativo, .oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 13%, var(--surface)); font-weight: 800; }
.rank-row.nao-clicavel { cursor: default; }
.rank-row.nao-clicavel:hover { background: transparent; }
.funil-etapa.pendente { opacity: 0.43; }
.rank-label, .funil-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.rank-track, .funil-barra { height: 12px; overflow: hidden; border-radius: 999px; background: var(--grid); }
.rank-fill, .funil-fill { height: 100%; min-width: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--bar), #ffb36d); }
.rank-value, .funil-valor { color: var(--ink-2); text-align: right; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.oportunidade-clicavel { cursor: pointer; border-radius: 12px; padding: 8px 10px; }
/* tabela */
.table-wrap { overflow-x: auto; margin: 0 -6px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; }
th, td { text-align: left; white-space: nowrap; padding: 10px 12px; border-bottom: 1px solid var(--grid); }
th {
color: var(--muted);
background: var(--surface);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.acao-col {
position: sticky;
left: 0;
z-index: 3;
width: 58px;
min-width: 58px;
text-align: center;
background: var(--surface);
box-shadow: 10px 0 18px -18px rgba(22, 32, 51, 0.8);
}
td.acao-col { padding-left: 8px; padding-right: 8px; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
tbody tr:hover .acao-col { background: color-mix(in srgb, var(--accent) 6%, var(--surface)); }
.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 850; white-space: nowrap; }
.badge.excelente { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); }
.badge.bom { background: color-mix(in srgb, var(--good) 12%, var(--warning) 8%); color: var(--good-text); }
.badge.medio { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
.badge.baixo { background: color-mix(in srgb, var(--warning) 16%, var(--critical) 10%); color: var(--critical); }
.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 14%, transparent); color: var(--critical); }
.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); }
.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
.badge.prioridade-3 { background: var(--grid); color: var(--ink-2); }
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
.produto-cell { font-size: 12.5px; }
.estrelas { color: var(--warning); letter-spacing: 1px; white-space: nowrap; }
.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.whatsapp-action {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--good) 35%, var(--grid));
border-radius: 999px;
background: color-mix(in srgb, var(--good) 12%, var(--surface));
color: var(--good-text);
cursor: pointer;
font-size: 17px;
line-height: 1;
}
.whatsapp-action:hover { background: var(--good); color: #fff; }
.pontos-fortes, .score-breakdown { list-style: none; margin: 7px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.resumo-comercial, .score-breakdown-wrap { border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 14px; margin-top: 12px; background: var(--surface-2); }
.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; }
.score-breakdown li.nok { color: var(--muted); }
/* usuarios */
#lista-usuarios { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
#lista-usuarios > div { padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); }
/* modais e toast */
dialog {
width: calc(100vw - 40px);
max-width: 600px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(8, 13, 22, 0.58); backdrop-filter: blur(4px); }
.modal-body { padding: 24px; }
.modal-body h2 { margin-top: 0; letter-spacing: -0.03em; }
#modal-historico { max-width: 680px; }
#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; }
.toast {
position: fixed;
left: 50%;
bottom: 24px;
z-index: 100;
max-width: min(86vw, 680px);
transform: translateX(-50%);
padding: 11px 16px;
border-radius: 999px;
background: var(--ink);
color: var(--page);
box-shadow: var(--shadow);
font-weight: 800;
}
.hidden { display: none !important; }
.muted { color: var(--muted); font-weight: 400; }
.detalhe-grid { display: grid; grid-template-columns: 150px 1fr; gap: 6px 14px; font-size: 13.5px; }
.detalhe-grid dt { color: var(--muted); }
.detalhe-grid dd { margin: 0; word-break: break-word; }
@media (max-width: 1100px) {
.grid-3 { grid-template-columns: 1fr 1fr; }
.topbar { grid-template-columns: 1fr auto auto auto; }
.tabs { grid-column: 1 / -1; order: 2; width: 100%; justify-content: center; }
#usuario-logado { justify-self: end; }
}
@media (max-width: 780px) {
main { padding: 16px 12px 50px; }
.topbar { padding: 10px 12px; grid-template-columns: 1fr auto auto; gap: 8px; }
.brand-name { display: none; }
#usuario-logado { display: none; }
.tabs { overflow-x: auto; justify-content: flex-start; }
.tab { white-space: nowrap; }
.grid-3 { grid-template-columns: 1fr; }
.card { border-radius: var(--radius); padding: 16px; }
.settings-header, .settings-principal { grid-template-columns: 1fr; }
.settings-controls { justify-content: flex-start; }
.manual-send-card, #manual-oficial-campos { grid-template-columns: 1fr; }
.settings-section input[type="text"],
.settings-section input[type="password"],
.settings-section textarea,
.settings-section select,
.manual-send-card input,
.manual-send-card select { width: 100%; min-width: 0; }
.manual-actions { align-items: stretch; flex-direction: column; }
.manual-actions .btn { width: 100%; }
#f-busca, .tile.tile-wide { grid-column: span 1; }
.rank-row, .funil-etapa { grid-template-columns: 1fr 90px; }
.rank-track, .funil-barra { grid-column: 1 / -1; order: 3; }
th { top: 107px; }
.modal-body { padding: 18px; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+566
View File
@@ -0,0 +1,566 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GTI Máquina Comercial</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="/img/logo-gti.png">
</head>
<body>
<header class="topbar">
<div class="brand">
<img class="brand-mark" src="/img/logo-gti.png" alt="GTI">
<span class="brand-name">Máquina Comercial</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="captura" role="tab" aria-selected="true">📡 Capturar Leads</button>
<button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
<button class="tab" data-tab="campanhas" role="tab" aria-selected="false">🎯 Campanhas</button>
</nav>
<span class="muted" id="usuario-logado"></span>
<button class="btn-ghost hidden" id="btn-usuarios" title="Usuários">👤 Usuários</button>
<button class="btn-ghost" id="btn-settings" title="Configurações">⚙️</button>
<button class="btn-ghost" id="btn-sair" title="Sair">🚪 Sair</button>
</header>
<main>
<!-- ==================== CAPTURA ==================== -->
<section id="tab-captura" class="tab-panel active">
<div class="captura-wrap">
<div class="card" id="captura-form">
<div class="row space-between">
<h2>Nova captura</h2>
<button class="btn-ghost" id="btn-ver-historico" type="button">🕓 Histórico de capturas</button>
</div>
<div class="field">
<label for="sel-uf">1. Estado (UF)</label>
<div class="row">
<select id="sel-uf"></select>
<span id="uf-status" class="chip"></span>
</div>
<div id="extracao-aviso" class="notice hidden">
<p>A base CNPJ de <strong id="extracao-uf"></strong> ainda não foi extraída neste computador.
A extração lê os arquivos da Receita Federal e demora <strong>~40 a 60 minutos</strong> (uma única vez por estado).</p>
<button class="btn" id="btn-extrair">⛏️ Extrair base agora</button>
</div>
</div>
<div class="field">
<label>2. Cidades</label>
<div class="row">
<label class="radio"><input type="radio" name="cidades-modo" value="todas" checked> Todas as cidades da UF</label>
<label class="radio"><input type="radio" name="cidades-modo" value="escolher"> Escolher cidades</label>
</div>
<div id="cidades-picker" class="picker hidden">
<input type="search" id="busca-cidade" placeholder="Digite para buscar cidade...">
<div id="cidades-sugestoes" class="sugestoes"></div>
<div id="cidades-chips" class="chips"></div>
</div>
</div>
<div class="field">
<label>3. Nichos <span class="muted" id="nichos-contagem"></span></label>
<div class="row">
<label class="radio"><input type="radio" name="nichos-modo" value="todos" checked> Todos os nichos</label>
<label class="radio"><input type="radio" name="nichos-modo" value="escolher"> Escolher nichos</label>
</div>
<div id="nichos-picker" class="picker hidden">
<div class="row">
<input type="search" id="busca-nicho" placeholder="Filtrar nichos...">
<button class="btn-ghost" id="btn-nichos-todos" type="button">Marcar visíveis</button>
<button class="btn-ghost" id="btn-nichos-nenhum" type="button">Desmarcar todos</button>
</div>
<div id="nichos-grid" class="nichos-grid"></div>
</div>
<details class="cnae-avulso">
<summary>Não achou o nicho? Buscar qualquer atividade da Receita Federal (CNAE)</summary>
<p class="muted cnae-avulso-hint">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.</p>
<input type="search" id="busca-cnae" placeholder="Ex.: salão de beleza, transportadora, escola...">
<div id="cnae-sugestoes" class="sugestoes"></div>
<div id="cnae-chips" class="chips"></div>
</details>
</div>
<div class="field">
<label>4. Fontes de busca</label>
<div id="fontes-lista" class="fontes"></div>
</div>
<details class="field">
<summary>Opções avançadas</summary>
<div class="row">
<label>Máx. por consulta (fontes de API)
<input type="number" id="inp-max" value="10" min="1" max="60">
</label>
<label>Limite total de consultas (0 = sem limite)
<input type="number" id="inp-query-limit" value="0" min="0">
</label>
</div>
</details>
<button class="btn btn-primary btn-lg" id="btn-capturar">🚀 Iniciar captura</button>
</div>
<div class="card hidden" id="progresso-card">
<div class="row space-between">
<h2 id="progresso-titulo">Captura em andamento</h2>
<button class="btn-danger" id="btn-cancelar">Cancelar</button>
</div>
<div class="progress-bar"><div class="progress-fill" id="progresso-fill"></div></div>
<p class="progress-msg" id="progresso-msg">iniciando...</p>
<div class="stat-row" id="progresso-stats"></div>
<div class="log" id="progresso-log"></div>
</div>
</div>
</section>
<!-- ==================== TRATAMENTO ==================== -->
<section id="tab-tratamento" class="tab-panel">
<div class="card filtros-card">
<div class="filtros" id="filtros">
<select id="f-uf" data-filtro="uf"><option value="">UF: todas</option></select>
<input list="dl-cidades" id="f-cidade" data-filtro="cidade" placeholder="Cidade: todas">
<datalist id="dl-cidades"></datalist>
<select id="f-segmento" data-filtro="segmento"><option value="">Nicho: todos</option></select>
<select id="f-classificacao" data-filtro="classificacao">
<option value="">Classificação: todas</option>
<option value="excelente">🟢 Excelente</option>
<option value="bom">🟢 Bom</option>
<option value="medio">🟡 Médio</option>
<option value="baixo">🟠 Baixo</option>
<option value="muito_baixo">🔴 Muito Baixo</option>
</select>
<select id="f-prioridade" data-filtro="prioridade">
<option value="">Prioridade: todas</option>
<option value="1">P1 — atacar agora</option>
<option value="2">P2 — esta semana</option>
<option value="3">P3 — quando sobrar</option>
<option value="4">P4 — campanhas específicas</option>
</select>
<select id="f-produto" data-filtro="produto"><option value="">Produto: todos</option></select>
<select id="f-interesse" data-filtro="interesse"><option value="">Interesse: todos</option></select>
<select id="f-telefone" data-filtro="telefone_valido">
<option value="">WhatsApp: todos</option>
<option value="sim">Com WhatsApp</option>
<option value="nao">Sem WhatsApp</option>
</select>
<select id="f-website" data-filtro="website">
<option value="">Website: todos</option>
<option value="sim">Com website</option>
<option value="nao">Sem website</option>
</select>
<select id="f-email" data-filtro="email">
<option value="">E-mail: todos</option>
<option value="sim">Com e-mail</option>
<option value="nao">Sem e-mail</option>
</select>
<select id="f-exportado" data-filtro="exportado">
<option value="">Exportação: todos</option>
<option value="nao">Ainda não exportados</option>
<option value="sim">Já exportados</option>
</select>
<input type="search" id="f-busca" data-filtro="q" placeholder="🔎 empresa, CNPJ ou WhatsApp">
<button class="btn-ghost" id="btn-limpar-filtros">Limpar</button>
</div>
</div>
<div class="stat-tiles" id="stat-tiles"></div>
<div class="grid-3">
<div class="card">
<div class="row space-between">
<h2>Top cidades</h2>
</div>
<div id="rank-cidades" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2>Top nichos</h2>
</div>
<div id="rank-segmentos" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2 id="rank-produtos-titulo">Top produto recomendado</h2>
</div>
<div id="rank-produtos" class="ranking"></div>
</div>
</div>
<div class="grid-3">
<div class="card" id="card-oportunidades-dia"></div>
<div class="card" id="card-maior-oportunidade"></div>
<div class="card" id="card-evolucao-diaria"></div>
</div>
<div class="card" id="card-funil"></div>
<div class="card">
<div class="row space-between wrap">
<h2>Leads <span class="muted" id="leads-total"></span></h2>
<button class="btn btn-primary" id="btn-exportar">📤 Gerar relatório / exportação com este filtro</button>
</div>
<div class="table-wrap">
<table id="tabela-leads">
<thead>
<tr>
<th class="acao-col">Ação</th><th>Empresa</th><th>WhatsApp</th><th>Cidade</th><th>Nicho</th>
<th>Prioridade</th><th class="num">Geral</th>
<th>Classificação</th><th>Produto</th><th>Exportado</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="row space-between">
<button class="btn-ghost" id="pg-prev">← Anterior</button>
<span class="muted" id="pg-info"></span>
<button class="btn-ghost" id="pg-next">Próxima →</button>
</div>
</div>
<div class="card">
<h2>Exportações geradas</h2>
<div id="historico-exports" class="lista"></div>
</div>
</section>
<!-- ==================== CAMPANHAS ==================== -->
<section id="tab-campanhas" class="tab-panel">
<div class="row space-between">
<h2>Campanhas de outreach</h2>
<button class="btn-ghost btn-sm" id="btn-nova-campanha" type="button">+ Nova campanha</button>
</div>
<div id="campanhas-lista" class="lista"></div>
<div id="campanha-detalhe" class="card hidden">
<div class="row space-between">
<h2 id="detalhe-nome"></h2>
<div class="row">
<span class="chip" id="detalhe-status"></span>
<button class="btn-ghost btn-sm" id="btn-fechar-detalhe" type="button">Fechar</button>
</div>
</div>
<div class="row" id="detalhe-acoes">
<button class="btn-ghost btn-sm" id="btn-iniciar-campanha" type="button">Iniciar</button>
<button class="btn-ghost btn-sm" id="btn-pausar-campanha" type="button">Pausar</button>
<button class="btn-ghost btn-sm" id="btn-retomar-campanha" type="button">Retomar</button>
<button class="btn-ghost btn-sm" id="btn-gerar-fila" type="button">Gerar fila</button>
</div>
<p class="muted" id="detalhe-saude"></p>
<div id="detalhe-variantes" class="row"></div>
<div class="row space-between">
<h3>Fila</h3>
<select id="fila-filtro-status">
<option value="preview">Preview</option>
<option value="approved">Aprovadas</option>
<option value="sent">Enviadas</option>
<option value="error">Erro</option>
<option value="cancelled">Canceladas</option>
</select>
</div>
<div class="row">
<button class="btn-ghost btn-sm" id="btn-aprovar-selecionados" type="button">Aprovar selecionados</button>
<button class="btn-ghost btn-sm" id="btn-cancelar-selecionados" type="button">Cancelar selecionados</button>
</div>
<table>
<thead><tr><th></th><th>Lead ID</th><th>Numero</th><th>Mensagem</th><th>Status</th><th>Estagio</th></tr></thead>
<tbody id="fila-tabela"></tbody>
</table>
</div>
<dialog id="modal-nova-campanha">
<form id="form-nova-campanha" method="dialog">
<h2>Nova campanha</h2>
<div class="field">
<label for="campanha-nome">Nome</label>
<input type="text" id="campanha-nome" required>
</div>
<div class="field">
<label for="campanha-produto">Produto</label>
<select id="campanha-produto" required></select>
</div>
<div class="field">
<label>Segmentos</label>
<div id="campanha-segmentos" class="segmento-lista"></div>
</div>
<div class="field">
<label>Variantes de mensagem (A/B/C)</label>
<div id="campanha-variantes"></div>
<button class="btn-ghost btn-sm" id="btn-add-variante" type="button">+ Variante</button>
</div>
<div class="row">
<label>Limite diário inicial <input type="number" id="campanha-limite-inicial" value="10" min="1"></label>
<label>Teto do limite <input type="number" id="campanha-limite-teto" value="80" min="1"></label>
</div>
<p class="muted" id="campanha-erro"></p>
<div class="row space-between">
<button class="btn-ghost" id="btn-cancelar-campanha" type="button">Cancelar</button>
<button class="btn btn-primary" id="btn-salvar-campanha" type="button">Criar campanha</button>
</div>
</form>
</dialog>
</section>
</main>
<!-- Modal de exportação -->
<dialog id="modal-export">
<form method="dialog" class="modal-body" id="form-export">
<h2>Gerar relatório / exportação</h2>
<p class="muted" id="export-recap"></p>
<div id="export-atalhos" class="chips"></div>
<div class="field">
<label>Tipo de arquivo</label>
<label class="radio"><input type="radio" name="export-tipo" value="excel" checked>
📊 <strong>Planilha (CSV + Excel)</strong> — lista completa com score, à prova de notação científica</label>
<label class="radio"><input type="radio" name="export-tipo" value="zpro-oportunidades">
🗂️ <strong>Z-PRO: Oportunidades (JSONL)</strong> — payloads prontos para createOpportunity</label>
<label class="radio"><input type="radio" name="export-tipo" value="zpro-bulk">
📨 <strong>Z-PRO: Disparo em massa (CSV)</strong> — dataInput para BulkSendMessageWithVariable</label>
</div>
<div class="field" id="export-zpro-params">
<label>Parâmetros Z-PRO</label>
<div class="row">
<label>Pipeline <input type="number" id="zp-pipeline" min="1"></label>
<label>Etapa <input type="number" id="zp-stage" min="1"></label>
<label>Responsável <input type="number" id="zp-responsible" min="1"></label>
</div>
</div>
<div class="field hidden" id="export-mensagem-wrap">
<label>Mensagem sugerida (use {empresa}, {cidade}, {segmento})</label>
<textarea id="export-mensagem" rows="3">Olá {empresa}! Somos do Grupo GTI e ajudamos empresas de {cidade} com sistema de gestão, TI e atendimento automático no WhatsApp. Podemos conversar?</textarea>
</div>
<label class="radio"><input type="checkbox" id="export-marcar" checked>
Marcar estes leads como exportados (não aparecem de novo no filtro "ainda não exportados")</label>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Cancelar</button>
<button class="btn btn-primary" id="btn-confirmar-export" value="default">Gerar arquivos</button>
</div>
</form>
</dialog>
<!-- Modal de histórico de capturas -->
<dialog id="modal-historico">
<div class="modal-body">
<div class="row space-between">
<h2>Últimas capturas</h2>
<button class="btn-ghost" id="btn-fechar-historico" type="button">Fechar</button>
</div>
<div id="historico-capturas" class="lista"></div>
</div>
</dialog>
<!-- Modal de configurações -->
<dialog id="modal-settings">
<form method="dialog" class="modal-body">
<div class="settings-header">
<div>
<h2>Configurações</h2>
<p class="muted">Ajustes de envio, credenciais Z-PRO e preferências comerciais.</p>
</div>
<span class="chip" id="outreach-status"></span>
</div>
<section class="settings-card settings-principal">
<div>
<label class="settings-title">Envio WhatsApp</label>
<p class="muted">O limite vale para envios manuais e automáticos feitos hoje.</p>
</div>
<div class="settings-controls">
<label class="toggle"><input type="checkbox" id="outreach-ativo"> Automação ativa</label>
<label>Limite por dia <input type="number" id="outreach-limite" min="1"></label>
</div>
</section>
<details class="settings-section" open>
<summary>Enviar mensagem manual</summary>
<p class="muted">Cria ou reutiliza um ticket no painel Z-PRO e respeita o limite diário.</p>
<div class="manual-send-card">
<label class="full">Número do WhatsApp
<input type="text" id="manual-numero" placeholder="5511999999999" inputmode="numeric">
</label>
<div class="manual-channel full" aria-label="Canal de envio manual">
<label class="radio"><input type="radio" name="manual-canal" id="manual-canal-naooficial" value="nao_oficial" checked> Não-oficial (texto livre)</label>
<label class="radio"><input type="radio" name="manual-canal" id="manual-canal-oficial" value="oficial"> Oficial (Meta, template aprovado)</label>
</div>
<label class="full" id="manual-mensagem-wrap">Mensagem
<textarea id="manual-mensagem" rows="2" placeholder="Digite a mensagem de texto livre..."></textarea>
</label>
<div id="manual-oficial-campos" class="hidden full">
<label class="full">Escolher modelo salvo
<select id="manual-template-biblioteca">
<option value="">Selecione um template...</option>
</select>
</label>
<div class="row full">
<button class="btn-ghost btn-sm" id="btn-testar-template-biblioteca" type="button">Testar modelo selecionado</button>
<span class="muted">Usa o número informado acima e variáveis de exemplo.</span>
</div>
<label>Template aprovado
<input type="text" id="manual-template-nome" placeholder="ex.: confirma_suporte">
</label>
<label>Idioma
<input type="text" id="manual-template-idioma" value="pt_BR">
</label>
<label>Rota Z-PRO
<select id="manual-template-rota">
<option value="template">Template padrão</option>
<option value="templateBody">Utility/Body</option>
<option value="templateMarketingBody">Marketing/Body</option>
</select>
</label>
<div class="template-vars full">
<label>Variáveis do template</label>
<p class="muted">Preencha na mesma ordem do template: {{1}}, {{2}}, {{3}}...</p>
<div class="variable-helper">
<select id="manual-variable-picker">
<option value="">Inserir variável da Máquina Comercial...</option>
</select>
<span class="muted">Clique em um campo abaixo e escolha uma variável.</span>
</div>
<div class="template-vars-grid">
<label class="template-var-field">Variável {{1}} <input type="text" class="manual-template-var" id="manual-template-var1" data-var-index="1" placeholder="Ex.: Gustavo"></label>
<label class="template-var-field">Variável {{2}} <input type="text" class="manual-template-var" data-var-index="2" placeholder="Ex.: Suporte GLPI #123"></label>
<label class="template-var-field">Variável {{3}} <input type="text" class="manual-template-var" data-var-index="3"></label>
<label class="template-var-field">Variável {{4}} <input type="text" class="manual-template-var" data-var-index="4"></label>
<label class="template-var-field">Variável {{5}} <input type="text" class="manual-template-var" data-var-index="5"></label>
</div>
<div class="template-preview" id="manual-template-preview">Selecione um template para ver a pré-visualização.</div>
</div>
</div>
<div class="manual-actions full">
<span class="muted" id="manual-status"></span>
<button class="btn btn-primary" id="btn-enviar-manual" type="button">Enviar mensagem</button>
</div>
<button class="btn-ghost btn-sm" id="btn-toggle-outreach-log" type="button">Ver log de envios</button>
<div id="outreach-ultimos" class="lista hidden full"></div>
</div>
</details>
<details class="settings-section">
<summary>Templates automáticos</summary>
<p class="muted">Mensagens usadas quando a automação estiver ativada. Uma por produto recomendado.</p>
<div class="template-sync-inline">
<span class="muted" id="waba-template-library-status">Carregando biblioteca de templates...</span>
<button class="btn-ghost btn-sm" id="btn-sync-gchat-templates-inline" type="button">Atualizar lista do GChat</button>
</div>
<div id="outreach-templates" class="lista"></div>
<div class="row space-between">
<span></span>
<button class="btn-ghost" id="btn-salvar-outreach" type="button">Salvar templates</button>
</div>
</details>
<details class="settings-section">
<summary>Credenciais Z-PRO</summary>
<p class="muted">Use os dados de Admin &gt; API do Z-PRO. Pode colar a Base URL com ou sem <code>https://</code>.</p>
<div class="row">
<label>Base URL <input type="text" id="cfg-zpro-baseurl" placeholder="seu-dominio-zpro.com.br"></label>
<label>API ID <input type="text" id="cfg-zpro-apiid"></label>
</div>
<div class="row">
<label>Token <input type="password" id="cfg-zpro-token" autocomplete="off"></label>
<label>WhatsApp ID <input type="text" id="cfg-zpro-whatsappid"></label>
</div>
<div class="row space-between">
<span class="muted" id="zpro-credenciais-status"></span>
<button class="btn-ghost" id="btn-testar-zpro" type="button">Testar conexão</button>
</div>
<div class="template-sync-box">
<label>URL de templates do GChat <input type="text" id="cfg-gchat-templates-url" placeholder="https://apigchat.gtigrupo.com.br/...templates..."></label>
<label>Token GChat opcional <input type="password" id="cfg-gchat-token" autocomplete="off"></label>
<div class="row space-between">
<span class="muted" id="gchat-template-sync-status"></span>
<button class="btn-ghost" id="btn-sync-gchat-templates" type="button">Sincronizar templates</button>
</div>
</div>
</details>
<details class="settings-section">
<summary>Padrões de exportação e ticket</summary>
<div class="row">
<label>Pipeline <input type="number" id="cfg-pipeline" min="1"></label>
<label>Etapa <input type="number" id="cfg-stage" min="1"></label>
<label>Responsável <input type="number" id="cfg-responsible" min="1"></label>
</div>
<div class="row">
<label>Previsão de fechamento (dias) <input type="number" id="cfg-forecast" min="1"></label>
<label>Fila ticket manual <input type="number" id="cfg-ticket-queue" min="1"></label>
</div>
</details>
<details class="settings-section zona-risco">
<summary>Zona de risco</summary>
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
<button class="btn-danger" id="btn-limpar-base" type="button">🗑️ Limpar toda a base de leads</button>
</details>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Fechar</button>
<button class="btn btn-primary" id="btn-salvar-settings" value="default">Salvar</button>
</div>
</form>
</dialog>
<!-- Modal de confirmacao da limpeza total da base -->
<dialog id="modal-reset-base">
<div class="modal-body">
<h2>Limpar toda a base de leads</h2>
<p class="muted">Esta ação é <strong>irreversível</strong>: todos os leads, exportações, capturas e extrações serão apagados. Digite a senha de segurança para confirmar.</p>
<div class="field">
<label>Senha de segurança <input type="password" id="reset-base-senha" autocomplete="off"></label>
</div>
<p class="muted erro hidden" id="reset-base-erro"></p>
<div class="row space-between">
<button class="btn-ghost" id="btn-cancelar-reset-base" type="button">Cancelar</button>
<button class="btn-danger" id="btn-confirmar-reset-base" type="button">Confirmar limpeza</button>
</div>
</div>
</dialog>
<!-- Modal de usuarios (somente admin) -->
<dialog id="modal-usuarios">
<div class="modal-body">
<div class="row space-between">
<h2>Usuários</h2>
<button class="btn-ghost" id="btn-fechar-usuarios" type="button">Fechar</button>
</div>
<div id="lista-usuarios" class="lista"></div>
<form class="field" id="form-novo-usuario">
<label>Novo usuário</label>
<div class="row">
<input type="text" id="novo-usuario-username" placeholder="usuário" minlength="3" required>
<input type="password" id="novo-usuario-password" placeholder="senha" minlength="4" required>
<select id="novo-usuario-role">
<option value="comercial">Comercial</option>
<option value="admin">Administrador</option>
</select>
<button class="btn btn-primary" type="submit">Criar</button>
</div>
</form>
</div>
</dialog>
<!-- Modal detalhe do lead -->
<dialog id="modal-lead">
<div class="modal-body" id="lead-detalhe"></div>
</dialog>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/captura.js"></script>
<script src="/js/tratamento.js"></script>
<script src="/js/campanhas.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
/* Cliente da API local + utilitários compartilhados. */
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
function redirecionaSeSessaoExpirou(path, status) {
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
window.location.href = '/login.html';
}
}
const API = {
async get(path) {
const res = await fetch(path);
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
throw new Error(data.erro || `HTTP ${res.status}`);
}
return data;
},
async send(method, path, body) {
const res = await fetch(path, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
const error = new Error(data.erro || `HTTP ${res.status}`);
error.data = data;
error.status = res.status;
throw error;
}
return data;
},
post(path, body) { return this.send('POST', path, body); },
put(path, body) { return this.send('PUT', path, body); },
// Acompanha um job via SSE; handlers: {progress, log, done, error}
watchJob(jobId, handlers) {
const source = new EventSource(`/api/jobs/${jobId}/events`);
const close = () => source.close();
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
source.addEventListener('done', (e) => {
handlers.done?.(JSON.parse(e.data));
close();
});
source.addEventListener('error', (e) => {
if (e.data) {
handlers.error?.(JSON.parse(e.data));
close();
}
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
});
return close;
}
};
const fmt = new Intl.NumberFormat('pt-BR');
function el(id) { return document.getElementById(id); }
function h(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child === null || child === undefined) continue;
node.append(child.nodeType ? child : document.createTextNode(child));
}
return node;
}
let toastTimer = null;
// Aviso: um <dialog> aberto via showModal() fica acima de qualquer div normal
// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover
// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a
// unica forma de mostrar erro de uma acao disparada de dentro de um modal
// aberto: use tambem uma mensagem inline no proprio modal nesses casos.
function toast(message, ms = 3500) {
const box = el('toast');
box.textContent = message;
box.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
}
function dataBr(iso) {
if (!iso) return '—';
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
+685
View File
@@ -0,0 +1,685 @@
/* Shell do aplicativo: login + abas + configurações + usuários. */
(async function init() {
// sessao (o servidor ja bloqueia "/" sem cookie, mas precisamos dos dados do usuario)
let usuarioAtual;
try {
({ user: usuarioAtual } = await API.get('/api/auth/me'));
} catch {
window.location.href = '/login.html';
return;
}
el('usuario-logado').textContent = `${usuarioAtual.username} (${usuarioAtual.role === 'admin' ? 'admin' : 'comercial'})`;
el('btn-usuarios').classList.toggle('hidden', usuarioAtual.role !== 'admin');
el('btn-sair').addEventListener('click', async () => {
await API.post('/api/auth/logout');
window.location.href = '/login.html';
});
// usuarios (somente admin)
async function carregarListaUsuarios() {
const { users } = await API.get('/api/users');
el('lista-usuarios').replaceChildren(...users.map((u) =>
h('div', { class: 'row space-between' },
h('span', {}, u.username),
h('span', { class: 'muted' }, u.role === 'admin' ? 'Administrador' : 'Comercial'))));
}
el('btn-usuarios').addEventListener('click', async () => {
el('modal-usuarios').showModal();
try {
await carregarListaUsuarios();
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close());
el('form-novo-usuario').addEventListener('submit', async (event) => {
event.preventDefault();
try {
await API.post('/api/users', {
username: el('novo-usuario-username').value,
password: el('novo-usuario-password').value,
role: el('novo-usuario-role').value
});
el('novo-usuario-username').value = '';
el('novo-usuario-password').value = '';
await carregarListaUsuarios();
toast('Usuário criado');
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
// abas
for (const tab of document.querySelectorAll('.tab')) {
tab.addEventListener('click', () => {
for (const t of document.querySelectorAll('.tab')) {
t.classList.toggle('active', t === tab);
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
}
for (const panel of document.querySelectorAll('.tab-panel')) {
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
});
}
// configurações
let WABA_TEMPLATE_LIBRARY = [];
let COMMERCIAL_VARIABLES = [];
let focusedVariableInput = null;
let manualLeadContext = null;
async function carregarVariaveisComerciais() {
const { variables } = await API.get('/api/outreach/variables');
COMMERCIAL_VARIABLES = variables || [];
const picker = el('manual-variable-picker');
if (picker) {
picker.replaceChildren(
h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'),
...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`))
);
}
}
async function carregarBibliotecaWaba() {
const { templates, lastSync } = await API.get('/api/outreach/waba-templates');
WABA_TEMPLATE_LIBRARY = templates || [];
const select = el('manual-template-biblioteca');
if (select) select.replaceChildren(...templateOptions(select.value));
const statusEl = el('waba-template-library-status');
if (statusEl) {
statusEl.textContent = lastSync
? `${WABA_TEMPLATE_LIBRARY.length} templates carregados. Última atualização: ${dataBr(lastSync)}`
: `${WABA_TEMPLATE_LIBRARY.length} templates locais carregados. Para trazer todos do GChat, preencha o Token GChat e clique em Atualizar.`;
}
}
function templateLabel(template) {
return `${template.name} (${template.language})`;
}
function templateOptions(selected = '') {
return [h('option', { value: '' }, 'Selecione um template...')].concat(
WABA_TEMPLATE_LIBRARY.map((template) => h('option', {
value: template.name,
selected: template.name === selected ? '' : null
}, templateLabel(template)))
);
}
function preencherVariaveis(inputs, variables, fallback = []) {
inputs.forEach((input, index) => {
input.value = variables[index] || fallback[index] || '';
});
}
function templateVariableCount(template) {
if (!template) return 0;
const fromVariables = Array.isArray(template.variables) ? template.variables.length : 0;
const matches = String(template.text || '').match(/{{\s*\d+\s*}}/g) || [];
const fromText = Math.max(0, ...matches.map((match) => Number(match.replace(/\D/g, ''))));
return Math.min(5, Math.max(fromVariables, fromText));
}
function setVariableFields(container, template, inputs, fallback = []) {
const count = templateVariableCount(template);
inputs.forEach((input, index) => {
const field = input.closest('.template-var-field') || input.closest('label');
const visible = index < count;
if (field) field.classList.toggle('hidden', !visible);
if (!visible) input.value = '';
else if (!input.value) input.value = template?.variables?.[index] || fallback[index] || '';
});
const empty = container?.querySelector?.('.template-vars-empty');
if (empty) empty.classList.toggle('hidden', count > 0);
}
function previewTemplateText(template, inputs) {
const text = String(template?.text || '').trim();
if (!template) return 'Selecione um template para ver a pré-visualização.';
if (!text) return 'Pré-visualização indisponível para este template local.';
return text.replace(/{{\s*(\d+)\s*}}/g, (_, index) => {
const input = inputs[Number(index) - 1];
const value = resolverVariavelManual(input?.value?.trim());
return value ? `[${value}]` : `[Variável ${index}]`;
});
}
function atualizarPreview(previewEl, template, inputs) {
if (previewEl) previewEl.textContent = previewTemplateText(template, inputs);
}
function collectVisibleVariables(inputs) {
return inputs
.filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden'))
.map((input) => input.value.trim());
}
function collectVisibleVariablesResolvidas(inputs) {
return collectVisibleVariables(inputs).map(resolverVariavelManual);
}
function valoresVariaveisManuais(lead = manualLeadContext) {
const empresa = lead?.empresa || lead?.legal_name || lead?.businessName || lead?.name || 'Empresa Teste';
const produto = lead?.produto || lead?.produto_principal || lead?.interesse || 'Atendimento comercial';
const segmento = lead?.segmento_label || lead?.segmento || 'Suporte';
const name = lead?.name || empresa;
return {
name,
empresa,
businessName: empresa,
firstName: String(name).split(/\s+/).filter(Boolean)[0] || empresa,
lastName: lead?.lastName || '',
produto,
protocol: lead?.protocol || produto,
cidade: lead?.cidade || 'Salvador',
uf: lead?.uf || 'BA',
segmento,
cnpj: lead?.cnpj || '00.000.000/0001-00',
email: lead?.email || 'cliente@exemplo.com.br',
phoneNumber: lead?.whatsapp || '5575999091780',
whatsapp: lead?.whatsapp || '5575999091780',
kanban: lead?.kanban || produto,
user: lead?.user || 'Grupo GTI',
userEmail: lead?.userEmail || 'comercial@gtigrupo.com.br',
greeting: 'Olá'
};
}
function resolverVariavelManual(value) {
const values = valoresVariaveisManuais();
let output = String(value || 'Informação')
.replaceAll('{empresa}', values.empresa)
.replaceAll('{produto}', values.produto)
.replaceAll('{cidade}', values.cidade)
.replaceAll('{segmento}', values.segmento);
for (const [key, variableValue] of Object.entries(values)) {
output = output.replaceAll(`{{${key}}}`, variableValue);
}
for (const variable of COMMERCIAL_VARIABLES) {
if (!output.includes(`{{${variable.key}}}`)) continue;
output = output.replaceAll(`{{${variable.key}}}`, values[variable.key] || variable.label);
}
return output;
}
const variavelExemplo = resolverVariavelManual;
function missingVisibleVariables(inputs) {
return inputs
.filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden'))
.filter((input) => !input.value.trim());
}
function aplicarTemplateBiblioteca(template, { nomeEl, idiomaEl, routeEl, variableInputs }) {
if (!template) return;
nomeEl.value = template.name;
idiomaEl.value = template.language;
if (routeEl) routeEl.value = template.route;
preencherVariaveis(variableInputs, template.variables);
}
function variablePickerEl() {
return h('select', { class: 'variable-picker' },
h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'),
...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`))
);
}
async function carregarOutreach() {
const { settings } = await API.get('/api/settings');
el('outreach-ativo').checked = settings['outreach.ativo'] === 'true';
el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10;
const [{ templates }, status] = await Promise.all([
API.get('/api/outreach/templates'),
API.get('/api/outreach/status')
]);
el('outreach-status').textContent = `Enviado hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`;
el('outreach-templates').replaceChildren(...templates.map((t) => {
const oficial = t.canal === 'oficial';
const mensagemEl = h('textarea', {
class: 'outreach-template-mensagem', rows: 2,
style: oficial ? 'display:none' : ''
}, t.mensagem);
const nomeEl = h('input', { type: 'text', class: 'outreach-template-waba-nome', value: t.template_waba_nome || '' });
const idiomaEl = h('input', { type: 'text', class: 'outreach-template-waba-idioma', value: t.template_waba_idioma || 'pt_BR' });
let variaveisSalvas = [];
try {
variaveisSalvas = JSON.parse(t.template_waba_variaveis_json || '[]');
} catch {
variaveisSalvas = [];
}
let templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === t.template_waba_nome) || null;
const variaveisEls = Array.from({ length: 5 }, (_, index) => {
const input = h('input', {
type: 'text',
class: 'outreach-template-waba-var',
value: variaveisSalvas[index] || (index === 0 ? '{empresa}' : index === 1 ? '{produto}' : ''),
placeholder: index === 0 ? '{empresa}' : index === 1 ? '{produto}' : ''
});
input.addEventListener('focus', () => { focusedVariableInput = input; });
return h('label', { class: 'template-var-field' }, `Variável {{${index + 1}}}`, input);
});
const bibliotecaEl = h('select', { class: 'outreach-template-biblioteca' }, ...templateOptions(t.template_waba_nome || ''));
const variablePicker = variablePickerEl();
const previewEl = h('div', { class: 'template-preview' }, 'Selecione um template para ver a pré-visualização.');
variablePicker.addEventListener('change', () => {
if (!focusedVariableInput || !oficialEl.contains(focusedVariableInput) || !variablePicker.value) return;
focusedVariableInput.value = variablePicker.value;
focusedVariableInput.dispatchEvent(new Event('input'));
variablePicker.value = '';
});
bibliotecaEl.addEventListener('change', () => {
templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === bibliotecaEl.value);
const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'));
aplicarTemplateBiblioteca(templateSelecionado, {
nomeEl,
idiomaEl,
variableInputs: inputs
});
setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']);
atualizarPreview(previewEl, templateSelecionado, inputs);
atualizarAviso();
});
const avisoEl = h('p', { class: 'muted outreach-template-aviso hidden' },
'⚠️ Preencha o nome do template pra essa automação funcionar.');
const oficialEl = h('div', {
class: 'outreach-template-oficial',
style: oficial ? '' : 'display:none'
},
h('label', {}, 'Escolher modelo salvo', bibliotecaEl),
h('div', { class: 'row' },
h('label', {}, 'Nome do template aprovado', nomeEl),
h('label', {}, 'Idioma', idiomaEl)),
h('div', { class: 'template-vars outreach-template-vars' },
h('label', {}, 'Variáveis automáticas'),
h('p', { class: 'muted' }, 'Use placeholders como {empresa}, {produto}, {cidade}, {segmento}.'),
h('div', { class: 'variable-helper' },
variablePicker,
h('span', { class: 'muted' }, 'Clique em um campo abaixo e escolha uma variável.')),
h('p', { class: 'muted template-vars-empty hidden' }, 'Este template não exige variáveis.'),
h('div', { class: 'template-vars-grid' }, ...variaveisEls),
previewEl),
h('a', {
href: 'https://business.facebook.com/wa/manage/message-templates/',
target: '_blank', rel: 'noopener', class: 'outreach-template-link'
}, '↗ Ver/criar templates no Meta Business Manager'),
avisoEl);
const atualizarAviso = () => {
avisoEl.classList.toggle('hidden', radioOficial.checked ? Boolean(nomeEl.value.trim()) : true);
};
nomeEl.addEventListener('input', atualizarAviso);
for (const input of variaveisEls.map((label) => label.querySelector('input'))) {
input.addEventListener('input', () => atualizarPreview(previewEl, templateSelecionado, Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'))));
}
const radioNaoOficial = h('input', {
type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'nao_oficial',
checked: oficial ? null : ''
});
const radioOficial = h('input', {
type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'oficial',
checked: oficial ? '' : null
});
const alternarCanal = () => {
const usaOficial = radioOficial.checked;
mensagemEl.style.display = usaOficial ? 'none' : '';
oficialEl.style.display = usaOficial ? '' : 'none';
atualizarAviso();
};
radioNaoOficial.addEventListener('change', alternarCanal);
radioOficial.addEventListener('change', alternarCanal);
atualizarAviso();
queueMicrotask(() => {
const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'));
setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']);
atualizarPreview(previewEl, templateSelecionado, inputs);
});
return h('div', { class: 'item outreach-template', 'data-produto': t.produto },
h('div', { class: 'row space-between' },
h('strong', {}, t.produto),
h('label', { class: 'radio' },
h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: t.ativo ? '' : null }),
' Ativo')),
h('div', { class: 'row' },
h('label', { class: 'radio' }, radioNaoOficial, ' Não-oficial (texto livre)'),
h('label', { class: 'radio' }, radioOficial, ' Oficial (Meta, template aprovado)')),
mensagemEl,
oficialEl);
}));
el('outreach-ultimos').replaceChildren(...status.ultimos.map((s) => {
const quem = s.origem === 'manual' ? (s.numero || 'número avulso') : `${s.empresa || '?'} (${s.cidade || '?'})`;
const rotulo = s.origem === 'manual' ? `Manual → ${quem}` : `${s.produto} → ${quem}`;
const statusIcone = s.status === 'enviado' ? '✅' : '❌';
return h('div', { class: 'item' },
h('div', {}, `${statusIcone} ${rotulo}`),
h('div', { class: 'sub' }, `${dataBr(s.sent_at)} · canal ${s.canal === 'oficial' ? 'oficial' : 'não-oficial'}${s.erro ? ` · ${s.erro}` : ''}`));
}));
if (!status.ultimos.length) {
el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.'));
}
}
el('btn-settings').addEventListener('click', async () => {
const { settings } = await API.get('/api/settings');
el('cfg-pipeline').value = settings['zpro.pipelineId'] || 1;
el('cfg-stage').value = settings['zpro.stageId'] || 1;
el('cfg-responsible').value = settings['zpro.responsibleId'] || 15;
el('cfg-ticket-queue').value = settings['zpro.ticketQueueId'] || 12;
el('cfg-forecast').value = settings['zpro.closingForecastDays'] || 30;
el('cfg-zpro-baseurl').value = settings['zpro.baseUrl'] || '';
el('cfg-zpro-apiid').value = settings['zpro.apiId'] || '';
el('cfg-zpro-token').value = settings['zpro.bearerToken'] || '';
el('cfg-zpro-whatsappid').value = settings['zpro.whatsappId'] || '';
el('cfg-gchat-templates-url').value = settings['gchat.templatesUrl'] || '';
el('cfg-gchat-token').value = settings['gchat.bearerToken'] || '';
el('zpro-credenciais-status').textContent = '';
el('modal-settings').showModal();
try {
await carregarBibliotecaWaba();
await carregarVariaveisComerciais();
await carregarOutreach();
} catch (error) {
toast(`Erro ao carregar automação: ${error.message}`);
}
});
el('btn-testar-zpro').addEventListener('click', async () => {
const statusEl = el('zpro-credenciais-status');
statusEl.textContent = 'Salvando e testando...';
try {
await API.put('/api/settings', {
'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(),
'zpro.apiId': el('cfg-zpro-apiid').value.trim(),
'zpro.bearerToken': el('cfg-zpro-token').value.trim(),
'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(),
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
'gchat.bearerToken': el('cfg-gchat-token').value.trim(),
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
'outreach.mensagensPorDia': el('outreach-limite').value
});
const resultado = await API.post('/api/outreach/testar-conexao');
const diag = resultado.diagnostico
? `\nBase: ${resultado.diagnostico.baseUrl || '-'} | API ID: ${resultado.diagnostico.apiId || '-'} | Token: ${resultado.diagnostico.token || '-'} | WhatsApp ID: ${resultado.diagnostico.whatsappId || '-'}`
: '';
statusEl.textContent = resultado.ok ? `✅ Conexão OK${diag}` : `❌ ${resultado.erro}${diag}`;
} catch (error) {
statusEl.textContent = `❌ ${error.message}`;
}
});
el('btn-sync-gchat-templates').addEventListener('click', async () => {
await sincronizarTemplatesGchat();
});
el('btn-sync-gchat-templates-inline').addEventListener('click', async () => {
await sincronizarTemplatesGchat();
});
async function sincronizarTemplatesGchat() {
const statusEl = el('gchat-template-sync-status');
const inlineStatusEl = el('waba-template-library-status');
statusEl.textContent = 'Sincronizando...';
if (inlineStatusEl) inlineStatusEl.textContent = 'Sincronizando com GChat...';
try {
await API.put('/api/settings', {
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
'gchat.bearerToken': el('cfg-gchat-token').value.trim()
});
const result = await API.post('/api/outreach/waba-templates/sync');
statusEl.textContent = result.ok ? `✅ ${result.total} templates sincronizados` : `❌ ${result.erro}`;
if (inlineStatusEl) inlineStatusEl.textContent = result.ok
? `✅ ${result.total} templates sincronizados do GChat`
: `❌ ${result.erro}. Preencha o Token GChat opcional em Credenciais Z-PRO.`;
await carregarBibliotecaWaba();
await carregarOutreach();
} catch (error) {
statusEl.textContent = `❌ ${error.message}`;
if (inlineStatusEl) inlineStatusEl.textContent = `❌ ${error.message}`;
}
}
el('btn-salvar-outreach').addEventListener('click', async () => {
try {
await API.put('/api/settings', {
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
'outreach.mensagensPorDia': el('outreach-limite').value
});
for (const item of el('outreach-templates').children) {
const canalEl = item.querySelector('.outreach-template-canal:checked');
await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, {
mensagem: item.querySelector('.outreach-template-mensagem').value,
ativo: item.querySelector('.outreach-template-ativo').checked,
canal: canalEl ? canalEl.value : 'nao_oficial',
templateWabaNome: item.querySelector('.outreach-template-waba-nome').value,
templateWabaIdioma: item.querySelector('.outreach-template-waba-idioma').value,
templateWabaVariaveis: collectVisibleVariables(Array.from(item.querySelectorAll('.outreach-template-waba-var')))
});
}
await carregarOutreach();
toast('Automação salva');
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
function alternarCanalManual() {
const oficial = el('manual-canal-oficial').checked;
el('manual-mensagem-wrap').classList.toggle('hidden', oficial);
el('manual-oficial-campos').classList.toggle('hidden', !oficial);
}
el('manual-canal-naooficial').addEventListener('change', alternarCanalManual);
el('manual-canal-oficial').addEventListener('change', alternarCanalManual);
el('manual-template-biblioteca').replaceChildren(...templateOptions());
el('manual-template-biblioteca').addEventListener('change', () => {
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
const inputs = Array.from(document.querySelectorAll('.manual-template-var'));
aplicarTemplateBiblioteca(template, {
nomeEl: el('manual-template-nome'),
idiomaEl: el('manual-template-idioma'),
routeEl: el('manual-template-rota'),
variableInputs: inputs
});
setVariableFields(el('manual-oficial-campos'), template, inputs);
atualizarPreview(el('manual-template-preview'), template, inputs);
});
for (const input of document.querySelectorAll('.manual-template-var')) {
input.addEventListener('focus', () => { focusedVariableInput = input; });
input.addEventListener('input', () => {
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
});
}
el('manual-variable-picker').addEventListener('change', () => {
if (!focusedVariableInput || !el('manual-variable-picker').value) return;
focusedVariableInput.value = el('manual-variable-picker').value;
focusedVariableInput.dispatchEvent(new Event('input'));
el('manual-variable-picker').value = '';
});
setVariableFields(el('manual-oficial-campos'), null, Array.from(document.querySelectorAll('.manual-template-var')));
el('btn-testar-template-biblioteca').addEventListener('click', async () => {
const statusEl = el('manual-status');
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
const numero = el('manual-numero').value.trim();
statusEl.classList.remove('status-ok', 'status-error');
if (!template) {
statusEl.textContent = '❌ Selecione um modelo salvo primeiro.';
statusEl.classList.add('status-error');
return;
}
if (!numero) {
statusEl.textContent = '❌ Informe um número de teste acima.';
statusEl.classList.add('status-error');
return;
}
aplicarTemplateBiblioteca(template, {
nomeEl: el('manual-template-nome'),
idiomaEl: el('manual-template-idioma'),
routeEl: el('manual-template-rota'),
variableInputs: Array.from(document.querySelectorAll('.manual-template-var'))
});
const variaveis = template.variables.map(resolverVariavelManual);
preencherVariaveis(Array.from(document.querySelectorAll('.manual-template-var')), variaveis);
setVariableFields(el('manual-oficial-campos'), template, Array.from(document.querySelectorAll('.manual-template-var')));
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
statusEl.textContent = `Testando ${template.name}...`;
try {
const resultado = await API.post('/api/outreach/enviar-manual', {
numero,
canal: 'oficial',
mensagem: '',
templateWabaNome: template.name,
templateWabaIdioma: template.language,
templateRota: template.route,
templateVariaveis: variaveis
});
statusEl.textContent = resultado.ok
? `✅ ${template.name} funcionou${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}`
: `❌ ${template.name} falhou: ${resultado.erro}`;
statusEl.classList.toggle('status-ok', Boolean(resultado.ok));
statusEl.classList.toggle('status-error', !resultado.ok);
await carregarOutreach();
} catch (error) {
statusEl.textContent = `❌ ${template.name} falhou: ${error.message}`;
statusEl.classList.add('status-error');
}
});
el('btn-toggle-outreach-log').addEventListener('click', () => {
const log = el('outreach-ultimos');
const hidden = log.classList.toggle('hidden');
el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios';
});
el('btn-enviar-manual').addEventListener('click', async () => {
const statusEl = el('manual-status');
const oficial = el('manual-canal-oficial').checked;
statusEl.classList.remove('status-ok', 'status-error');
if (oficial) {
const faltando = missingVisibleVariables(Array.from(document.querySelectorAll('.manual-template-var')));
if (faltando.length) {
statusEl.textContent = `❌ Preencha todas as variáveis do template (${faltando.length} pendente${faltando.length > 1 ? 's' : ''}).`;
statusEl.classList.add('status-error');
return;
}
}
statusEl.textContent = 'Enviando...';
try {
const resultado = await API.post('/api/outreach/enviar-manual', {
numero: el('manual-numero').value.trim(),
canal: oficial ? 'oficial' : 'nao_oficial',
mensagem: el('manual-mensagem').value,
templateWabaNome: el('manual-template-nome').value.trim(),
templateWabaIdioma: el('manual-template-idioma').value.trim(),
templateVariaveis: collectVisibleVariablesResolvidas(Array.from(document.querySelectorAll('.manual-template-var'))),
templateRota: el('manual-template-rota').value,
lead: manualLeadContext
});
statusEl.textContent = resultado.ok
? `✅ Enviado${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}`
: `❌ ${resultado.erro}`;
statusEl.classList.toggle('status-ok', Boolean(resultado.ok));
statusEl.classList.toggle('status-error', !resultado.ok);
await carregarOutreach();
} catch (error) {
statusEl.textContent = `❌ ${error.message}`;
statusEl.classList.add('status-error');
}
});
window.GTISelecionarLeadManual = (lead) => {
manualLeadContext = lead || null;
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
};
el('btn-salvar-settings').addEventListener('click', async (event) => {
event.preventDefault();
try {
await API.put('/api/settings', {
'zpro.pipelineId': el('cfg-pipeline').value,
'zpro.stageId': el('cfg-stage').value,
'zpro.responsibleId': el('cfg-responsible').value,
'zpro.ticketQueueId': el('cfg-ticket-queue').value,
'zpro.closingForecastDays': el('cfg-forecast').value,
'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(),
'zpro.apiId': el('cfg-zpro-apiid').value.trim(),
'zpro.bearerToken': el('cfg-zpro-token').value.trim(),
'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(),
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
'gchat.bearerToken': el('cfg-gchat-token').value.trim(),
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
'outreach.mensagensPorDia': el('outreach-limite').value
});
await carregarOutreach();
el('modal-settings').close();
toast('Configurações salvas');
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
// limpar toda a base (zona de risco)
el('btn-limpar-base').addEventListener('click', () => {
el('reset-base-senha').value = '';
el('reset-base-erro').classList.add('hidden');
el('modal-settings').close();
el('modal-reset-base').showModal();
});
el('btn-cancelar-reset-base').addEventListener('click', () => {
el('modal-reset-base').close();
});
// Erro aqui precisa aparecer DENTRO do modal (nao so no toast): um <dialog>
// aberto via showModal() fica na "top layer" do navegador, acima de
// qualquer div normal — inclusive do toast, que ficava invisivel atras do
// modal quando a senha estava errada, dando a impressao de que nada
// acontecia (bug reportado: "limpeza nao foi feita mesmo pondo a senha").
el('btn-confirmar-reset-base').addEventListener('click', async () => {
const erroEl = el('reset-base-erro');
const senha = el('reset-base-senha').value;
if (!senha) {
erroEl.textContent = 'Informe a senha de segurança';
erroEl.classList.remove('hidden');
return;
}
try {
await API.post('/api/admin/reset', { senha });
el('modal-reset-base').close();
toast('Base de leads limpa com sucesso — recarregando...');
setTimeout(() => window.location.reload(), 1200);
} catch (error) {
erroEl.textContent = `Erro: ${error.message}`;
erroEl.classList.remove('hidden');
}
});
// fecha modais clicando fora
for (const dialog of document.querySelectorAll('dialog')) {
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
}
Tratamento.bindEvents();
try {
await Captura.init();
} catch (error) {
toast(`Erro ao carregar: ${error.message}`, 8000);
}
})();
@@ -0,0 +1,253 @@
/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */
const Campanhas = (() => {
let carregado = false;
let variantesCount = 0;
const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' };
const MAX_VARIANTES = 3; // A/B/C — mesmo limite usado na convencao do restante da UI/backend.
function addVarianteRow(codigoSugerido) {
if (variantesCount >= MAX_VARIANTES) return;
const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount);
variantesCount += 1;
el('campanha-variantes').append(
h('div', { class: 'row', 'data-variante-code': codigo },
h('span', { class: 'chip' }, codigo),
h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' }))
);
atualizarBotaoAddVariante();
}
function atualizarBotaoAddVariante() {
el('btn-add-variante').disabled = variantesCount >= MAX_VARIANTES;
}
async function carregarProdutos() {
const { produtos } = await API.get('/api/campaigns/produtos');
el('campanha-produto').replaceChildren(
...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`))
);
}
async function carregarSegmentos(produto) {
const container = el('campanha-segmentos');
if (!produto) { container.replaceChildren(); return; }
const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`);
container.replaceChildren(...segmentos.map((s) =>
h('label', { class: 'segmento-opcao' },
h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }),
` ${s.value} (${fmt.format(s.total)})`)
));
}
function segmentosSelecionados() {
return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value);
}
function variantesPreenchidas() {
return Array.from(el('campanha-variantes').children).map((row) => ({
code: row.dataset.varianteCode,
mensagem: row.querySelector('.campanha-variante-mensagem').value.trim()
})).filter((v) => v.mensagem);
}
async function abrirModalNovaCampanha() {
el('campanha-nome').value = '';
el('campanha-limite-inicial').value = 10;
el('campanha-limite-teto').value = 80;
el('campanha-erro').textContent = '';
el('campanha-variantes').replaceChildren();
variantesCount = 0;
addVarianteRow('A');
await carregarProdutos();
await carregarSegmentos(el('campanha-produto').value);
el('modal-nova-campanha').showModal();
}
function renderCampanhaCard(campanha) {
return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) },
h('div', {},
h('strong', {}, campanha.nome),
h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)),
h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` },
STATUS_LABELS[campanha.status] || campanha.status));
}
async function carregarLista() {
const { campanhas } = await API.get('/api/campaigns');
el('campanhas-lista').replaceChildren(
...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')])
);
}
function ativar() {
if (carregado) return;
carregado = true;
el('btn-nova-campanha').addEventListener('click', () => {
abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`));
});
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
el('campanha-produto').addEventListener('change', (event) => {
// O modal de nova campanha esta aberto (<dialog> via showModal()) quando
// isto dispara — toast sozinho fica escondido atras do dialog (ver
// comentario em api.js), entao o erro tambem precisa aparecer no
// paragrafo inline #campanha-erro que ja existe pro erro de submit.
carregarSegmentos(event.target.value).catch((error) => {
toast(`Erro: ${error.message}`);
el('campanha-erro').textContent = `Erro: ${error.message}`;
});
});
el('btn-salvar-campanha').addEventListener('click', async () => {
try {
await API.post('/api/campaigns', {
nome: el('campanha-nome').value.trim(),
produto: el('campanha-produto').value,
segmentos: segmentosSelecionados(),
variantes: variantesPreenchidas(),
limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10,
limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80
});
el('modal-nova-campanha').close();
toast('Campanha criada');
await carregarLista();
} catch (error) {
el('campanha-erro').textContent = error.message;
}
});
ligarDetalheCampanha();
carregarLista().catch((error) => toast(`Erro: ${error.message}`));
}
let campanhaAtualId = null;
async function carregarDetalhe(campanhaId) {
campanhaAtualId = campanhaId;
const campanha = await API.get(`/api/campaigns/${campanhaId}`);
el('detalhe-nome').textContent = campanha.nome;
el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status;
el('campanha-detalhe').classList.remove('hidden');
await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]);
}
async function carregarSaude(campanhaId) {
const saude = await API.get(`/api/campaigns/${campanhaId}/saude`);
el('detalhe-saude').textContent = saude.pause
? `Pausada automaticamente: ${saude.reason}`
: 'Saude: sem alertas.';
}
async function carregarVariantesStats(campanhaId) {
const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`);
el('detalhe-variantes').replaceChildren(...variantes.map((v) =>
h('span', { class: 'chip' },
`${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`)
));
}
const STAGE_OPTIONS = [
'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada'
];
async function carregarFila(campanhaId) {
const status = el('fila-filtro-status').value;
const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`);
el('fila-tabela').replaceChildren(...fila.map((linha) => {
const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error';
const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' },
h('option', { value: '' }, '—'),
...STAGE_OPTIONS.map((s) => h('option', { value: s }, s)));
const marcarBtn = h('button', {
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
onclick: async () => {
try {
if (!stageSelect.value) return;
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
toast('Estagio atualizado');
if (stageSelect.value === 'demonstracao_agendada') {
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
if (dataAgendamento) {
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
toast('Agendamento registrado');
}
}
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
}, 'Marcar');
return h('tr', {},
h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })),
h('td', {}, String(linha.lead_id)),
h('td', {}, linha.numero),
h('td', {}, linha.rendered_message),
h('td', {}, linha.status),
h('td', { class: 'row' }, stageSelect, marcarBtn));
}));
}
function filaSelecionada() {
return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value));
}
function ligarDetalheCampanha() {
el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden'));
el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`)));
el('btn-gerar-fila').addEventListener('click', async () => {
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {});
toast(`${resultado.adicionadosNaFila} leads adicionados a fila`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-iniciar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {});
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-pausar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' });
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-retomar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {});
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-aprovar-selecionados').addEventListener('click', async () => {
const queueIds = filaSelecionada();
if (!queueIds.length) return;
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds });
toast(`${resultado.aprovados} aprovados`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-cancelar-selecionados').addEventListener('click', async () => {
const queueIds = filaSelecionada();
if (!queueIds.length) return;
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' });
toast(`${resultado.cancelados} cancelados`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
}
return { ativar };
})();
+405
View File
@@ -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 };
})();
+16
View File
@@ -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;
}
});
@@ -0,0 +1,658 @@
/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */
const Tratamento = (() => {
let page = 1;
let totalPages = 1;
let carregado = false;
let refreshTimer = null;
const CLASSIFICACAO_LABELS = {
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
};
const PRIORIDADE_LABELS = {
1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana',
3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas'
};
function estrelas(n) {
const cheias = Math.max(0, Math.min(5, Number(n) || 0));
return '★'.repeat(cheias) + '☆'.repeat(5 - cheias);
}
// ---------- filtros ----------
function filtroAtual() {
const filtro = {};
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
const value = input.value.trim();
if (value) filtro[input.dataset.filtro] = value;
}
return filtro;
}
function filtroQuery(extra = {}) {
return new URLSearchParams({ ...filtroAtual(), ...extra }).toString();
}
// Clique nos cards/barras do dashboard: aplica o filtro somando aos ja
// ativos; clicar de novo no mesmo valor remove so aquele filtro.
function toggleFiltro(inputId, valor) {
const input = el(inputId);
const novo = String(valor ?? '');
input.value = input.value === novo ? '' : novo;
atualizar();
}
function descreverFiltro() {
const f = filtroAtual();
const partes = [];
if (f.uf) partes.push(`UF ${f.uf}`);
if (f.cidade) partes.push(f.cidade);
if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento);
if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao);
if (f.prioridade) partes.push(`P${f.prioridade}`);
if (f.produto) partes.push(`produto: ${f.produto}`);
if (f.interesse) partes.push(f.interesse);
if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com WhatsApp' : 'sem WhatsApp');
if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website');
if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail');
if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados');
if (f.q) partes.push(`busca "${f.q}"`);
return partes.length ? partes.join(' · ') : 'todos os leads';
}
// ---------- carregamento ----------
async function atualizar() {
page = 1;
const [summary, evolutionRes, oportunidadeRes] = await Promise.all([
API.get(`/api/dashboard/summary?${filtroQuery()}`),
API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`),
API.get(`/api/dashboard/oportunidade?${filtroQuery()}`)
]);
await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]);
carregarEvolucaoDiaria(evolutionRes.evolution);
carregarMaiorOportunidade(oportunidadeRes.oportunidade);
}
function invalidar() {
carregado = false;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (document.querySelector('#tab-tratamento.active')) ativar();
}, 800);
}
async function ativar() {
if (carregado) return;
carregado = true;
await Promise.all([carregarFacets(), atualizar(), carregarExports()]);
}
async function carregarFacets() {
const facets = await API.get('/api/leads/facets');
const fill = (select, valores, prefixo, formatar = (v) => v) => {
const atual = select.value;
const primeira = select.options[0];
select.replaceChildren(primeira, ...valores.map((item) =>
h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`)
));
select.value = atual;
};
fill(el('f-uf'), facets.ufs);
fill(el('f-interesse'), facets.interesses);
fill(el('f-produto'), facets.produtos);
const segSelect = el('f-segmento');
const atual = segSelect.value;
segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) =>
h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`)
));
segSelect.value = atual;
el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value })));
}
async function salvarMetaDiaria(valor) {
const meta = Math.max(1, Number(valor) || 100);
await API.put('/api/settings', { meta_diaria: meta });
const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
carregarTiles(summary);
}
async function carregarTiles(s) {
const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : '';
el('stat-tiles').replaceChildren(
tile(s.total, 'Leads no filtro', ''),
tile(s.com_whatsapp, 'Com WhatsApp', pct(s.com_whatsapp), 't-good'),
tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'),
tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4))
);
el('stat-tiles').append(
cardCobertura(s),
cardPrioridades(s),
cardMeta(s)
);
function tile(valor, rotulo, detalhe, cls = '') {
return h('div', { class: `tile ${cls}` },
h('div', { class: 'valor' }, fmt.format(valor)),
h('div', { class: 'rotulo' }, rotulo),
h('div', { class: 'detalhe' }, detalhe)
);
}
function cardCobertura(s) {
const cidadeAtiva = filtroAtual().cidade;
const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura';
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, titulo),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })),
h('div', { class: 'detalhe' },
`${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`)
);
}
function cardPrioridades(s) {
const ativa = filtroAtual().prioridade;
const linhas = [
['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct]
];
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, 'Distribuição por prioridade'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) =>
h('div', {
class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`,
title: `${label}: ${fmt.format(total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-prioridade', i + 1)
},
h('div', { class: 'rank-label' }, label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
)))
);
}
function cardMeta(s) {
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'row space-between' },
h('div', { class: 'rotulo' }, '🎯 Meta diária'),
h('input', {
type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input',
onchange: (e) => salvarMetaDiaria(e.target.value)
})
),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })),
h('div', { class: 'detalhe' },
`${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`)
);
}
}
async function carregarRankings(summary) {
const cidadeAtiva = filtroAtual().cidade;
el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado';
const [cidades, segmentos, produtos] = await Promise.all([
API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`)
]);
const f = filtroAtual();
const segmentoValuePorLabel = (label) => {
const opts = [...el('f-segmento').options];
const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase());
const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase()));
return (porValor || porTexto)?.value || '';
};
renderRanking(el('rank-cidades'), cidades.ranking,
(label) => toggleFiltro('f-cidade', label),
null,
(label) => f.cidade === label);
renderRanking(el('rank-segmentos'), segmentos.ranking,
(label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)),
null,
(label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label));
renderRanking(el('rank-produtos'), produtos.ranking,
(label) => toggleFiltro('f-produto', label),
summary.total,
(label) => f.produto === label);
carregarOportunidadesDoDia(produtos.ranking, summary.total);
}
function carregarOportunidadesDoDia(produtosRanking, totalGeral) {
const top3 = produtosRanking.slice(0, 3);
if (!top3.length) {
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'muted' }, 'Sem dados para este filtro.')
);
return;
}
const produtoAtivo = filtroAtual().produto;
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => {
const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0;
return h('div', {
class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-produto', r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
);
}))
);
}
function dataBrCurta(diaIso) {
const [, mes, dia] = diaIso.split('-');
return `${dia}/${mes}`;
}
function carregarEvolucaoDiaria(evolution) {
if (!evolution.length) {
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.')
);
return;
}
const max = Math.max(...evolution.map((d) => d.total));
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'),
h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' },
h('div', { class: 'rank-label' }, dataBrCurta(d.dia)),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, fmt.format(d.total))
)))
);
}
function carregarMaiorOportunidade(oportunidade) {
if (!oportunidade) {
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.')
);
return;
}
const f = filtroAtual();
const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto;
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', {
class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`,
style: 'margin-top:6px',
title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto',
onclick: () => {
el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade;
el('f-produto').value = jaAplicado ? '' : oportunidade.produto;
atualizar();
}
},
h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)),
h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)),
h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total)))
)
);
}
function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) {
if (!ranking.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.'));
return;
}
const max = ranking[0].total;
box.replaceChildren(...ranking.map((r) => {
const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : '';
const ativo = isAtivo(r.label);
return h('div', {
class: `rank-row${ativo ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`,
onclick: () => onClick(r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' },
h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`)
);
}));
}
const FUNIL_ETAPAS = [
{ key: 'total', label: 'Leads capturados' },
{ key: 'disponiveis', label: 'Leads disponíveis' },
{ key: 'exportados', label: 'Leads exportados' },
{ key: null, label: 'Empresas contatadas' },
{ key: null, label: 'Empresas interessadas' },
{ key: null, label: 'Propostas enviadas' },
{ key: null, label: 'Clientes convertidos' }
];
function carregarFunil(s) {
const base = s.total || 1;
el('card-funil').replaceChildren(
h('h2', {}, 'Evolução Comercial'),
h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => {
const pendente = etapa.key === null;
const valor = pendente ? null : s[etapa.key];
const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100);
return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`,
title: pendente ? 'Aguardando integração com CRM' : '' },
h('div', { class: 'funil-label' }, etapa.label),
h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })),
h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor))
);
}))
);
}
async function carregarTabela() {
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
el('pg-prev').disabled = page <= 1;
el('pg-next').disabled = page >= totalPages;
const tbody = document.querySelector('#tabela-leads tbody');
if (!data.rows.length) {
tbody.replaceChildren(h('tr', {}, h('td', { colspan: '10', class: 'muted' }, 'Nenhum lead com este filtro.')));
return;
}
const abrirWhatsAppLead = async (event, lead) => {
event.stopPropagation();
if (!lead.whatsapp) {
toast('Este lead não tem WhatsApp cadastrado');
return;
}
let leadCompleto = lead;
try {
leadCompleto = await API.get(`/api/leads/${lead.id}`);
} catch {
leadCompleto = lead;
}
const settingsButton = el('btn-settings');
settingsButton.click();
setTimeout(() => {
window.GTISelecionarLeadManual?.(leadCompleto);
el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp;
el('manual-canal-naooficial').checked = true;
el('manual-canal-naooficial').dispatchEvent(new Event('change'));
el('manual-mensagem').value = `Olá ${leadCompleto.empresa || lead.empresa}! Somos do Grupo GTI. Podemos conversar?`;
el('manual-status').textContent = '';
}, 250);
};
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
h('td', { class: 'acao-col' }, h('button', {
class: 'whatsapp-action',
title: 'Enviar WhatsApp para este lead',
onclick: (event) => abrirWhatsAppLead(event, lead)
}, '☎')),
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
h('td', {}, lead.whatsapp || '—'),
h('td', {}, lead.cidade),
h('td', {}, lead.segmento_label || lead.segmento),
h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)),
h('td', { class: 'num' }, String(lead.score)),
h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)),
h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)),
h('td', {}, lead.exported_at ? '📤 sim' : '—')
)));
}
const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' };
function produtoCell(produto, afinidade) {
if (!produto) return '—';
const label = AFINIDADE_TABELA_LABELS[afinidade];
return label ? `${produto} · ${label}` : produto;
}
const OBSERVACAO_POR_PRIORIDADE = {
1: 'Empresa apta para abordagem imediata.',
2: 'Aguardar a janela de trabalho desta semana.',
3: 'Abordar quando não houver leads de prioridade mais alta.',
4: 'Reservar para campanhas específicas — baixa prioridade comercial.'
};
const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' };
function montarResumoComercial(lead) {
const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20)));
const pontosFortes = [];
if (lead.telefone_valido) pontosFortes.push('WhatsApp válido');
if (lead.cnpj_valido) pontosFortes.push('CNPJ válido');
if (lead.website) pontosFortes.push('Website');
if (lead.email) pontosFortes.push('E-mail cadastrado');
if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado');
return h('div', { class: 'resumo-comercial' },
h('h3', {}, 'Resumo comercial'),
h('div', { class: 'row space-between' },
h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)),
h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`)
),
lead.produtos && lead.produtos.length
? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'),
...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`)))
: h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'),
pontosFortes.length
? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`)))
: null,
h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '')
);
}
function montarScoreBreakdown(lead) {
const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30';
const qualidade = [
[lead.telefone_valido, 'WhatsApp válido', '+30'],
[lead.cnpj_valido, 'CNPJ válido', '+20'],
[lead.empresa_valida, 'Empresa válida', '+20'],
[!!lead.cidade, 'Cidade identificada', '+5'],
[!!lead.segmento, 'Nicho identificado', '+10'],
[!!lead.cnae, 'CNAE identificado', '+10'],
[!!lead.website, 'Website informado', '+5']
];
const comercial = [
[true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos],
[!!lead.website, 'Website informado', '+15'],
[!!lead.email, 'E-mail informado', '+10'],
[lead.telefone_valido, 'WhatsApp válido', '+15'],
[lead.cnpj_valido, 'CNPJ válido', '+20']
];
const bloco = (itens, titulo, valorTotal) => h('div', {},
h('div', { class: 'row space-between', style: 'margin-top:10px' },
h('strong', {}, titulo), h('span', {}, String(valorTotal))),
h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) =>
h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`)))
);
return h('div', { class: 'score-breakdown-wrap' },
bloco(qualidade, 'Score Qualidade', lead.score_qualidade),
bloco(comercial, 'Score Comercial', lead.score_comercial)
);
}
async function abrirLead(id) {
const lead = await API.get(`/api/leads/${id}`);
const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')];
el('lead-detalhe').replaceChildren(
h('h2', {}, lead.empresa),
montarResumoComercial(lead),
montarScoreBreakdown(lead),
h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' },
linha('Razão social', lead.legal_name),
linha('WhatsApp', lead.whatsapp),
linha('Cidade/UF', `${lead.cidade}/${lead.uf}`),
linha('Endereço', lead.endereco),
linha('Nicho', lead.segmento_label || lead.segmento),
linha('Interesse', lead.interesse),
linha('CNPJ', lead.cnpj),
linha('CNAE', lead.cnae),
linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`),
linha('Dores', (lead.pains || []).join(', ')),
linha('Origem', lead.origem),
linha('Capturado em', dataBr(lead.captured_at || lead.created_at)),
linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'),
linha('Exportações', lead.exports.length
? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ')
: 'nenhuma')
),
h('div', { class: 'row space-between', style: 'margin-top:14px' },
h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar')
)
);
el('modal-lead').showModal();
}
// ---------- exportação ----------
const EXPORT_ATALHOS = [
{ label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } },
{ label: 'Só ERP', set: () => { el('f-produto').value = 'ERP'; } },
{ label: 'Só G-Chat', set: () => { el('f-produto').value = 'G-Chat'; } },
{ label: 'Só Sistema de Ponto', set: () => { el('f-produto').value = 'Sistema de Ponto'; } },
{ label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } }
];
async function atualizarRecapExport() {
const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`;
return total;
}
function renderAtalhosExport() {
el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) =>
h('button', {
type: 'button', class: 'chip', onclick: async () => {
atalho.set();
await atualizar();
await atualizarRecapExport();
}
}, atalho.label)));
}
async function abrirModalExport() {
const total = await atualizarRecapExport();
if (!total) {
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
return;
}
renderAtalhosExport();
const { settings } = await API.get('/api/settings');
el('zp-pipeline').value = settings['zpro.pipelineId'] || 1;
el('zp-stage').value = settings['zpro.stageId'] || 1;
el('zp-responsible').value = settings['zpro.responsibleId'] || 15;
el('modal-export').showModal();
}
async function confirmarExport(event) {
event.preventDefault();
const tipo = document.querySelector('input[name="export-tipo"]:checked').value;
const body = {
tipo,
filtro: filtroAtual(),
params: {
marcarExportado: el('export-marcar').checked,
pipelineId: Number(el('zp-pipeline').value) || undefined,
stageId: Number(el('zp-stage').value) || undefined,
responsibleId: Number(el('zp-responsible').value) || undefined,
mensagem: tipo === 'zpro-bulk' ? el('export-mensagem').value : undefined
}
};
try {
const { jobId, totalEstimado } = await API.post('/api/exports', body);
el('modal-export').close();
toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`);
API.watchJob(jobId, {
done() {
toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.');
carregarExports();
atualizar();
},
error(e) {
toast(`❌ Erro na exportação: ${e.message}`);
carregarExports();
}
});
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function carregarExports() {
const { exports } = await API.get('/api/exports?limit=12');
const box = el('historico-exports');
if (!exports.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.'));
return;
}
const tipoLabel = {
excel: '📊 Planilha', 'zpro-oportunidades': '🗂️ Z-PRO Oportunidades',
'zpro-bulk': '📨 Z-PRO Disparo em massa', legado: '📦 Importação antiga (Z-PRO)'
};
box.replaceChildren(...exports.map((e) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`),
` · ${fmt.format(e.total_leads)} leads`),
h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`)
),
h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado'
? e.arquivos.map((nome) =>
h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` },
`⬇ ${nome.split('.').pop().toUpperCase()}`))
: []))
)));
}
// ---------- eventos ----------
function bindEvents() {
let debounce = null;
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
clearTimeout(debounce);
debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350);
});
}
el('btn-limpar-filtros').addEventListener('click', () => {
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
atualizar();
});
el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); });
el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); });
el('btn-exportar').addEventListener('click', abrirModalExport);
el('form-export').addEventListener('submit', confirmarExport);
for (const radio of document.querySelectorAll('input[name="export-tipo"]')) {
radio.addEventListener('change', () => {
el('export-mensagem-wrap').classList.toggle('hidden', radio.value !== 'zpro-bulk');
el('export-zpro-params').classList.toggle('hidden', radio.value === 'excel');
});
}
el('export-zpro-params').classList.add('hidden');
}
return { ativar, invalidar, bindEvents };
})();
window.Tratamento = Tratamento;
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GTI Máquina Comercial — Login</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="/img/logo-gti.png">
</head>
<body class="login-body">
<main class="login-wrap">
<form class="card login-card" id="form-login">
<div class="brand login-brand">
<img class="brand-mark brand-mark-lg" src="/img/logo-gti.png" alt="GTI">
<span class="brand-name">Máquina Comercial</span>
</div>
<div class="field">
<label for="login-username">Usuário</label>
<input type="text" id="login-username" autocomplete="username" required>
</div>
<div class="field">
<label for="login-password">Senha</label>
<input type="password" id="login-password" autocomplete="current-password" required>
</div>
<p class="muted login-erro" id="login-erro"></p>
<button class="btn btn-primary btn-lg" type="submit">Entrar</button>
</form>
</main>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/login.js"></script>
</body>
</html>
@@ -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"
@@ -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"
@@ -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=<ids> [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);
});
@@ -0,0 +1,24 @@
import { loadDotEnv } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { runOutreachBatch } from '../src/outreach/run.mjs';
// Dispara um lote manual da automacao de envio WhatsApp (Z-PRO), fora do
// ciclo automatico de 20 min do servidor. Util pra testar ou forcar um lote
// extra sem reiniciar o servidor:
// node scripts/enviar-outreach.mjs (ou npm run outreach)
//
// Respeita as mesmas regras do ciclo automatico (outreach.ativo, horario
// comercial, limite diario) — nao manda nada "escondido" do que a tela
// de Configuracoes mostra.
await loadDotEnv();
const log = (message) => console.log(`[outreach] ${message}`);
const db = openDb();
await ensureSeeds(db, { log });
const resumo = await runOutreachBatch({ db, log });
console.log(JSON.stringify(resumo, null, 2));
db.close();
@@ -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);
});
@@ -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);
});
@@ -0,0 +1,41 @@
import { loadDotEnv, GTI_PORT } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
import { createAppServer } from '../src/server/index.mjs';
import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
// Servidor local do GTI Maquina Comercial. Inicie com:
// npm start (ou node scripts/servidor.mjs, ou o GTI-Comercial.exe)
// e abra http://127.0.0.1:3900 no navegador.
await loadDotEnv();
const log = (message) => console.log(`[servidor] ${message}`);
const db = openDb();
await ensureSeeds(db, { log });
recoverOrphans(db, log);
const jobs = createJobManager();
const server = createAppServer({ db, jobs, log });
startOutreachScheduler({ db, log });
startCampaignScheduler({ db, log });
const port = GTI_PORT;
server.listen(port, '127.0.0.1', () => {
log(`GTI Maquina Comercial rodando em http://127.0.0.1:${port}`);
log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
});
function shutdown() {
log('encerrando...');
server.close(() => {
db.close();
process.exit(0);
});
setTimeout(() => process.exit(0), 3000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+19
View File
@@ -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);
}
@@ -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:<codigo>").
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);
});
@@ -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];
};
}
@@ -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
};
}
@@ -0,0 +1,133 @@
import { processLead } from '../lead-engine.mjs';
import { createLeadInserter, transaction } from '../db.mjs';
import { createGeocoder } from './geocode.mjs';
import { leadRowFromProcessed } from './lead-row.mjs';
import { getSource } from '../sources/index.mjs';
import { envString, envNumber, paths } from '../env.mjs';
export function defaultEndpoints() {
return {
overpassUrls: envString(
'OVERPASS_API_URL',
'https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter'
).split(',').map((url) => url.trim()).filter(Boolean),
nominatimUrl: envString('NOMINATIM_API_URL', 'https://nominatim.openstreetmap.org/search'),
wikidataUrl: envString('WIKIDATA_API_URL', 'https://query.wikidata.org/sparql'),
brasilApiUrl: envString('BRASILAPI_URL', 'https://brasilapi.com.br/api').replace(/\/$/, '')
};
}
// Executa uma captura: percorre as fontes selecionadas, processa cada lead
// (normalizacao + score) e insere no banco em lotes. A dedup acontece no
// proprio INSERT (ON CONFLICT dedup_key DO NOTHING) — leads ja capturados
// em qualquer execucao anterior contam como duplicados.
export async function runCapture({ db, runId, params, emit = () => {}, log = () => {}, isCancelled = () => false }) {
const insert = createLeadInserter(db);
const endpoints = defaultEndpoints();
const ctx = {
db,
emit,
log,
isCancelled,
cnpjDir: params.cnpjDir || paths.cnpjDir,
endpoints,
delays: {
overpassDelayMs: envNumber('OVERPASS_REQUEST_DELAY_MS', 5000),
overpassTimeoutMs: envNumber('OVERPASS_TIMEOUT_MS', 15000)
},
overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'false') === 'true',
geocode: createGeocoder(db, { nominatimUrl: endpoints.nominatimUrl })
};
const counts = { raw: 0, new: 0, dup: 0 };
const updateRun = runId
? db.prepare(
`UPDATE capture_runs SET raw_count = ?, new_count = ?, dup_count = ?, progress_json = ? WHERE id = ?`
)
: null;
const persistCounts = (progress) => {
if (updateRun) {
updateRun.run(counts.raw, counts.new, counts.dup, JSON.stringify(progress || {}), runId);
}
};
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?`
).run(process.pid, runId);
}
let cancelled = false;
try {
for (const sourceId of params.sources) {
if (isCancelled()) {
cancelled = true;
break;
}
const source = getSource(sourceId);
if (!source) {
log(`fonte desconhecida: ${sourceId} — pulada`);
continue;
}
log(`fonte ${sourceId}: iniciando`);
emit({ phase: sourceId, message: 'iniciando', counts: { ...counts } });
for await (const batch of source.collect(params, ctx)) {
counts.raw += batch.leads.length;
transaction(db, () => {
for (const payload of batch.leads) {
const result = processLead(payload);
const row = leadRowFromProcessed({ payload, lead: result.lead }, sourceId);
row.capture_run_id = runId || null;
if (insert(row) === 'inserted') counts.new += 1;
else counts.dup += 1;
}
});
const progress = {
phase: sourceId,
message: `${batch.label}: ${counts.new} novos, ${counts.dup} duplicados (${counts.raw} brutos)`,
counts: { ...counts }
};
emit(progress);
persistCounts(progress);
if (isCancelled()) {
cancelled = true;
break;
}
}
if (cancelled) break;
log(`fonte ${sourceId}: concluida`);
}
} catch (error) {
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'error', error = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(String(error.message || error), counts.raw, counts.new, counts.dup, runId);
}
throw error;
}
const finalStatus = cancelled ? 'cancelled' : 'done';
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(finalStatus, counts.raw, counts.new, counts.dup, runId);
}
return { ...counts, status: finalStatus };
}
export function createCaptureRun(db, params) {
const result = db.prepare('INSERT INTO capture_runs (params_json, status) VALUES (?, ?)')
.run(JSON.stringify(params), 'pending');
return Number(result.lastInsertRowid);
}
+657
View File
@@ -0,0 +1,657 @@
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { paths } from './env.mjs';
import { segmentLabel } from './scoring.mjs';
import { cleanText } from './lead-engine.mjs';
import { hashPassword } from './auth.mjs';
const MIGRATIONS = [
// v1 — esquema inicial
`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE municipios (
codigo_ibge TEXT PRIMARY KEY,
codigo_tom TEXT,
nome TEXT NOT NULL,
nome_clean TEXT NOT NULL,
uf TEXT NOT NULL
);
CREATE INDEX idx_municipios_uf ON municipios(uf, nome_clean);
CREATE INDEX idx_municipios_tom ON municipios(codigo_tom);
CREATE TABLE cnaes (
codigo TEXT PRIMARY KEY,
descricao TEXT NOT NULL
);
CREATE TABLE segments (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
interest TEXT,
queries_json TEXT NOT NULL DEFAULT '[]',
cnae_codes_json TEXT NOT NULL DEFAULT '[]',
overpass_tags_json TEXT NOT NULL DEFAULT '[]',
pains_json TEXT NOT NULL DEFAULT '[]',
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE geo_cache (
key TEXT PRIMARY KEY,
south REAL, west REAL, north REAL, east REAL,
provider TEXT NOT NULL,
resolved_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE capture_runs (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
params_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress_json TEXT,
raw_count INTEGER NOT NULL DEFAULT 0,
new_count INTEGER NOT NULL DEFAULT 0,
dup_count INTEGER NOT NULL DEFAULT 0,
error TEXT,
pid INTEGER,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE extractions (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
uf TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
csv_path TEXT,
total_rows INTEGER,
with_phone INTEGER,
municipios_count INTEGER,
progress_json TEXT,
error TEXT,
pid INTEGER,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE leads (
id INTEGER PRIMARY KEY,
lead_uid TEXT,
dedup_key TEXT NOT NULL UNIQUE,
empresa TEXT NOT NULL,
legal_name TEXT DEFAULT '',
whatsapp TEXT DEFAULT '',
ddd TEXT DEFAULT '',
email TEXT DEFAULT '',
cidade TEXT DEFAULT '',
uf TEXT DEFAULT '',
segmento TEXT DEFAULT '',
segmento_label TEXT DEFAULT '',
interesse TEXT DEFAULT '',
cnpj TEXT DEFAULT '',
cnae TEXT DEFAULT '',
endereco TEXT DEFAULT '',
website TEXT DEFAULT '',
pains_json TEXT NOT NULL DEFAULT '[]',
origem TEXT NOT NULL DEFAULT '',
telefone_valido INTEGER NOT NULL DEFAULT 0,
cnpj_valido INTEGER NOT NULL DEFAULT 0,
empresa_valida INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
classificacao TEXT NOT NULL DEFAULT 'ruim',
motivo_classificacao TEXT DEFAULT '',
capture_run_id INTEGER REFERENCES capture_runs(id),
captured_at TEXT,
exported_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX idx_leads_uf_cidade ON leads(uf, cidade);
CREATE INDEX idx_leads_class_score ON leads(classificacao, score DESC);
CREATE INDEX idx_leads_segmento ON leads(segmento);
CREATE INDEX idx_leads_whatsapp ON leads(whatsapp);
CREATE INDEX idx_leads_cnpj ON leads(cnpj);
CREATE INDEX idx_leads_exported ON leads(exported_at);
CREATE INDEX idx_leads_origem ON leads(origem);
CREATE TABLE exports (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
tipo TEXT NOT NULL,
filtro_json TEXT NOT NULL DEFAULT '{}',
params_json TEXT NOT NULL DEFAULT '{}',
arquivos_json TEXT NOT NULL DEFAULT '[]',
total_leads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
CREATE TABLE export_leads (
export_id INTEGER NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
lead_id INTEGER NOT NULL REFERENCES leads(id),
PRIMARY KEY (export_id, lead_id)
) WITHOUT ROWID;
CREATE INDEX idx_export_leads_lead ON export_leads(lead_id);
`,
// v2 — score de qualidade dos dados + score comercial (aderencia de nicho,
// website, e-mail) + score geral (60% comercial + 40% qualidade), prioridade
// comercial (1-4, derivada da classificacao) e produto recomendado por nicho.
`
ALTER TABLE segments ADD COLUMN affinity TEXT NOT NULL DEFAULT 'media';
ALTER TABLE segments ADD COLUMN products_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE leads ADD COLUMN score_qualidade INTEGER NOT NULL DEFAULT 0;
ALTER TABLE leads ADD COLUMN score_comercial INTEGER NOT NULL DEFAULT 0;
ALTER TABLE leads ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 4;
ALTER TABLE leads ADD COLUMN produto_principal TEXT DEFAULT '';
ALTER TABLE leads ADD COLUMN produtos_json TEXT NOT NULL DEFAULT '[]';
CREATE INDEX idx_leads_prioridade ON leads(prioridade, score DESC);
CREATE INDEX idx_leads_produto ON leads(produto_principal);
`,
// v3 — login: usuarios do app (senha com hash, papel admin/comercial).
`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'comercial',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`,
// v4 — automacao de envio WhatsApp (Z-PRO): um template de mensagem por
// produto/servico, e o historico de disparos (o que foi mandado, pra quem,
// quando) usado tanto pra nao repetir envio quanto pra decidir quando tentar
// o proximo produto recomendado.
`
CREATE TABLE message_templates (
id INTEGER PRIMARY KEY,
produto TEXT NOT NULL UNIQUE,
mensagem TEXT NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1,
updated_at TEXT
);
CREATE TABLE outreach_sends (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
produto TEXT NOT NULL,
mensagem_enviada TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'enviado',
erro TEXT,
zpro_ticket_id TEXT,
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto);
CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at);
`,
// v5 — canal de envio por template: 'nao_oficial' (texto livre, bulkIndividual,
// conexao QR/Baileys) ou 'oficial' (API oficial Meta/WABA, exige template
// ja aprovado no Meta Business Manager — nao da pra criar/aprovar por aqui).
`
ALTER TABLE message_templates ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial';
ALTER TABLE message_templates ADD COLUMN template_waba_nome TEXT NOT NULL DEFAULT '';
ALTER TABLE message_templates ADD COLUMN template_waba_idioma TEXT NOT NULL DEFAULT 'pt_BR';
ALTER TABLE outreach_sends ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial';
`,
// v6 — envio manual avulso (mandar mensagem pra um numero especifico, sem
// estar ligado a um lead da base). lead_id precisa virar opcional (SQLite
// nao remove NOT NULL com ALTER TABLE direto, por isso o rebuild da tabela)
// e numero/origem guardam pra quem foi e se foi automatico ou manual.
`
CREATE TABLE outreach_sends_v6 (
id INTEGER PRIMARY KEY,
lead_id INTEGER REFERENCES leads(id),
produto TEXT NOT NULL DEFAULT '',
numero TEXT,
mensagem_enviada TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'enviado',
erro TEXT,
zpro_ticket_id TEXT,
canal TEXT NOT NULL DEFAULT 'nao_oficial',
origem TEXT NOT NULL DEFAULT 'automatico',
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO outreach_sends_v6
(id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at)
SELECT id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at
FROM outreach_sends;
DROP TABLE outreach_sends;
ALTER TABLE outreach_sends_v6 RENAME TO outreach_sends;
CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto);
CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at);
`,
// v7 — variaveis dos templates WABA automaticos por produto. Cada item pode
// usar placeholders resolvidos por lead: {empresa}, {produto}, {cidade}, etc.
`
ALTER TABLE message_templates ADD COLUMN template_waba_variaveis_json TEXT NOT NULL DEFAULT '[]';
`,
// v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57
// segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala:
// o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch
// existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao
// existia antes): uma fila que recontata o mesmo lead ao longo de semanas
// precisa checar quem ja pediu pra parar.
`
CREATE TABLE outreach_campaigns (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
produto TEXT NOT NULL,
segmentos_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'rascunho',
pause_reason TEXT,
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
limite_diario_teto INTEGER NOT NULL DEFAULT 80,
semana_iniciada_em TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_campaign_variants (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
code TEXT NOT NULL,
mensagem TEXT NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id);
CREATE TABLE outreach_campaign_queue (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
lead_id INTEGER NOT NULL REFERENCES leads(id),
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
segmento TEXT NOT NULL DEFAULT '',
numero TEXT NOT NULL,
rendered_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'preview',
cancel_reason TEXT,
outreach_send_id INTEGER REFERENCES outreach_sends(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status);
CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id);
CREATE TABLE outreach_lead_states (
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
stage TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_stage_events (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
campaign_id INTEGER REFERENCES outreach_campaigns(id),
stage TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id);
CREATE TABLE outreach_appointments (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
kind TEXT NOT NULL,
scheduled_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id);
CREATE TABLE outreach_opt_out (
numero TEXT PRIMARY KEY,
motivo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
];
export function openDb(dbPath = paths.db) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA busy_timeout = 5000;');
db.exec('PRAGMA foreign_keys = ON;');
migrate(db);
return db;
}
export function migrate(db) {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);`);
const row = db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get();
const current = row?.version || 0;
for (let version = current + 1; version <= MIGRATIONS.length; version += 1) {
db.exec('BEGIN');
try {
db.exec(MIGRATIONS[version - 1]);
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
}
export function transaction(db, fn) {
db.exec('BEGIN');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
const LEAD_COLUMNS = [
'lead_uid', 'dedup_key', 'empresa', 'legal_name', 'whatsapp', 'ddd', 'email',
'cidade', 'uf', 'segmento', 'segmento_label', 'interesse', 'cnpj', 'cnae',
'endereco', 'website', 'pains_json', 'origem', 'telefone_valido', 'cnpj_valido',
'empresa_valida', 'score', 'classificacao', 'motivo_classificacao',
'score_qualidade', 'score_comercial', 'prioridade', 'produto_principal', 'produtos_json',
'capture_run_id', 'captured_at', 'exported_at'
];
// INSERT com dedup embutida: conflitos na dedup_key sao ignorados
// (lead ja existe de captura/importacao anterior).
export function createLeadInserter(db) {
const placeholders = LEAD_COLUMNS.map((c) => `:${c}`).join(', ');
const stmt = db.prepare(
`INSERT INTO leads (${LEAD_COLUMNS.join(', ')}) VALUES (${placeholders})
ON CONFLICT(dedup_key) DO NOTHING`
);
return (lead) => {
const params = {};
for (const column of LEAD_COLUMNS) {
const value = lead[column];
params[column] = value === undefined || value === null ? null : value;
}
if (params.empresa === null) params.empresa = '';
if (params.score_qualidade === null) params.score_qualidade = 0;
if (params.score_comercial === null) params.score_comercial = 0;
if (params.prioridade === null) params.prioridade = 4;
if (params.produto_principal === null) params.produto_principal = '';
if (params.produtos_json === null) params.produtos_json = '[]';
if (params.dedup_key === null) throw new Error('lead sem dedup_key');
const result = stmt.run(params);
return result.changes > 0 ? 'inserted' : 'duplicate';
};
}
export function getSetting(db, key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(db, key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
const DEFAULT_SETTINGS = {
'zpro.pipelineId': '1',
'zpro.stageId': '1',
'zpro.responsibleId': '15',
'zpro.ticketQueueId': '12',
'zpro.status': 'open',
'zpro.closingForecastDays': '30',
'zpro.validateNumber': 'true',
// Credenciais da API externa do Z-PRO usadas pela automacao de envio
// WhatsApp (src/outreach/zpro-client.mjs). Editaveis em Configuracoes >
// Credenciais Z-PRO; vazio aqui cai pro .env (ZPRO_BASE_URL/etc) se existir.
'zpro.baseUrl': '',
'zpro.apiId': '',
'zpro.bearerToken': '',
'zpro.whatsappId': '',
'meta_diaria': '100',
// Automacao de envio WhatsApp (Z-PRO) — comeca desligada de proposito:
// so liga depois que o .env tiver as credenciais e os templates forem revisados.
'outreach.ativo': 'false',
'outreach.mensagensPorDia': '10',
'outreach.diasEntreProdutos': '5',
'outreach.tentarProximoProduto': 'false',
'outreach.horaInicio': '08:00',
'outreach.horaFim': '18:00',
// Endpoint interno do GChat que lista templates WABA do canal GRUPO GTI COEX.
// O token de sessao do GChat deve ser preenchido localmente na tela; nao fica
// embutido no codigo.
'gchat.templatesUrl': 'https://apigchat.gtigrupo.com.br/wabametaTemplate/1126322523899986',
'gchat.bearerToken': ''
};
export function seedSettings(db, extra = {}) {
const entries = { ...DEFAULT_SETTINGS, ...extra };
transaction(db, () => {
const stmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(entries)) {
stmt.run(key, String(value));
}
});
}
// Upsert (nao so INSERT OR IGNORE): reaplicar o config.json com afinidade/produtos
// ajustados deve atualizar os 57 nichos ja existentes, nao so inserir os novos.
export function seedSegments(db, segments) {
transaction(db, () => {
const stmt = db.prepare(
`INSERT INTO segments (name, label, interest, queries_json, cnae_codes_json, overpass_tags_json, pains_json, affinity, products_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
label = excluded.label,
interest = excluded.interest,
queries_json = excluded.queries_json,
cnae_codes_json = excluded.cnae_codes_json,
overpass_tags_json = excluded.overpass_tags_json,
pains_json = excluded.pains_json,
affinity = excluded.affinity,
products_json = excluded.products_json`
);
for (const segment of segments) {
stmt.run(
segment.name,
segmentLabel(segment.name),
segment.interest || '',
JSON.stringify(segment.queries || []),
JSON.stringify(segment.cnaeCodes || []),
JSON.stringify(segment.overpassTags || []),
JSON.stringify(segment.pains || []),
segment.affinity || 'media',
JSON.stringify(segment.products || [])
);
}
});
}
export function seedMunicipios(db, rows) {
transaction(db, () => {
const stmt = db.prepare(
'INSERT OR IGNORE INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES (?, ?, ?, ?)'
);
for (const row of rows) {
stmt.run(String(row.codigo_ibge), row.nome, cleanText(row.nome), row.uf);
}
});
}
export function seedCnaes(db, rows) {
transaction(db, () => {
const stmt = db.prepare('INSERT OR IGNORE INTO cnaes (codigo, descricao) VALUES (?, ?)');
for (const row of rows) {
stmt.run(String(row.codigo), row.descricao);
}
});
}
export function seedGeoCache(db, cityAreas) {
transaction(db, () => {
const stmt = db.prepare(
`INSERT OR IGNORE INTO geo_cache (key, south, west, north, east, provider)
VALUES (?, ?, ?, ?, ?, 'config-legacy')`
);
for (const area of cityAreas || []) {
const [south, west, north, east] = area.bbox;
stmt.run(`bbox:${area.state}:${cleanText(area.name)}`, south, west, north, east);
}
});
}
// Zera a base comercial (leads, exportacoes, capturas e extracoes) mantendo
// tabelas de referencia (segments, settings, municipios, cnaes) intactas.
// Usado pelo botao "Limpar toda a base" da tela de Configuracoes.
export function resetLeadsBase(db) {
transaction(db, () => {
db.exec('DELETE FROM export_leads');
db.exec('DELETE FROM exports');
db.exec('DELETE FROM leads');
db.exec('DELETE FROM capture_runs');
db.exec('DELETE FROM extractions');
db.exec('DELETE FROM geo_cache');
});
db.exec('VACUUM');
}
export function createUser(db, { username, passwordHash, role = 'comercial' }) {
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role);
}
export function findUserByUsername(db, username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function listUsers(db) {
return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all();
}
// Primeiro acesso: sem usuarios cadastrados, cria admin/admin — deve ser
// trocado ou complementado com outros usuarios pela tela de Usuarios.
export function ensureDefaultAdmin(db) {
if (countRows(db, 'users') > 0) return;
createUser(db, { username: 'admin', passwordHash: hashPassword('admin'), role: 'admin' });
}
function countRows(db, table) {
return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total;
}
const MENSAGEM_PADRAO =
'Ola! Somos o Grupo GTI. Notamos que a {empresa} pode se beneficiar do nosso ' +
'{produto} — ajudamos empresas em {cidade} com isso. Podemos conversar?';
// Um template por produto/servico (INSERT OR IGNORE: nao sobrescreve texto ja
// editado pelo usuario na tela de Configuracoes ao reaplicar em boots seguintes).
export function seedMessageTemplates(db, produtos) {
transaction(db, () => {
const stmt = db.prepare('INSERT OR IGNORE INTO message_templates (produto, mensagem) VALUES (?, ?)');
for (const produto of produtos) {
if (!produto) continue;
stmt.run(produto, MENSAGEM_PADRAO.replace('{produto}', produto));
}
});
}
export function listMessageTemplates(db) {
return db.prepare('SELECT * FROM message_templates ORDER BY produto').all();
}
export function updateMessageTemplate(db, produto, { mensagem, ativo, canal, templateWabaNome, templateWabaIdioma, templateWabaVariaveis }) {
const row = db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto);
if (!row) return null;
const novaMensagem = mensagem !== undefined ? mensagem : row.mensagem;
const novoAtivo = ativo !== undefined ? (ativo ? 1 : 0) : row.ativo;
const novoCanal = canal === 'oficial' ? 'oficial' : canal === 'nao_oficial' ? 'nao_oficial' : row.canal;
const novoNome = templateWabaNome !== undefined ? templateWabaNome : row.template_waba_nome;
const novoIdioma = templateWabaIdioma !== undefined ? templateWabaIdioma : row.template_waba_idioma;
const novasVariaveis = templateWabaVariaveis !== undefined
? JSON.stringify((Array.isArray(templateWabaVariaveis) ? templateWabaVariaveis : [])
.map((value) => String(value || '').trim())
.filter(Boolean))
: row.template_waba_variaveis_json;
db.prepare(
`UPDATE message_templates
SET mensagem = ?, ativo = ?, canal = ?, template_waba_nome = ?, template_waba_idioma = ?, template_waba_variaveis_json = ?, updated_at = datetime('now')
WHERE produto = ?`
).run(novaMensagem, novoAtivo, novoCanal, novoNome, novoIdioma, novasVariaveis, produto);
return db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto);
}
// Popula tabelas de referencia na primeira execucao. Tolerante a arquivos de
// seed ausentes (loga e segue) para o servidor nunca falhar no boot por isso.
export async function ensureSeeds(db, { log = () => {} } = {}) {
const fsp = await import('node:fs/promises');
// seedSegments faz upsert (nao so insere): sempre reaplicado no boot para que
// ajustes de afinidade/produtos no config.json cheguem ao banco so reiniciando.
try {
const config = JSON.parse(await fsp.readFile(paths.config, 'utf8'));
seedSegments(db, config.segments || []);
if (countRows(db, 'geo_cache') === 0) seedGeoCache(db, config.cityAreas || []);
const produtos = [...new Set(
(config.segments || []).flatMap((s) => (s.products || []).map((p) => p.nome)).filter(Boolean)
)];
seedMessageTemplates(db, produtos);
log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes, ${countRows(db, 'message_templates')} templates`);
} catch (error) {
log(`seeds: config.json indisponivel (${error.message})`);
}
if (countRows(db, 'municipios') === 0) {
try {
const seedPath = path.join(paths.seeds, 'municipios-ibge.json');
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
seedMunicipios(db, rows);
log(`seeds: ${countRows(db, 'municipios')} municipios (IBGE)`);
} catch (error) {
log(`seeds: municipios-ibge.json indisponivel (${error.message})`);
}
}
if (countRows(db, 'cnaes') === 0) {
try {
const seedPath = path.join(paths.seeds, 'cnaes.json');
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
seedCnaes(db, rows);
log(`seeds: ${countRows(db, 'cnaes')} CNAEs`);
} catch (error) {
log(`seeds: cnaes.json indisponivel (${error.message})`);
}
}
let templates = {};
try {
const templatesPath = path.join(paths.root, 'whatsapp', 'templates-mensagens.json');
templates = { 'templates.mensagens': await fsp.readFile(templatesPath, 'utf8') };
} catch {
// pasta whatsapp/ ja removida: template ja deve estar no banco
}
seedSettings(db, templates);
const criouAdmin = countRows(db, 'users') === 0;
ensureDefaultAdmin(db);
if (criouAdmin) log('seeds: usuario admin/admin criado (primeiro acesso)');
}
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Raiz do projeto resolvida a partir deste arquivo (src/env.mjs -> pasta acima),
// para funcionar igual quando o servidor e iniciado pelo launcher (cwd diferente).
export const projectRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
export const paths = {
root: projectRoot,
data: path.join(projectRoot, 'data'),
db: process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db'),
exports: path.join(projectRoot, 'data', 'exports'),
seeds: path.join(projectRoot, 'data', 'seeds'),
cnpjDir: process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'),
rfbRaw: path.join(projectRoot, 'prospeccao', 'cnpj', 'rfb-raw'),
config: path.join(projectRoot, 'prospeccao', 'config.json'),
public: path.join(projectRoot, 'public')
};
export let GTI_PORT = envNumber('GTI_PORT', 3900);
function refreshEnvDerivedValues() {
paths.db = process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db');
paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj');
paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw');
GTI_PORT = envNumber('GTI_PORT', 3900);
}
export async function loadDotEnv(rootDir = projectRoot) {
try {
const content = await fs.readFile(path.join(rootDir, '.env'), 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const separator = trimmed.indexOf('=');
if (separator === -1) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
refreshEnvDerivedValues();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
export function envNumber(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
export function envString(name, fallback) {
return process.env[name] || fallback;
}
+104
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
}</tr>\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) =>
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(row[h])}</td>`
);
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
}
await writeStream(xls, '</table></body></html>\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)]
};
}
@@ -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);
});
+122
View File
@@ -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)] };
}
@@ -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);
});
@@ -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-<UF>.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 };
}
+160
View File
@@ -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`);
}
}
}
}
@@ -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;
}
}
+411
View File
@@ -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'
};
}
@@ -0,0 +1,14 @@
import { renderTemplate } from './templates.mjs';
// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead
// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem
// precisar guardar nenhum estado extra so pra isso.
export function variantForLead(variants, leadId) {
if (!variants.length) throw new Error('campanha sem variantes ativas');
const index = Number(leadId) % variants.length;
return variants[index];
}
export function renderCampaignMessage(variant, lead) {
return renderTemplate(variant.mensagem, lead);
}
@@ -0,0 +1,116 @@
function dateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function applyPause(db, campaignId, reason) {
db.prepare(
`UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now')
WHERE id = ? AND status = 'rodando'`
).run(reason, campaignId);
}
// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/
// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta),
// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue
// (nao por um valor de origem dedicado).
//
// PURA/read-only: so calcula e retorna {pause, reason}, nunca escreve no
// banco. Quem precisa da mutacao (pausar de verdade quando pause=true) deve
// chamar applyCampaignHealth, nao esta funcao — assim uma rota GET (como
// /api/campaigns/:id/saude, chamada so de abrir o painel na UI) nunca pausa
// uma campanha como efeito colateral de uma leitura.
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null };
const todayKey = dateKey(now);
const attemptsToday = Number(db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_sends s ON s.id = q.outreach_send_id
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (attemptsToday >= 10) {
const failureCount = Number(db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_sends s ON s.id = q.outreach_send_id
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (failureCount / attemptsToday > 0.10) {
return { pause: true, reason: 'high_failure_rate' };
}
}
const contacts = Number(db.prepare(
`SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')`
).get(campaignId).total);
if (contacts >= 20) {
const negativeCount = Number(db.prepare(
`SELECT COUNT(DISTINCT q.lead_id) AS total
FROM outreach_campaign_queue q
JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
AND ls.stage IN ('sem_interesse', 'opt_out')`
).get(campaignId).total);
if (negativeCount / contacts > 0.10) {
return { pause: true, reason: 'high_negative_rate' };
}
}
if (contacts >= 50) {
const respondedCount = Number(db.prepare(
`SELECT COUNT(DISTINCT q.lead_id) AS total
FROM outreach_campaign_queue q
JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
).get(campaignId).total);
if (respondedCount / contacts < 0.05) {
return { pause: true, reason: 'low_reply_rate' };
}
}
return { pause: false, reason: null };
}
// MUTANTE: chama a avaliacao pura acima e, se ela mandar pausar, persiste a
// pausa de verdade via applyPause. E isto que o worker deve chamar apos cada
// envio real — e so o worker, nunca uma rota de leitura.
export function applyCampaignHealth(db, campaignId, now = new Date()) {
const resultado = evaluateCampaignHealth(db, campaignId, now);
if (resultado.pause) {
applyPause(db, campaignId, resultado.reason);
}
return resultado;
}
// Numero de leads distintos com resposta por variante, via subquery
// correlacionada (nao um JOIN direto) para nao contar duplicado quando um
// lead tem mais de um outreach_stage_events.
export function variantStats(db, campaignId) {
const rows = db.prepare(
`SELECT v.id AS variant_id, v.code,
COUNT(*) AS enviados,
(SELECT COUNT(DISTINCT q2.lead_id)
FROM outreach_campaign_queue q2
JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id
WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error')
) AS respostas
FROM outreach_campaign_queue q
JOIN outreach_campaign_variants v ON v.id = q.variant_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
GROUP BY v.id, v.code`
).all(campaignId);
return rows.map((row) => ({
code: row.code,
enviados: row.enviados,
respostas: row.respostas,
taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0,
elegivelParaComparacao: row.enviados >= 40
}));
}
@@ -0,0 +1,94 @@
import { getCampaign } from './campaign-store.mjs';
import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs';
const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'";
function candidateQuery() {
return `
SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf,
leads.segmento_label, leads.prioridade, leads.score
FROM leads
WHERE leads.produto_principal = ?
AND leads.segmento = ?
AND leads.telefone_valido = 1
AND leads.whatsapp != ''
AND NOT EXISTS (
SELECT 1 FROM outreach_sends
WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado'
AND outreach_sends.lead_id = leads.id
)
AND NOT EXISTS (
SELECT 1 FROM outreach_sends
WHERE outreach_sends.status = 'enviado'
AND outreach_sends.numero = leads.whatsapp
AND outreach_sends.origem = 'manual'
)
AND NOT EXISTS (
SELECT 1 FROM outreach_campaign_queue q
JOIN outreach_campaigns c ON c.id = q.campaign_id
WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES})
)
AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp)
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`;
}
// Gera ate `quantidade` linhas novas de fila (status 'preview') para a
// campanha, buscando candidatos em todos os segmentos configurados. Nao e
// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio
// do worker) — o operador pode gerar e revisar um lote maior do que vai
// enviar hoje.
export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) {
const campaign = getCampaign(db, campaignId);
if (!campaign) throw new Error('campanha nao encontrada');
if (!campaign.variants.length) throw new Error('campanha sem variantes ativas');
if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados');
const statement = db.prepare(candidateQuery());
const todosCandidatos = campaign.segmentos
.flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto))
.sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id);
const candidatos = todosCandidatos.slice(0, quantidade);
const insert = db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status)
VALUES (?, ?, ?, ?, ?, ?, 'preview')`
);
let adicionados = 0;
for (const lead of candidatos) {
const variant = variantForLead(campaign.variants, lead.id);
const renderedMessage = renderCampaignMessage(variant, lead);
insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage);
adicionados += 1;
}
return { candidatosEncontrados: todosCandidatos.length, adicionadosNaFila: adicionados };
}
export function listQueue(db, campaignId, status = null) {
if (status) {
return db.prepare(
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id'
).all(campaignId, status);
}
return db.prepare(
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
).all(campaignId);
}
export function approveQueueRows(db, campaignId, queueIds) {
if (!queueIds.length) return 0;
const placeholders = queueIds.map(() => '?').join(', ');
return db.prepare(
`UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now')
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
).run(campaignId, ...queueIds).changes;
}
export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') {
if (!queueIds.length) return 0;
const placeholders = queueIds.map(() => '?').join(', ');
return db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now')
WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})`
).run(reason, campaignId, ...queueIds).changes;
}
@@ -0,0 +1,25 @@
import { runCampaignQueue } from './campaign-worker.mjs';
import { escalateWeeklyLimits } from './campaign-store.mjs';
const CICLO_MS = 20 * 60 * 1000;
// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch)
// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco
// depois do boot), rodando ao lado sem interferir um no outro.
export function startCampaignScheduler({ db, log = () => {} }) {
const ciclo = () => {
escalateWeeklyLimits(db);
runCampaignQueue({ db, log }).catch((error) => {
log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`);
});
};
const primeiro = setTimeout(ciclo, 45 * 1000);
primeiro.unref();
const timer = setInterval(ciclo, CICLO_MS);
timer.unref();
return () => {
clearTimeout(primeiro);
clearInterval(timer);
};
}
@@ -0,0 +1,115 @@
import { transaction } from '../db.mjs';
export function createCampaign(db, {
nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80
}) {
return transaction(db, () => {
const result = db.prepare(
`INSERT INTO outreach_campaigns
(nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto)
VALUES (?, ?, ?, ?, ?)`
).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto);
const campaignId = Number(result.lastInsertRowid);
const insertVariant = db.prepare(
`INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)`
);
for (const variante of variantes) {
insertVariant.run(campaignId, variante.code, variante.mensagem);
}
return getCampaign(db, campaignId);
});
}
export function getCampaign(db, id) {
const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id);
if (!row) return null;
return {
...row,
segmentos: JSON.parse(row.segmentos_json || '[]'),
variants: listVariants(db, id)
};
}
export function listCampaigns(db) {
return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all()
.map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') }));
}
export function listVariants(db, campaignId) {
return db.prepare(
'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code'
).all(campaignId);
}
// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'.
// pause_reason so fica setado quando o novo status e 'pausado'.
export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
if (status === 'rodando') {
// When transitioning to 'rodando', stamp semana_iniciada_em only on first run (if currently NULL)
db.prepare(
`UPDATE outreach_campaigns
SET status = ?, pause_reason = ?, semana_iniciada_em = COALESCE(semana_iniciada_em, datetime('now')), updated_at = datetime('now')
WHERE id = ?`
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
} else {
db.prepare(
`UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
}
return getCampaign(db, campaignId);
}
// So avanca campanha que estiver de fato pausada — retomar uma campanha em
// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando').
export function resumeCampaign(db, campaignId) {
// Reseta semana_iniciada_em pra agora: uma campanha que ficou pausada (por
// saude ruim ou motivo manual) por varios dias nao pode, ao ser retomada,
// já satisfazer o "rodando ha 7+ dias" do escalateWeeklyLimits e dobrar o
// limite diario logo de cara — isso inverteria a logica de seguranca.
db.prepare(
`UPDATE outreach_campaigns
SET status = 'rodando', pause_reason = NULL, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND status = 'pausado'`
).run(campaignId);
return getCampaign(db, campaignId);
}
// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando
// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com
// status='rodando' avancam — uma pausada fica congelada ate o operador retomar.
export function escalateWeeklyLimits(db, now = new Date()) {
const campaigns = db.prepare(
`SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em
FROM outreach_campaigns WHERE status = 'rodando'`
).all();
let escaladas = 0;
for (const campaign of campaigns) {
if (!campaign.semana_iniciada_em) continue;
const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000;
if (dias < 7) continue;
const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2);
if (novoLimite === campaign.limite_diario_atual) continue;
db.prepare(
`UPDATE outreach_campaigns
SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
WHERE id = ?`
).run(novoLimite, campaign.id);
escaladas += 1;
}
return escaladas;
}
export function isOptedOut(db, numero) {
return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero));
}
export function addOptOut(db, numero, motivo = null) {
db.prepare(
`INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?)
ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo`
).run(numero, motivo);
}
export function removeOptOut(db, numero) {
db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero);
}
@@ -0,0 +1,131 @@
import { createZproClient } from './zpro-client.mjs';
import { isOptedOut } from './campaign-store.mjs';
import { applyCampaignHealth } from './campaign-metrics.mjs';
import { getSetting } from '../db.mjs';
// Mesmo intervalo aleatorio entre envios do run.mjs (INTERVALO_MIN_MS/
// INTERVALO_MAX_MS), duplicado aqui de proposito — os dois workers devem se
// comportar de forma consistente contra o mesmo numero de WhatsApp, mas sao
// modulos independentes (nao importar um do outro).
const SLEEP_MIN_MS = 3000;
const SLEEP_MAX_MS = 8000;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function randomSleepMs() {
return SLEEP_MIN_MS + Math.random() * (SLEEP_MAX_MS - SLEEP_MIN_MS);
}
// Mesma logica de comparacao de string HH:MM do run.mjs.
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
const hhmm = now.toTimeString().slice(0, 5);
return hhmm >= horaInicio && hhmm <= horaFim;
}
function todayAttempts(db, campaignId) {
return Number(db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_sends s ON s.id = q.outreach_send_id
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')`
).get(campaignId).total);
}
// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a
// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO
// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca
// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem
// um estado intermediario "reservado" nem logica de recuperacao pos-crash.
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {}, sleepMs = null }) {
const client = zproClient || createZproClient(db);
const summary = { enviados: 0, erros: 0, pulados: 0 };
// Mesmas duas travas de seguranca do disparo automatico (run.mjs):
// interruptor geral e janela de horario comercial. Sem elas, o worker de
// campanha mandaria mensagem 24h/dia mesmo com o operador tendo desligado
// o outreach nas Configuracoes.
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
log('campanhas: desativado (outreach.ativo=false)');
return summary;
}
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
if (!withinBusinessHours(horaInicio, horaFim, now)) {
log(`campanhas: fora do horario comercial (${horaInicio}-${horaFim})`);
return summary;
}
const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all();
for (const { id: campaignId } of campaigns) {
const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId);
let attemptsToday = todayAttempts(db, campaignId);
while (attemptsToday < campaign.limite_diario_atual) {
const row = db.prepare(
`SELECT * FROM outreach_campaign_queue
WHERE campaign_id = ? AND status = 'approved'
ORDER BY id ASC LIMIT 1`
).get(campaignId);
if (!row) break;
if (isOptedOut(db, row.numero)) {
db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?`
).run(row.id);
summary.pulados += 1;
continue;
}
const jaEnviado = db.prepare(
`SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado'
AND (lead_id = ? OR numero = ?) LIMIT 1`
).get(campaign.produto, row.lead_id, row.numero);
if (jaEnviado) {
db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?`
).run(row.id);
summary.pulados += 1;
continue;
}
const externalKey = `campanha-${campaignId}-${row.id}`;
const insert = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')`
);
let sendId;
let queueStatus;
try {
const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey });
const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null;
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid);
queueStatus = 'sent';
summary.enviados += 1;
} catch (error) {
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid);
queueStatus = 'error';
summary.erros += 1;
log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`);
}
db.prepare(
`UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?`
).run(queueStatus, sendId, row.id);
attemptsToday += 1;
// Jitter entre tentativas reais de envio (nao entre pulos/cancelamentos,
// que nao batem no Z-PRO) — mesmo padrao de run.mjs, pra nao parecer
// disparo automatizado agressivo pro mesmo numero de WhatsApp.
await sleep(sleepMs != null ? sleepMs : randomSleepMs());
const health = applyCampaignHealth(db, campaignId, now);
if (health.pause) {
log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`);
break;
}
}
}
return summary;
}
+214
View File
@@ -0,0 +1,214 @@
import { getSetting, listMessageTemplates } from '../db.mjs';
import { renderTemplate } from './templates.mjs';
import { createZproClient } from './zpro-client.mjs';
// Intervalo aleatorio entre envios (mesmo padrao dos endpoints de bulk do
// proprio Z-PRO) para nao parecer disparo automatizado agressivo.
const INTERVALO_MIN_MS = 3000;
const INTERVALO_MAX_MS = 8000;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
const hhmm = now.toTimeString().slice(0, 5);
return hhmm >= horaInicio && hhmm <= horaFim;
}
function lastEnviado(db, leadId, produto) {
return db.prepare(
"SELECT * FROM outreach_sends WHERE lead_id = ? AND produto = ? AND status = 'enviado' ORDER BY sent_at DESC LIMIT 1"
).get(leadId, produto);
}
// Decide qual produto tentar pra um lead, olhando o historico de envios:
// - cascata desligada (padrao): so tenta o produto principal, uma vez.
// - cascata ligada: percorre os produtos em ordem de estrelas; usa o primeiro
// nunca enviado, ou pula pro proximo se o ultimo envio ja passou do prazo
// de espera. Se o produto atual ainda esta "no prazo", o lead e pulado
// nessa rodada (nao aumenta o consumo do limite diario).
function pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos }) {
const produtos = JSON.parse(lead.produtos_json || '[]')
.slice()
.sort((a, b) => (b.estrelas || 0) - (a.estrelas || 0))
.map((p) => p.nome)
.filter(Boolean);
if (!produtos.length) return null;
if (tentarProximoProduto !== 'true') {
const produto = produtos[0];
return lastEnviado(db, lead.id, produto) ? null : produto;
}
for (const produto of produtos) {
const ultimo = lastEnviado(db, lead.id, produto);
if (!ultimo) return produto;
const diasPassados = (Date.now() - new Date(`${ultimo.sent_at}Z`).getTime()) / 86400000;
if (diasPassados < Number(diasEntreProdutos)) return null;
}
return null;
}
function priorityTagName(prioridade) {
return `GTI P${prioridade || 4}`;
}
function offerTagName(produto) {
return `GTI Oferta: ${produto}`;
}
function templateVariablesForLead(template, lead, produto) {
let configured = [];
try {
configured = JSON.parse(template.template_waba_variaveis_json || '[]');
} catch {
configured = [];
}
const variables = Array.isArray(configured) && configured.length
? configured
: ['{empresa}', '{produto}'];
const data = { ...lead, produto };
return variables
.map((value) => renderTemplate(String(value || ''), data).trim())
.filter(Boolean);
}
// Cache em memoria (por lote) de nome de tag -> id: evita chamar listTags a
// cada lead e evita criar a mesma tag duas vezes.
async function ensureTagIds(zproClient, nomes, cache) {
if (!cache.loaded) {
const resposta = await zproClient.listTags({ isActive: true });
const lista = Array.isArray(resposta) ? resposta : resposta?.data || resposta?.tags || [];
for (const tag of lista) {
if (tag?.tag) cache.map.set(tag.tag, tag.id);
}
cache.loaded = true;
}
const ids = [];
for (const nome of nomes) {
let id = cache.map.get(nome);
if (!id) {
const criada = await zproClient.createTag({ tag: nome });
id = criada?.id || criada?.data?.id;
if (id) cache.map.set(nome, id);
}
if (id) ids.push(id);
}
return ids;
}
// Um lote de disparo automatico: verifica se esta ligado/dentro do horario/
// dentro do limite diario, escolhe candidatos elegiveis e manda uma mensagem
// por lead via bulkIndividual, registrando tudo em outreach_sends e aplicando
// tags de prioridade + oferta no contato do Z-PRO.
export async function runOutreachBatch({ db, zproClient = createZproClient(db), log = () => {} }) {
const resumo = { tentados: 0, enviados: 0, erros: 0, pulados: 0 };
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
log('outreach: desativado (outreach.ativo=false)');
return resumo;
}
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
if (!withinBusinessHours(horaInicio, horaFim)) {
log(`outreach: fora do horario comercial (${horaInicio}-${horaFim})`);
return resumo;
}
const limiteDiario = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
const jaEnviadosHoje = db.prepare(
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
).get().t;
let restante = limiteDiario - jaEnviadosHoje;
if (restante <= 0) {
log(`outreach: limite diario atingido (${jaEnviadosHoje}/${limiteDiario})`);
return resumo;
}
const tentarProximoProduto = getSetting(db, 'outreach.tentarProximoProduto', 'false');
const diasEntreProdutos = getSetting(db, 'outreach.diasEntreProdutos', '5');
const templates = new Map(listMessageTemplates(db).map((t) => [t.produto, t]));
const candidatos = db.prepare(
`SELECT id, empresa, cidade, segmento, segmento_label, whatsapp, prioridade, produtos_json
FROM leads
WHERE exported_at IS NULL AND telefone_valido = 1 AND whatsapp != '' AND produto_principal != ''
ORDER BY prioridade ASC, score DESC`
).all();
const insertSend = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal)
VALUES (?, ?, ?, ?, ?, ?, ?)`
);
const tagCache = { loaded: false, map: new Map() };
for (let i = 0; i < candidatos.length; i += 1) {
const lead = candidatos[i];
if (restante <= 0) break;
const produto = pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos });
if (!produto) {
resumo.pulados += 1;
continue;
}
const template = templates.get(produto);
if (!template || !template.ativo) {
resumo.pulados += 1;
continue;
}
const canal = template.canal === 'oficial' ? 'oficial' : 'nao_oficial';
if (canal === 'oficial' && !template.template_waba_nome) {
log(`outreach: produto "${produto}" no canal oficial sem template aprovado configurado — lead #${lead.id} pulado`);
resumo.pulados += 1;
continue;
}
resumo.tentados += 1;
const externalKey = `gti-lead-${lead.id}-${produto}-${Date.now()}`;
const mensagem = canal === 'oficial'
? `template:${template.template_waba_nome}/${template.template_waba_idioma}`
: renderTemplate(template.mensagem, lead);
try {
const resposta = canal === 'oficial'
? await zproClient.sendTemplateWaba({
number: lead.whatsapp,
templateName: template.template_waba_nome,
languageCode: template.template_waba_idioma,
variables: templateVariablesForLead(template, lead, produto)
})
: await zproClient.sendText({ number: lead.whatsapp, message: mensagem, externalKey });
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
insertSend.run(lead.id, produto, mensagem, 'enviado', null, ticketId, canal);
resumo.enviados += 1;
restante -= 1;
try {
const tagIds = await ensureTagIds(
zproClient,
[priorityTagName(lead.prioridade), offerTagName(produto)],
tagCache
);
if (tagIds.length) await zproClient.addTagContact({ number: lead.whatsapp, tagIds });
} catch (tagError) {
log(`outreach: falha ao aplicar tags do lead #${lead.id}: ${tagError.message}`);
}
} catch (error) {
insertSend.run(lead.id, produto, mensagem, 'erro', String(error.message || error), null, canal);
resumo.erros += 1;
log(`outreach: erro ao enviar lead #${lead.id} (${produto}): ${error.message}`);
}
if (restante > 0 && i < candidatos.length - 1) {
await sleep(INTERVALO_MIN_MS + Math.random() * (INTERVALO_MAX_MS - INTERVALO_MIN_MS));
}
}
log(`outreach: ${resumo.enviados} enviados, ${resumo.erros} erros, ${resumo.pulados} pulados (tentados ${resumo.tentados})`);
return resumo;
}
@@ -0,0 +1,29 @@
import { runOutreachBatch } from './run.mjs';
const CICLO_MS = 20 * 60 * 1000;
// Timer interno no processo do servidor: nao depende de agendador do sistema
// operacional (Task Scheduler/cron), entao funciona identico local (Windows)
// ou dentro de um container Docker apos a migracao — so precisa do servidor
// estar de pe, o que ja e verdade hoje. runOutreachBatch e idempotente e sai
// cedo quando desligado/fora de horario/limite batido, entao rodar a cada
// 20 min e seguro sem lock ou fila.
export function startOutreachScheduler({ db, log = () => {} }) {
const ciclo = () => {
runOutreachBatch({ db, log }).catch((error) => {
log(`outreach: erro inesperado no ciclo automatico: ${error.message}`);
});
};
// roda um primeiro ciclo pouco depois do boot (idempotente/seguro), sem
// esperar os 20 min inteiros para o primeiro lote do dia.
const primeiro = setTimeout(ciclo, 30 * 1000);
primeiro.unref();
const timer = setInterval(ciclo, CICLO_MS);
timer.unref();
return () => {
clearTimeout(primeiro);
clearInterval(timer);
};
}
@@ -0,0 +1,65 @@
export const COMMERCIAL_VARIABLES = [
{ key: 'name', label: 'Nome do WhatsApp/contato' },
{ key: 'empresa', label: 'Nome da empresa' },
{ key: 'businessName', label: 'Nome da empresa (GChat)' },
{ key: 'firstName', label: 'Primeiro nome' },
{ key: 'produto', label: 'Produto recomendado' },
{ key: 'protocol', label: 'Assunto/protocolo' },
{ key: 'cidade', label: 'Cidade' },
{ key: 'uf', label: 'UF' },
{ key: 'segmento', label: 'Nicho/segmento' },
{ key: 'cnpj', label: 'CNPJ' },
{ key: 'email', label: 'E-mail' },
{ key: 'phoneNumber', label: 'WhatsApp/telefone' },
{ key: 'whatsapp', label: 'WhatsApp/telefone' },
{ key: 'kanban', label: 'Produto/interesse' },
{ key: 'user', label: 'Usuário/remetente' },
{ key: 'userEmail', label: 'E-mail do remetente' },
{ key: 'greeting', label: 'Saudação' }
];
export function commercialVariableValues(lead = {}) {
const empresa = lead.empresa || lead.businessName || lead.name || 'sua empresa';
const produto = lead.produto || lead.produto_principal || lead.interesse || 'Atendimento comercial';
const cidade = lead.cidade || 'sua regiao';
const segmento = lead.segmento_label || lead.segmento || '';
const phoneNumber = lead.whatsapp || lead.phoneNumber || '';
const firstName = String(lead.name || empresa).split(/\s+/).filter(Boolean)[0] || empresa;
return {
name: lead.name || empresa,
empresa,
businessName: empresa,
firstName,
lastName: lead.lastName || '',
produto,
protocol: lead.protocol || produto,
cidade,
uf: lead.uf || '',
segmento,
cnpj: lead.cnpj || '',
email: lead.email || '',
phoneNumber,
whatsapp: phoneNumber,
kanban: lead.kanban || produto,
user: lead.user || 'Grupo GTI',
userEmail: lead.userEmail || 'comercial@gtigrupo.com.br',
greeting: 'Ola'
};
}
// Substituicao simples de variaveis no texto do template. Cada mensagem ja e
// resolvida por completo antes de chamar o Z-PRO (bulkIndividual manda texto
// pronto), entao nao dependemos da substituicao posicional por CSV que o
// bulkSendMessageWithVariable do Z-PRO usa.
export function renderTemplate(mensagem, lead) {
const values = commercialVariableValues(lead);
let output = String(mensagem || '')
.replaceAll('{empresa}', values.empresa)
.replaceAll('{cidade}', values.cidade)
.replaceAll('{produto}', values.produto)
.replaceAll('{segmento}', values.segmento);
for (const [key, value] of Object.entries(values)) {
output = output.replaceAll(`{{${key}}}`, value);
}
return output;
}
@@ -0,0 +1,104 @@
import { getSetting, setSetting } from '../db.mjs';
export const DEFAULT_WABA_TEMPLATES = [
{ name: 'gti_inicio_atendimento_solicitado', label: 'Inicio de atendimento solicitado', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', 'Atendimento sobre {produto}'] },
{ name: 'gti_agendamento_suporte_remoto', label: 'Agendamento de suporte remoto', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] },
{ name: 'gti_alerta_instabilidade_cliente', label: 'Alerta de instabilidade', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
{ name: 'gti_aviso_pendente_usuario', label: 'Aviso pendente usuario', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
{ name: 'gti_chamado_em_andamento', label: 'Chamado em andamento', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
{ name: 'confirma_suporte', label: 'Confirma suporte', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] }
];
function defaultVariable(index) {
if (index === 0) return '{empresa}';
if (index === 1) return '{produto}';
return 'Informacao';
}
function normalizeTemplate(raw) {
const name = raw?.name || raw?.templateName || raw?.elementName;
if (!name || typeof name !== 'string') return null;
const language = raw?.language || raw?.languageCode || raw?.lang || raw?.locale || 'pt_BR';
const category = raw?.category || raw?.templateCategory || '';
const bodyComponent = Array.isArray(raw?.components)
? raw.components.find((component) => String(component?.type || '').toUpperCase() === 'BODY')
: null;
const text = raw?.text || raw?.body || raw?.content || raw?.templateText || bodyComponent?.text || '';
const matches = String(text).match(/{{\s*\d+\s*}}/g) || [];
const count = Math.max(...matches.map((match) => Number(match.replace(/\D/g, ''))), 0);
const variables = Array.from({ length: count }, (_, index) => defaultVariable(index));
return {
name,
label: raw?.label || raw?.title || name,
language,
category,
status: raw?.status || raw?.templateStatus || '',
route: String(category).toUpperCase() === 'MARKETING' ? 'templateMarketingBody' : 'templateBody',
variables,
text
};
}
function collectTemplates(value, out = []) {
if (Array.isArray(value)) {
for (const item of value) collectTemplates(item, out);
return out;
}
if (!value || typeof value !== 'object') return out;
const normalized = normalizeTemplate(value);
if (normalized) out.push(normalized);
for (const child of Object.values(value)) {
if (child && typeof child === 'object') collectTemplates(child, out);
}
return out;
}
function uniqueByName(templates) {
const map = new Map();
for (const template of templates) {
if (!template?.name) continue;
const count = Math.max(
Array.isArray(template.variables) ? template.variables.length : 0,
...((String(template.text || '').match(/{{\s*\d+\s*}}/g) || []).map((match) => Number(match.replace(/\D/g, '')))),
0
);
template.variables = Array.from({ length: count }, (_, index) => String(template.variables?.[index] || '').trim() || defaultVariable(index));
map.set(`${template.name}:${template.language || 'pt_BR'}`, template);
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export function listWabaTemplateLibrary(db) {
let synced = [];
try {
synced = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson', '[]'));
} catch {
synced = [];
}
return uniqueByName([...DEFAULT_WABA_TEMPLATES, ...synced]);
}
export async function syncWabaTemplateLibrary(db) {
const url = getSetting(db, 'gchat.templatesUrl', '').trim();
const token = getSetting(db, 'gchat.bearerToken', '').trim().replace(/^Bearer\s+/i, '');
if (!url) {
return { ok: false, erro: 'configure gchat.templatesUrl com a URL de listagem de templates do GChat' };
}
const headers = { accept: 'application/json' };
if (token) headers.authorization = `Bearer ${token}`;
const response = await fetch(url, { headers });
const text = await response.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
return { ok: false, erro: `GChat retornou resposta nao-JSON (${response.status})` };
}
if (!response.ok) {
return { ok: false, erro: `GChat templates falhou (${response.status}): ${JSON.stringify(data).slice(0, 300)}` };
}
const templates = uniqueByName(collectTemplates(data));
setSetting(db, 'gchat.wabaTemplatesJson', JSON.stringify(templates));
setSetting(db, 'gchat.templatesLastSync', new Date().toISOString());
return { ok: true, total: templates.length, templates };
}
@@ -0,0 +1,212 @@
import { envString } from '../env.mjs';
import { getSetting } from '../db.mjs';
// Cliente fino para a API externa do Z-PRO (docs/referencia/Z-PRO.postman_collection.json).
// Todas as rotas: POST/GET https://{BaseUrl}/v2/api/external/{ApiID}/<acao>, auth Bearer.
// Credenciais vem da tela de Configuracoes > Credenciais Z-PRO (tabela settings,
// editavel sem reiniciar o servidor) — se estiverem vazias la, cai pro .env
// (ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN/ZPRO_WHATSAPP_ID), util pra
// deploy via variavel de ambiente (Docker/Proxmox) sem depender da tela.
// Aceita tanto "seu-dominio.com.br" quanto "https://seu-dominio.com.br/" (o
// que o usuario colar) — sem isso, colar a URL com protocolo virava
// "https://https://..." na hora de montar a chamada e todo teste falhava.
function normalizeBaseUrl(value) {
return value.trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '');
}
function normalizeBearerToken(value) {
return String(value || '').trim().replace(/^Bearer\s+/i, '').trim();
}
function parseTicketId(data) {
if (data?.data?.ticketId) return String(data.data.ticketId);
if (data?.data?.id) return String(data.data.id);
const message = data?.data?.message;
if (typeof message === 'string') {
const jsonStart = message.indexOf('{');
if (jsonStart !== -1) {
try {
const parsed = JSON.parse(message.slice(jsonStart));
if (parsed?.id) return String(parsed.id);
if (parsed?.ticketId) return String(parsed.ticketId);
} catch {
// Algumas respostas do Z-PRO embutem JSON em texto livre; se falhar,
// seguimos sem ticketId em vez de rejeitar uma chamada bem-sucedida.
}
}
}
return null;
}
function normalizeTemplateRoute(route) {
const allowed = new Set(['template', 'templateBody', 'templateMarketingBody']);
return allowed.has(route) ? route : 'template';
}
function config(db) {
const fromDbOrEnv = (settingKey, envKey) => {
const fromDb = db ? getSetting(db, settingKey, '') : '';
return fromDb || envString(envKey, '');
};
return {
baseUrl: normalizeBaseUrl(fromDbOrEnv('zpro.baseUrl', 'ZPRO_BASE_URL')),
apiId: fromDbOrEnv('zpro.apiId', 'ZPRO_API_ID'),
bearerToken: normalizeBearerToken(fromDbOrEnv('zpro.bearerToken', 'ZPRO_BEARER_TOKEN')),
whatsappId: fromDbOrEnv('zpro.whatsappId', 'ZPRO_WHATSAPP_ID')
};
}
export function zproDiagnostics(db) {
const { baseUrl, apiId, bearerToken, whatsappId } = config(db);
return {
baseUrl: baseUrl || '',
apiId: apiId ? `${apiId.slice(0, 8)}...` : '',
token: bearerToken ? `${bearerToken.slice(0, 4)}...${bearerToken.slice(-4)} (${bearerToken.length} chars)` : '',
whatsappId: whatsappId || '',
configured: Boolean(baseUrl && apiId && bearerToken),
hasWhatsappId: Boolean(whatsappId)
};
}
export function zproConfigured(db) {
const { baseUrl, apiId, bearerToken } = config(db);
return Boolean(baseUrl && apiId && bearerToken);
}
async function call(db, action, { method = 'POST', body, query } = {}) {
const { baseUrl, apiId, bearerToken } = config(db);
if (!baseUrl || !apiId || !bearerToken) {
throw new Error('Z-PRO nao configurado: preencha as credenciais em Configuracoes > Credenciais Z-PRO (ou ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN no .env)');
}
const path = action ? `${apiId}/${action}` : apiId;
const url = new URL(`https://${baseUrl}/v2/api/external/${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
}
const response = await fetch(url, {
method,
headers: {
authorization: `Bearer ${bearerToken}`,
'content-type': 'application/json'
},
body: method === 'GET' ? undefined : JSON.stringify(body ?? {})
});
const text = await response.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!response.ok) {
const apiError = data?.error || data?.message || data?.erro || '';
if (response.status === 401 || response.status === 403 || apiError === 'ERR_SESSION_NOT_AUTH_TOKEN') {
throw new Error([
`Z-PRO recusou a autenticacao (${response.status}${apiError ? ` / ${apiError}` : ''}).`,
'Confira se o Token e o API ID sao os de Admin > API do Z-PRO, nao o token de sessao/login.',
'Depois clique em "Testar conexao" antes de enviar mensagem.'
].join(' '));
}
if (apiError === 'ERR_SEND_TEMPLATE') {
throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.');
}
throw new Error(`Z-PRO ${action} falhou (${response.status}): ${JSON.stringify(data).slice(0, 500)}`);
}
if (data?.success === false || data?.error) {
const apiError = data?.error || data?.message || data?.erro || '';
if (apiError === 'ERR_SEND_TEMPLATE') {
throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.');
}
throw new Error(`Z-PRO ${action} falhou: ${JSON.stringify(data).slice(0, 500)}`);
}
return data;
}
// Cliente com as credenciais (settings + fallback .env) resolvidas uma vez e
// presas via closure — assim cada chamada do lote de outreach ja usa as
// credenciais atuais da tela de Configuracoes, sem precisar reiniciar o servidor.
export function createZproClient(db) {
return {
// Manda uma unica mensagem de texto pronta para um numero (docs: bulkIndividual).
// externalKey permite correlacionar a resposta/webhook do Z-PRO com o envio.
bulkIndividual({ number, message, externalKey }) {
const { whatsappId } = config(db);
return call(db, 'bulkIndividual', { body: { whatsappId, number, message, externalKey } });
},
sendText({ number, message, externalKey, isClosed = false }) {
return call(db, '', { body: { body: message, number, externalKey, isClosed, validateNumber: true } });
},
async createTicket({ number, message, externalKey, userId, channelId, queueId, status = 'pending' }) {
const body = {
body: message,
number,
externalKey,
userId: Number(userId),
status,
validateNumber: true
};
if (channelId) body.channelId = Number(channelId);
if (queueId) body.queueId = Number(queueId);
let response;
try {
response = await call(db, 'createTicket', { body });
} catch (error) {
if (!channelId || !String(error.message || '').includes('createTicket falhou')) throw error;
const fallbackBody = { ...body };
delete fallbackBody.channelId;
response = await call(db, 'createTicket', { body: fallbackBody });
}
return { ...response, ticketId: parseTicketId(response) };
},
listTags({ isActive = true } = {}) {
return call(db, 'listTags', { method: 'GET', query: { isActive: String(isActive) } });
},
createTag({ tag, color = '#2A78D6', isActive = true }) {
return call(db, 'createTag', { body: { tag, color, isActive } });
},
addTagContact({ number, tagIds }) {
return call(db, 'addTagContact', { body: { number, tagIds, validateNumber: true } });
},
// Manda um template ja aprovado no Meta Business Manager (canal oficial/WABA).
// Diferente de bulkIndividual: nao aceita texto livre, so nome+idioma de um
// template que ja existe e ja foi aprovado (a aprovacao e feita fora do app).
sendTemplateWaba({ number, templateName, languageCode = 'pt_BR', route = 'template', variables = [] }) {
const template = { name: templateName, language: { code: languageCode } };
const bodyParameters = variables
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
.map((value) => ({ type: 'text', text: String(value) }));
if (bodyParameters.length) {
template.components = [{ type: 'body', parameters: bodyParameters }];
}
return call(db, normalizeTemplateRoute(route), {
body: {
number,
isClosed: false,
validateNumber: true,
templateData: {
messaging_product: 'whatsapp',
to: number,
type: 'template',
template
}
}
});
}
};
}
+152
View File
@@ -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:<empresa>:<cidade>).
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(' ');
}
+130
View File
@@ -0,0 +1,130 @@
import http from 'node:http';
import { paths } from '../env.mjs';
import { createRouter } from './router.mjs';
import { serveStatic } from './static.mjs';
import { openSse } from './sse.mjs';
import { registerMetaRoutes } from './routes/meta.mjs';
import { registerCaptureRoutes } from './routes/capture.mjs';
import { registerExtractionRoutes } from './routes/extractions.mjs';
import { registerLeadRoutes } from './routes/leads.mjs';
import { registerExportRoutes } from './routes/exports.mjs';
import { registerAuthRoutes } from './routes/auth.mjs';
import { registerOutreachRoutes } from './routes/outreach.mjs';
import { registerCampaignRoutes } from './routes/campaigns.mjs';
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
const BODY_LIMIT = 1024 * 1024; // 1MB
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new Error('corpo da requisicao muito grande'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!chunks.length) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('JSON invalido no corpo da requisicao'));
}
});
req.on('error', reject);
});
}
export function createAppServer({ db, jobs, log = () => {} }) {
const router = createRouter();
const deps = { db, jobs };
registerAuthRoutes(router, deps);
registerMetaRoutes(router, deps);
registerCaptureRoutes(router, deps);
registerExtractionRoutes(router, deps);
registerLeadRoutes(router, deps);
registerExportRoutes(router, deps);
registerOutreachRoutes(router, deps);
registerCampaignRoutes(router, deps);
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
const sse = openSse(res);
const unsubscribe = jobs.subscribe(params.jobId, (event) => {
const name = event.t === 'done' ? 'done' : event.t === 'error' ? 'error' : event.t;
sse.send(name, event);
});
if (unsubscribe === null) {
sse.send('error', { t: 'error', message: 'job nao encontrado (o servidor pode ter sido reiniciado)' });
sse.close();
return;
}
req.on('close', () => {
unsubscribe();
sse.close();
});
});
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const pathname = url.pathname;
const json = (status, data) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
const session = getSession(getCookie(req, SESSION_COOKIE));
const user = session?.user || null;
try {
if (pathname.startsWith('/api/') && !PUBLIC_API_PATHS.has(pathname) && !user) {
json(401, { erro: 'não autenticado' });
return;
}
const matched = router.match(req.method, pathname);
if (matched) {
let body = null;
if (req.method === 'POST' || req.method === 'PUT') {
body = await readJsonBody(req);
}
await matched.handler(req, res, { params: matched.params, query: url.searchParams, body, json, user });
return;
}
if (req.method === 'GET' && !pathname.startsWith('/api/')) {
if ((pathname === '/' || pathname === '/index.html') && !user) {
res.writeHead(302, { location: '/login.html' });
res.end();
return;
}
if (serveStatic(paths.public, pathname, res)) return;
}
json(404, { erro: `rota nao encontrada: ${req.method} ${pathname}` });
} catch (error) {
log(`erro em ${req.method} ${pathname}: ${error.stack || error.message}`);
if (!res.headersSent) {
json(500, { erro: String(error.message || error) });
} else {
res.end();
}
}
});
return server;
}
@@ -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 };
}
@@ -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 });
});
}
@@ -0,0 +1,162 @@
import {
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
addOptOut, removeOptOut
} from '../../outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs';
import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs';
import { onlyDigits } from '../../scoring.mjs';
export function registerCampaignRoutes(router, { db }) {
router.add('POST', '/api/campaigns', async (req, res, { json, body }) => {
const nome = String(body?.nome || '').trim();
const produto = String(body?.produto || '').trim();
const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : [];
const variantesValidas = Array.isArray(body?.variantes)
? body.variantes.filter((v) => v && String(v.mensagem || '').trim())
: [];
if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; }
if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; }
if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; }
const campanha = createCampaign(db, {
nome, produto, segmentos,
variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })),
limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10,
limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80
});
json(200, campanha);
});
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
json(200, { campanhas: listCampaigns(db) });
});
// Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no
// path — o router retorna a primeira rota que casar).
router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => {
const produto = String(query?.get('produto') || '').trim();
if (!produto) { json(400, { erro: 'informe o produto' }); return; }
const segmentos = db.prepare(
`SELECT segmento AS value, COUNT(*) AS total FROM leads
WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC`
).all(produto);
json(200, { segmentos });
});
router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => {
const produtos = db.prepare(
`SELECT produto_principal AS value, COUNT(*) AS total FROM leads
WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC`
).all();
json(200, { produtos });
});
router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => {
const campanha = getCampaign(db, Number(params.id));
if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; }
json(200, campanha);
});
router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => {
try {
const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 });
json(200, resultado);
} catch (error) {
json(400, { erro: String(error.message || error) });
}
});
router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => {
json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) });
});
router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => {
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) });
});
router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => {
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') });
});
router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => {
json(200, setCampaignStatus(db, Number(params.id), 'rodando'));
});
router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => {
json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual'));
});
router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => {
json(200, resumeCampaign(db, Number(params.id)));
});
// So le o status atual — nao aplica pausa. A aplicacao de pausa (mutacao)
// acontece exclusivamente no worker (applyCampaignHealth), nunca como efeito
// colateral de abrir o painel de detalhe da campanha.
router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => {
json(200, evaluateCampaignHealth(db, Number(params.id)));
});
router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => {
json(200, { variantes: variantStats(db, Number(params.id)) });
});
router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => {
const stage = String(body?.stage || '').trim();
if (!stage) { json(400, { erro: 'informe o estagio' }); return; }
const leadId = Number(params.leadId);
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at`
).run(leadId, stage);
db.prepare(
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)`
).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null);
// Marcar 'opt_out' via estagio precisa REALMENTE tirar o lead de circulacao
// (senao ele continua elegivel na candidateQuery/worker) — nao basta
// registrar o estagio, tem que gravar em outreach_opt_out tambem.
if (stage === 'opt_out') {
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId);
const numero = onlyDigits(lead?.whatsapp || '');
if (numero) addOptOut(db, numero, 'marcado via estagio');
}
json(200, { ok: true });
});
router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => {
const leadId = Number(params.leadId);
json(200, {
atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null,
historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId)
});
});
router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => {
const kind = String(body?.kind || '').trim();
if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; }
db.prepare(
`INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)`
).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null);
json(200, { ok: true });
});
router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => {
json(200, {
agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId))
});
});
router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => {
const numero = onlyDigits(body?.numero || '');
if (!numero) { json(400, { erro: 'informe o numero' }); return; }
addOptOut(db, numero, body?.motivo || null);
json(200, { ok: true });
});
router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => {
removeOptOut(db, onlyDigits(params.numero));
json(200, { ok: true });
});
}
@@ -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})` });
});
}
@@ -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);
});
}
@@ -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})` });
});
}
@@ -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)) });
});
}
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { describeSources } from '../../sources/index.mjs';
import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs';
import { cleanText } from '../../lead-engine.mjs';
// Senha de confirmacao para a limpeza total da base (acao destrutiva e
// irreversivel) — pedida no modal de Configuracoes antes de zerar leads,
// exportacoes, capturas e extracoes. Vem do .env (nunca versionado).
// Funcao (nao const) de proposito: os imports deste arquivo rodam antes do
// "await loadDotEnv()" em scripts/servidor.mjs (imports sao hoisted acima de
// qualquer top-level await), entao ler process.env.GTI_RESET_PASSWORD direto
// num const no topo do modulo sempre capturava undefined — nenhuma senha
// nunca batia, mesmo a correta. Lendo dentro da rota (call-time) o .env ja
// foi carregado quando a chamada realmente acontece.
function resetBasePassword() {
return process.env.GTI_RESET_PASSWORD;
}
function extractedCsvForUf(uf) {
const candidates = [
path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`),
// nome antigo da primeira extracao (Bahia)
...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : [])
];
return candidates.find((file) => fs.existsSync(file)) || null;
}
export function registerMetaRoutes(router, { db, jobs }) {
router.add('GET', '/api/health', async (req, res, { json }) => {
json(200, { ok: true, app: 'gti-maquina-comercial', pid: process.pid });
});
router.add('GET', '/api/meta/ufs', async (req, res, { json }) => {
const doneByUf = new Map(
db.prepare(
`SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count
FROM extractions WHERE status = 'done' GROUP BY uf`
).all().map((row) => [row.uf, row])
);
const municipiosByUf = new Map(
db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all()
.map((row) => [row.uf, row.total])
);
const runningUfs = new Set(
db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf)
);
const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => {
const csv = extractedCsvForUf(uf);
const done = doneByUf.get(uf);
return {
uf,
nome,
municipios: municipiosByUf.get(uf) || 0,
extraida: Boolean(csv),
extraindo: runningUfs.has(uf),
extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null
};
});
json(200, { ufs });
});
router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => {
const uf = String(query.get('uf') || '').toUpperCase();
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uf valida, ex.: ?uf=BA' });
return;
}
const q = String(query.get('q') || '').toLowerCase();
let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf);
if (q) rows = rows.filter((row) => row.nome_clean.includes(q));
json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) });
});
router.add('GET', '/api/meta/segments', async (req, res, { json }) => {
const rows = db.prepare(
'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label'
).all();
json(200, {
segments: rows.map((row) => ({
id: row.id,
name: row.name,
label: row.label,
interesse: row.interest,
cnaes: JSON.parse(row.cnae_codes_json).length
}))
});
});
// Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada
// para adicionar nichos avulsos alem dos 57 pre-configurados em segments.
router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => {
const q = cleanText(String(query.get('q') || ''));
if (q.length < 2) {
json(200, { cnaes: [] });
return;
}
const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all();
const achados = rows
.filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q))
.slice(0, 30);
json(200, { cnaes: achados });
});
router.add('GET', '/api/meta/sources', async (req, res, { json }) => {
json(200, { sources: describeSources() });
});
router.add('GET', '/api/settings', async (req, res, { json }) => {
const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all();
json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) });
});
router.add('PUT', '/api/settings', async (req, res, { json, body }) => {
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
return;
}
for (const [key, value] of Object.entries(body)) {
setSetting(db, key, value);
}
json(200, { ok: true, atualizadas: Object.keys(body).length });
});
router.add('GET', '/api/jobs/active', async (req, res, { json }) => {
json(200, { jobs: jobs.activeJobs() });
});
// Zera toda a base de leads (irreversivel): exige a senha de confirmacao.
// 403 (nao 401): o usuario ja esta autenticado, so a senha de confirmacao
// da acao esta errada. 401 aqui disparava o redirect global de "sessao
// expirada" do api.js antes do erro aparecer na tela — o usuario via a
// tela de login e achava que so tinha caido a sessao, sem saber que a
// limpeza nunca rodou.
router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => {
if (!body || body.senha !== resetBasePassword()) {
json(403, { erro: 'senha incorreta' });
return;
}
resetLeadsBase(db);
for (const entry of fs.readdirSync(paths.exports, { withFileTypes: true })) {
fs.rmSync(path.join(paths.exports, entry.name), { recursive: true, force: true });
}
json(200, { ok: true });
});
}
@@ -0,0 +1,205 @@
import { getSetting, listMessageTemplates, updateMessageTemplate } from '../../db.mjs';
import { createZproClient, zproConfigured, zproDiagnostics } from '../../outreach/zpro-client.mjs';
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../../outreach/waba-template-library.mjs';
import { COMMERCIAL_VARIABLES, renderTemplate } from '../../outreach/templates.mjs';
import { validatePhone, onlyDigits } from '../../scoring.mjs';
function dailyLimitStatus(db) {
const limite = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
const enviadosHoje = db.prepare(
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
).get().t;
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
}
export function registerOutreachRoutes(router, { db }) {
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
const ativo = getSetting(db, 'outreach.ativo', 'false') === 'true';
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
const porProduto = db.prepare(
`SELECT produto, COUNT(*) AS total
FROM outreach_sends
WHERE status = 'enviado' AND date(sent_at) = date('now')
GROUP BY produto ORDER BY total DESC`
).all();
// LEFT JOIN (nao JOIN): envio manual nao tem lead_id, senao sumiria da lista.
const ultimos = db.prepare(
`SELECT outreach_sends.id, outreach_sends.produto, outreach_sends.status,
outreach_sends.sent_at, outreach_sends.canal, outreach_sends.origem,
outreach_sends.numero, outreach_sends.erro, leads.empresa, leads.cidade
FROM outreach_sends LEFT JOIN leads ON leads.id = outreach_sends.lead_id
ORDER BY outreach_sends.id DESC LIMIT 20`
).all();
json(200, {
ativo,
configurado: zproConfigured(db),
mensagensPorDia: limite,
enviadosHoje,
restante,
porProduto,
ultimos
});
});
// Testa as credenciais Z-PRO atuais (settings ou .env) com uma chamada real
// e barata (listTags), sem mandar nenhuma mensagem — pra validar a tela de
// Credenciais Z-PRO antes de ativar a automacao de verdade.
router.add('POST', '/api/outreach/testar-conexao', async (req, res, { json }) => {
if (!zproConfigured(db)) {
json(400, { ok: false, erro: 'preencha Base URL, API ID e Token antes de testar', diagnostico: zproDiagnostics(db) });
return;
}
try {
await createZproClient(db).listTags({ isActive: true });
json(200, { ok: true, diagnostico: zproDiagnostics(db) });
} catch (error) {
json(200, { ok: false, erro: String(error.message || error), diagnostico: zproDiagnostics(db) });
}
});
// Envio avulso pra um numero especifico, fora do lote automatico e sem
// depender de um lead cadastrado — util pra mandar uma mensagem pontual
// ou testar um template antes de ligar a automacao pra valer. Fica
// registrado em outreach_sends com origem='manual' (lead_id/produto
// ficam vazios), aparece nos "ultimos" igual aos envios automaticos.
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
const numeroDigitado = String(body?.numero || '').trim();
if (!numeroDigitado) {
json(400, { erro: 'informe o numero (com DDI+DDD, ex.: 5511999999999)' });
return;
}
// O Z-PRO aceita (200 OK) numero mal formatado e so falha depois, de forma
// assincrona, sem avisar o app — por isso valida aqui ANTES de mandar,
// em vez de confiar na resposta da API pra saber se o numero e valido.
const { valid, motivo } = validatePhone(numeroDigitado);
if (!valid) {
json(400, { erro: `numero invalido (${motivo}): use DDI+DDD+numero, so digitos, ex. 5511999999999` });
return;
}
const numero = onlyDigits(numeroDigitado);
const canal = body?.canal === 'oficial' ? 'oficial' : 'nao_oficial';
const mensagem = String(body?.mensagem || '').trim();
const templateWabaNome = String(body?.templateWabaNome || '').trim();
const templateWabaIdioma = String(body?.templateWabaIdioma || 'pt_BR').trim() || 'pt_BR';
const templateRoute = String(body?.templateRota || 'template').trim() || 'template';
const rawTemplateVariables = Array.isArray(body?.templateVariaveis)
? body.templateVariaveis.map((value) => String(value || '').trim()).filter(Boolean)
: [String(body?.templateVariavel1 || '').trim()].filter(Boolean);
const leadContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
const templateVariables = rawTemplateVariables.map((value) => renderTemplate(value, {
empresa: 'Gustavo',
produto: 'Atendimento comercial',
cidade: 'Salvador',
segmento_label: 'Suporte',
email: 'cliente@exemplo.com.br',
...leadContext,
whatsapp: leadContext.whatsapp || onlyDigits(numeroDigitado)
}));
if (canal === 'nao_oficial' && !mensagem) {
json(400, { erro: 'escreva a mensagem pro canal nao-oficial' });
return;
}
if (canal === 'oficial' && !templateWabaNome) {
json(400, { erro: 'informe o nome do template aprovado pro canal oficial' });
return;
}
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
if (restante <= 0) {
json(429, {
ok: false,
erro: `limite diario atingido (${enviadosHoje}/${limite}). Aumente o limite por dia ou aguarde virar o dia.`,
limite,
enviadosHoje,
restante
});
return;
}
const insert = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
VALUES (NULL, '', ?, ?, ?, ?, ?, ?, 'manual')`
);
const mensagemEnviada = canal === 'oficial' ? `template:${templateWabaNome}/${templateWabaIdioma}` : mensagem;
try {
const zproClient = createZproClient(db);
const externalKey = `gti-manual-${Date.now()}`;
const resposta = canal === 'oficial'
? await zproClient.sendTemplateWaba({
number: numero,
templateName: templateWabaNome,
languageCode: templateWabaIdioma,
route: templateRoute,
variables: templateVariables
})
: await zproClient.sendText({ number: numero, message: mensagem, externalKey });
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
insert.run(numero, mensagemEnviada, 'enviado', null, ticketId, canal);
json(200, { ok: true, ticketId });
} catch (error) {
insert.run(numero, mensagemEnviada, 'erro', String(error.message || error), null, canal);
json(200, { ok: false, erro: String(error.message || error) });
}
});
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
json(200, { templates: listMessageTemplates(db) });
});
router.add('GET', '/api/outreach/waba-templates', async (req, res, { json }) => {
const syncUrl = getSetting(db, 'gchat.templatesUrl', '').trim();
const lastSync = getSetting(db, 'gchat.templatesLastSync', '');
const lastSyncAgeMs = lastSync ? Date.now() - new Date(lastSync).getTime() : Infinity;
if (syncUrl && lastSyncAgeMs > 10 * 60 * 1000) {
try {
await syncWabaTemplateLibrary(db);
} catch {
// A biblioteca local continua funcionando mesmo se o GChat estiver fora.
}
}
json(200, {
templates: listWabaTemplateLibrary(db),
lastSync: getSetting(db, 'gchat.templatesLastSync', '')
});
});
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
json(200, { variables: COMMERCIAL_VARIABLES });
});
router.add('POST', '/api/outreach/waba-templates/sync', async (req, res, { json }) => {
try {
json(200, await syncWabaTemplateLibrary(db));
} catch (error) {
json(200, { ok: false, erro: String(error.message || error) });
}
});
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body }) => {
const produto = params.produto;
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"mensagem": "...", "ativo": true}' });
return;
}
const atualizado = updateMessageTemplate(db, produto, {
mensagem: body.mensagem,
ativo: body.ativo,
canal: body.canal,
templateWabaNome: body.templateWabaNome,
templateWabaIdioma: body.templateWabaIdioma,
templateWabaVariaveis: body.templateWabaVariaveis
});
if (!atualizado) {
json(404, { erro: `template do produto "${produto}" nao encontrado` });
return;
}
json(200, atualizado);
});
}
@@ -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`;
}
+33
View File
@@ -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
}
}
};
}
@@ -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;
}
@@ -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`);
}
@@ -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;
}
@@ -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 };
}
}
@@ -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` : ''
};
});
}
@@ -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 };
}
@@ -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 };
}
}
@@ -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);
}
}
@@ -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 };
}
}
@@ -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;
}
@@ -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)
};
}
@@ -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;
}
+117
View File
@@ -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()));
});
}
};
}
@@ -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`);
}
}
+38
View File
@@ -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();
});
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs';
const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }];
test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => {
const primeira = variantForLead(variants, 42);
const segunda = variantForLead(variants, 42);
assert.equal(primeira.code, segunda.code);
});
test('variantForLead distribui entre variantes diferentes conforme o id', () => {
assert.equal(variantForLead(variants, 0).code, 'A');
assert.equal(variantForLead(variants, 1).code, 'B');
assert.equal(variantForLead(variants, 2).code, 'C');
assert.equal(variantForLead(variants, 3).code, 'A');
});
test('variantForLead lanca erro sem variantes ativas', () => {
assert.throws(() => variantForLead([], 1), /sem variantes/);
});
test('renderCampaignMessage substitui variaveis do lead', () => {
const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' });
assert.equal(texto, 'Ola Oficina do Ze, sobre ERP');
});
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
function inserirLead(db, dedup) {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`,
ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '',
cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1,
cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '',
score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP',
produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null
});
return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
}
function campanhaRodando(db) {
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
});
setCampaignStatus(db, campanha.id, 'rodando');
return campanha;
}
function simularEnvio(db, campanhaId, variantId, leadId, numero, status) {
const sendId = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)`
).run(leadId, numero, status).lastInsertRowid;
db.prepare(
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
VALUES (?, ?, ?, ?, 'oi', ?, ?)`
).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId);
}
test('evaluateCampaignHealth nao pausa campanha saudavel', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 10; i += 1) {
const lead = inserirLead(db, `l${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 8; i += 1) {
const lead = inserirLead(db, `ok${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
for (let i = 0; i < 2; i += 1) {
const lead = inserirLead(db, `err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_failure_rate');
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(semMutacao.status, 'rodando');
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
const aplicado = applyCampaignHealth(db, campanha.id);
assert.equal(aplicado.pause, true);
assert.equal(aplicado.reason, 'high_failure_rate');
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 8; i += 1) {
const lead = inserirLead(db, `puro-ok${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
for (let i = 0; i < 2; i += 1) {
const lead = inserirLead(db, `puro-err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
for (let i = 0; i < 5; i += 1) {
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
}
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
db.close();
});
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
const lead = inserirLead(db, 'so1');
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 20; i += 1) {
const lead = inserirLead(db, `neg${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
if (i < 3) {
db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id);
}
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_negative_rate');
db.close();
});
test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 50; i += 1) {
const lead = inserirLead(db, `rep${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'low_reply_rate');
db.close();
});
test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
setCampaignStatus(db, campanha.id, 'pausado', 'manual');
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 5; i += 1) {
const lead = inserirLead(db, `v${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
if (i < 1) {
db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id);
}
}
const stats = variantStats(db, campanha.id);
assert.equal(stats.length, 1);
assert.equal(stats[0].enviados, 5);
assert.equal(stats[0].respostas, 1);
assert.equal(stats[0].taxaResposta, 0.2);
assert.equal(stats[0].elegivelParaComparacao, false);
db.close();
});
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs';
function inserirLead(db, overrides = {}) {
const inserir = createLeadInserter(db);
const dedup = overrides.dedup_key || `lead-${Math.random()}`;
inserir({
dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001',
ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom',
motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1,
produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null,
...overrides
});
return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup);
}
function campanhaComVariantes(db, overrides = {}) {
return createCampaign(db, {
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'],
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
...overrides
});
}
test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => {
const db = openDb(':memory:');
inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' });
inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' });
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 1);
assert.equal(resultado.adicionadosNaFila, 1);
const fila = listQueue(db, campanha.id);
assert.equal(fila.length, 1);
assert.equal(fila[0].status, 'preview');
assert.equal(fila[0].rendered_message, 'Ola Oficina Teste');
db.close();
});
test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => {
const db = openDb(':memory:');
const lead = inserirLead(db);
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')`
).run(lead.id, lead.whatsapp);
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id, produto vazio, como o envio manual real grava)', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990009' });
// Reproduz exatamente como src/server/routes/outreach.mjs grava um envio
// manual: lead_id NULL, produto '' (nunca o produto da campanha) e
// origem='manual'. A exclusao precisa valer mesmo assim, por numero.
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (NULL, '', '5511999990009', 'oi', 'enviado', 'manual')`
).run();
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue exclui numero em opt-out', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run();
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => {
const db = openDb(':memory:');
inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' });
const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' });
generateCampaignQueue(db, campanha1.id);
const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' });
const resultado = generateCampaignQueue(db, campanha2.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue respeita o parametro quantidade', () => {
const db = openDb(':memory:');
for (let i = 0; i < 5; i += 1) {
inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` });
}
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 });
assert.equal(resultado.adicionadosNaFila, 3);
db.close();
});
test('generateCampaignQueue reporta candidatosEncontrados como o pool elegivel completo, mesmo com quantidade menor', () => {
const db = openDb(':memory:');
for (let i = 0; i < 5; i += 1) {
inserirLead(db, { dedup_key: `pool-${i}`, whatsapp: `551199998000${i}` });
}
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 2 });
assert.equal(resultado.candidatosEncontrados, 5);
assert.equal(resultado.adicionadosNaFila, 2);
assert.equal(listQueue(db, campanha.id).length, 2);
db.close();
});
test('approveQueueRows so aprova linhas em preview da campanha certa', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
const campanha = campanhaComVariantes(db);
generateCampaignQueue(db, campanha.id);
const [linha] = listQueue(db, campanha.id);
const total = approveQueueRows(db, campanha.id, [linha.id]);
assert.equal(total, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
});
test('cancelQueueRows cancela e registra motivo', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
const campanha = campanhaComVariantes(db);
generateCampaignQueue(db, campanha.id);
const [linha] = listQueue(db, campanha.id);
const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa');
assert.equal(total, 1);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
assert.equal(cancelada.cancel_reason, 'nao interessa');
});
test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => {
const db = openDb(':memory:');
const campanha = campanhaComVariantes(db);
assert.equal(approveQueueRows(db, campanha.id, []), 0);
assert.equal(cancelQueueRows(db, campanha.id, []), 0);
db.close();
});
@@ -0,0 +1,217 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
function setupRouter(db) {
const router = createRouter();
registerCampaignRoutes(router, { db });
return router;
}
function fakeJson() {
const calls = [];
return { json: (status, data) => calls.push({ status, data }), calls };
}
function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11',
email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '',
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]',
capture_run_id: null, captured_at: null, exported_at: null
});
}
test('POST /api/campaigns cria campanha com segmentos e variantes', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const { handler } = router.match('POST', '/api/campaigns');
const { json, calls } = fakeJson();
await handler(null, null, { json, body: {
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'],
variantes: [{ mensagem: 'Ola {empresa}' }]
} });
assert.equal(calls[0].status, 200);
assert.equal(calls[0].data.produto, 'ERP');
assert.equal(calls[0].data.variants.length, 1);
db.close();
});
test('POST /api/campaigns rejeita sem segmentos', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const { handler } = router.match('POST', '/api/campaigns');
const { json, calls } = fakeJson();
await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } });
assert.equal(calls[0].status, 400);
db.close();
});
test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
inserirLead(db, '2', 'ERP', 'oficinas');
inserirLead(db, '3', 'ERP', 'padarias');
inserirLead(db, '4', 'G-Chat', 'padarias');
const router = setupRouter(db);
const { handler } = router.match('GET', '/api/campaigns/segmentos');
const { json, calls } = fakeJson();
await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) });
assert.equal(calls[0].status, 200);
const segmentos = calls[0].data.segmentos;
assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2);
assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1);
db.close();
});
test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
const router = setupRouter(db);
const criar = router.match('POST', '/api/campaigns');
const respCriar = fakeJson();
await criar.handler(null, null, { json: respCriar.json, body: {
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
} });
const campaignId = respCriar.calls[0].data.id;
const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`);
const respFila = fakeJson();
await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} });
assert.equal(respFila.calls[0].data.adicionadosNaFila, 1);
const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`);
const respLista = fakeJson();
await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() });
const queueId = respLista.calls[0].data.fila[0].id;
const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`);
const respAprovar = fakeJson();
await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } });
assert.equal(respAprovar.calls[0].data.aprovados, 1);
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
const respSaude = fakeJson();
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
assert.equal(respSaude.calls[0].data.pause, false);
db.close();
});
test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => {
const db = openDb(':memory:');
inserirLead(db, '1');
const lead = db.prepare('SELECT id FROM leads LIMIT 1').get();
const router = setupRouter(db);
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
const respMarcar = fakeJson();
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } });
assert.equal(respMarcar.calls[0].status, 200);
const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio');
const respConsultar = fakeJson();
await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } });
assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado');
assert.equal(respConsultar.calls[0].data.historico.length, 1);
db.close();
});
test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const adicionar = router.match('POST', '/api/campaigns/opt-out');
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } });
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } });
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
db.close();
});
test('POST/DELETE /api/campaigns/opt-out normalizam o numero pra digitos, igual ao resto do app', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const adicionar = router.match('POST', '/api/campaigns/opt-out');
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '+55 (11) 99999-0001', motivo: 'pediu' } });
// Tem que ter gravado so digitos, senao isOptedOut/candidateQuery (que comparam
// contra leads.whatsapp, ja digit-only) nunca vao casar com este registro.
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('+55 (11) 99999-0001'), undefined);
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '+55 (11) 99999-0001' } });
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
db.close();
});
test('POST /api/campaigns/leads/:leadId/estagio com stage=opt_out tambem grava em outreach_opt_out (numero digit-only)', async () => {
const db = openDb(':memory:');
inserirLead(db, '1');
const lead = db.prepare('SELECT id, whatsapp FROM leads LIMIT 1').get();
const router = setupRouter(db);
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
const respMarcar = fakeJson();
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'opt_out' } });
assert.equal(respMarcar.calls[0].status, 200);
assert.ok(
db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(lead.whatsapp),
'marcar estagio opt_out deveria ter chamado addOptOut com o numero do lead'
);
db.close();
});
test('GET /:id/saude nao muta o status da campanha (leitura pura); o worker e quem aplica a pausa', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
const router = setupRouter(db);
const criar = router.match('POST', '/api/campaigns');
const respCriar = fakeJson();
await criar.handler(null, null, { json: respCriar.json, body: {
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
} });
const campaignId = respCriar.calls[0].data.id;
const iniciar = router.match('POST', `/api/campaigns/${campaignId}/iniciar`);
await iniciar.handler(null, null, { json: fakeJson().json, params: { id: String(campaignId) } });
// Simula 10 tentativas hoje, todas com erro (100% > 10%) -> evaluateCampaignHealth
// deveria retornar pause:true, mas a rota GET nao pode persistir essa pausa.
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campaignId);
for (let i = 0; i < 10; i += 1) {
const dedup = `saude-${i}`;
inserirLead(db, dedup, 'ERP', 'oficinas');
const l = db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
const sendId = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'erro')`
).run(l.id, l.whatsapp).lastInsertRowid;
db.prepare(
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
VALUES (?, ?, ?, ?, 'oi', 'error', ?)`
).run(campaignId, l.id, variant.id, l.whatsapp, sendId);
}
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
const respSaude = fakeJson();
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
assert.equal(respSaude.calls[0].data.pause, true, 'a avaliacao deveria sinalizar pause:true dado 100% de erro');
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
assert.equal(aindaRodando.status, 'rodando', 'abrir o painel de saude (GET) nao deveria ter pausado a campanha');
db.close();
});
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => {
const db = openDb(':memory:');
const stop = startCampaignScheduler({ db, log: () => {} });
assert.equal(typeof stop, 'function');
assert.doesNotThrow(() => stop());
db.close();
});
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import {
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut
} from '../src/outreach/campaign-store.mjs';
function campanhaBase(overrides = {}) {
return {
nome: 'ERP oficinas',
produto: 'ERP',
segmentos: ['oficinas_mecanicas', 'autopecas'],
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }],
...overrides
};
}
test('createCampaign grava campanha, segmentos e variantes', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
assert.equal(campanha.status, 'rascunho');
assert.equal(campanha.produto, 'ERP');
assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']);
assert.equal(campanha.variants.length, 2);
assert.equal(campanha.variants[0].code, 'A');
db.close();
});
test('listCampaigns retorna mais recente primeiro', () => {
const db = openDb(':memory:');
createCampaign(db, campanhaBase({ nome: 'Primeira' }));
createCampaign(db, campanhaBase({ nome: 'Segunda' }));
const campanhas = listCampaigns(db);
assert.equal(campanhas.length, 2);
assert.equal(campanhas[0].nome, 'Segunda');
db.close();
});
test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
setCampaignStatus(db, campanha.id, 'rodando');
const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
assert.equal(pausada.status, 'pausado');
assert.equal(pausada.pause_reason, 'high_failure_rate');
const retomada = resumeCampaign(db, campanha.id);
assert.equal(retomada.status, 'rodando');
assert.equal(retomada.pause_reason, null);
db.close();
});
test('setCampaignStatus rodando stamps semana_iniciada_em on first transition', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
assert.equal(campanha.semana_iniciada_em, null);
const rodando = setCampaignStatus(db, campanha.id, 'rodando');
assert.notEqual(rodando.semana_iniciada_em, null, 'semana_iniciada_em should be set on first rodando transition');
db.close();
});
test('setCampaignStatus rodando does not reset already-set semana_iniciada_em', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
// Set to rodando first time, which stamps semana_iniciada_em
setCampaignStatus(db, campanha.id, 'rodando');
// Manually set to a known past value via raw SQL
const knownTime = '2026-07-15 10:00:00';
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = ? WHERE id = ?`).run(knownTime, campanha.id);
// Call setCampaignStatus again while already 'rodando'
const unchanged = setCampaignStatus(db, campanha.id, 'rodando');
// Verify semana_iniciada_em was not changed
assert.equal(unchanged.semana_iniciada_em, knownTime, 'semana_iniciada_em should not be reset on subsequent rodando calls');
db.close();
});
test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 }));
setCampaignStatus(db, campanha.id, 'rodando');
setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
// Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em
// backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento.
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id);
resumeCampaign(db, campanha.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente');
const atualizada = getCampaign(db, campanha.id);
assert.equal(atualizada.limite_diario_atual, 10);
db.close();
});
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
const resultado = resumeCampaign(db, campanha.id);
assert.equal(resultado.status, 'rascunho');
db.close();
});
test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 }));
setCampaignStatus(db, campanha.id, 'rodando');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 1);
const atualizada = getCampaign(db, campanha.id);
assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15
db.close();
});
test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => {
const db = openDb(':memory:');
const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' }));
setCampaignStatus(db, rodando.id, 'rodando');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id);
const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' }));
setCampaignStatus(db, pausada.id, 'rodando');
setCampaignStatus(db, pausada.id, 'pausado', 'manual');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 0);
db.close();
});
test('opt-out: addOptOut bloqueia, removeOptOut libera', () => {
const db = openDb(':memory:');
assert.equal(isOptedOut(db, '5511999990001'), false);
addOptOut(db, '5511999990001', 'pediu pra parar');
assert.equal(isOptedOut(db, '5511999990001'), true);
removeOptOut(db, '5511999990001');
assert.equal(isOptedOut(db, '5511999990001'), false);
db.close();
});
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
// horario real em que o teste roda.
function ativarWorker(db) {
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '00:00');
setSetting(db, 'outreach.horaFim', '23:59');
}
function inserirLead(db, dedup, whatsapp) {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '',
cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '',
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]',
capture_run_id: null, captured_at: null, exported_at: null
});
}
function campanhaAprovada(db, quantidadeLeads = 1) {
for (let i = 0; i < quantidadeLeads; i += 1) {
inserirLead(db, `lead-${i}`, `551199999000${i}`);
}
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
limiteDiarioInicial: 10
});
setCampaignStatus(db, campanha.id, 'rodando');
generateCampaignQueue(db, campanha.id);
const fila = listQueue(db, campanha.id, 'preview');
approveQueueRows(db, campanha.id, fila.map((r) => r.id));
return campanha;
}
function fakeZproClient(overrides = {}) {
const enviados = [];
return {
enviados,
sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; },
...overrides
};
}
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
const fila = listQueue(db, campanha.id, 'sent');
assert.equal(fila.length, 1);
assert.ok(fila[0].outreach_send_id);
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id);
assert.equal(send.status, 'enviado');
assert.equal(send.origem, 'automatico');
db.close();
});
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 2);
let chamada = 0;
const zproClient = fakeZproClient({
sendText: async (args) => {
chamada += 1;
if (chamada === 1) throw new Error('Z-PRO falhou');
return { data: { ticketId: 1 } };
}
});
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(resumo.erros, 1);
const erro = listQueue(db, campanha.id, 'error')[0];
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id);
assert.equal(send.status, 'erro');
db.close();
});
test('runCampaignQueue respeita o limite diario da campanha', async () => {
const db = openDb(':memory:');
ativarWorker(db);
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
limiteDiarioInicial: 1
});
setCampaignStatus(db, campanha.id, 'rodando');
generateCampaignQueue(db, campanha.id);
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
db.close();
});
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
addOptOut(db, '5511999990000', 'pediu pra parar');
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.pulados, 1);
assert.equal(zproClient.enviados.length, 0);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
assert.equal(cancelada.cancel_reason, 'opt_out');
db.close();
});
test('runCampaignQueue so processa campanhas com status rodando', async () => {
const db = openDb(':memory:');
ativarWorker(db);
inserirLead(db, 'l1', '5511999990001');
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
});
generateCampaignQueue(db, campanha.id);
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
// campanha continua em 'rascunho' — nunca foi iniciada
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
db.close();
});
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 10);
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
let chamada = 0;
const zproClient = fakeZproClient({
sendText: async () => {
chamada += 1;
if (chamada <= 8) return { data: { ticketId: chamada } };
throw new Error('falha simulada');
}
});
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
const db = openDb(':memory:');
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '08:00');
setSetting(db, 'outreach.horaFim', '18:00');
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
const foraDoHorario = new Date();
foraDoHorario.setHours(3, 0, 0, 0);
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
+90
View File
@@ -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 });
}
});
@@ -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();
});

Some files were not shown because too many files have changed in this diff Show More