Baseline commit of the existing Maquina Comercial Pessoal project
Tracks the app that was already in place (capture, treatment, outreach, WhatsApp integration, server, frontend, tests, docs) so future feature work can branch and use isolated worktrees.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Configuração local opcional da Máquina Comercial Pessoal
|
||||
APP_PORT=3910
|
||||
# Obrigatoria para habilitar o reset destrutivo da base pela interface
|
||||
APP_RESET_PASSWORD=
|
||||
#APP_DB_PATH=data/maquina-pessoal.db
|
||||
CNPJ_BASE_DIR=prospeccao/cnpj
|
||||
GOOGLE_PLACES_API_KEY=
|
||||
PERSONAL_COMMERCIAL_WHATSAPP=
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
prospeccao/cnpj/rfb-raw/
|
||||
prospeccao/cnpj/*.csv
|
||||
prospeccao/cnpj/*.ndjson
|
||||
*.log
|
||||
Maquina-Comercial-Pessoal.exe
|
||||
data/runtime/
|
||||
launcher/publish/
|
||||
test-results/
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
if exist "Maquina-Comercial-Pessoal.exe" (
|
||||
start "" "Maquina-Comercial-Pessoal.exe"
|
||||
) else (
|
||||
echo Maquina-Comercial-Pessoal.exe nao encontrado. Gerando agora...
|
||||
powershell -ExecutionPolicy Bypass -File "scripts\build-launcher.ps1"
|
||||
if exist "Maquina-Comercial-Pessoal.exe" start "" "Maquina-Comercial-Pessoal.exe"
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Máquina Comercial Pessoal
|
||||
|
||||
Aplicação local para uma pessoa capturar, qualificar, tratar, exportar e contatar leads. Os dados ficam em SQLite no próprio computador e o WhatsApp funciona por uma sessão de dispositivo vinculado.
|
||||
|
||||
## Instalação e acesso
|
||||
|
||||
Requisitos: Node.js 24 ou superior e npm.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Abra `http://127.0.0.1:3910`. O acesso inicial é `admin` / `admin`; abra **Configurações > Alterar minha senha**, informe `admin` como senha atual e defina uma nova senha com ao menos 8 caracteres. Essa opção está disponível para qualquer usuário autenticado. No Windows também é possível executar `INICIAR-MAQUINA-PESSOAL.bat` ou gerar o launcher com `npm run build:launcher`.
|
||||
|
||||
Variáveis opcionais ficam em `.env`. Consulte `.env.example`. A porta padrão é `3910` e pode ser alterada com `APP_PORT`.
|
||||
|
||||
## Fluxo operacional
|
||||
|
||||
1. Em **Capturar Leads**, escolha UF, cidades, nichos e fontes.
|
||||
2. Execute a captura e acompanhe o progresso.
|
||||
3. Em **Tratar Leads**, filtre e priorize a base.
|
||||
4. Use a ação de WhatsApp na primeira coluna para abrir o envio manual já preenchido com o lead real.
|
||||
5. Exporte o filtro atual em CSV e XLS quando precisar trabalhar fora da aplicação.
|
||||
|
||||
## Conexão WhatsApp
|
||||
|
||||
1. Abra **Configurações** como administrador.
|
||||
2. Clique em **Conectar / gerar QR**.
|
||||
3. No celular, abra **WhatsApp > Dispositivos conectados > Conectar dispositivo**.
|
||||
4. Leia o QR exibido na aplicação e aguarde o estado **Conectado**.
|
||||
|
||||
Os estados da conexão incluem parado, iniciando, QR necessário, autenticando, conectado, reconectando, desconectado e erro. **Desconectar** interrompe a conexão preservando a sessão. **Sair e esquecer sessão** remove o vínculo local e exigirá um novo QR.
|
||||
|
||||
O envio manual exige número com DDI e DDD, cadastro ativo desse número no WhatsApp, mensagem revisada e conexão pronta. O histórico distingue aceite local, confirmação do servidor, entrega e leitura; "aceito" ainda não significa que a mensagem foi entregue.
|
||||
|
||||
## Automação e segurança comercial
|
||||
|
||||
- A automação inicia desligada e deve ser ativada conscientemente pelo administrador.
|
||||
- Configure um limite diário baixo e mantenha os intervalos de envio.
|
||||
- Faça contato apenas em horários adequados e com uma finalidade legítima.
|
||||
- Adicione imediatamente à lista de bloqueio quem pedir para não receber mensagens.
|
||||
- Não faça spam, disparos indiscriminados nem compre listas de contatos.
|
||||
- A conexão usa integração não oficial por dispositivo vinculado. Mudanças no WhatsApp podem interromper o funcionamento e uso abusivo pode causar bloqueio da conta.
|
||||
|
||||
Templates automáticos são simples: serviço, mensagem e estado ativo. O catálogo inicial usa o serviço neutro **Meu serviço**; adapte sua oferta no mecanismo de configuração de serviços antes de ativar automações.
|
||||
|
||||
## Dados locais
|
||||
|
||||
O banco, exportações, logs e autenticação do WhatsApp ficam em `data/`. A sessão vinculada fica em `data/runtime/whatsapp-auth`. Toda essa pasta é ignorada pelo Git.
|
||||
|
||||
Faça backup do banco indicado por `APP_DB_PATH` antes de manutenção ou migração. Nunca compartilhe `.env`, banco, logs ou a pasta de autenticação.
|
||||
|
||||
## Comandos
|
||||
|
||||
```powershell
|
||||
npm start # servidor local
|
||||
npm test # suíte automatizada
|
||||
npm run capture # captura por linha de comando
|
||||
npm run download-rfb # baixa/retoma os 21 ZIPs necessarios (~6 GB)
|
||||
npm run extract -- --uf=BA # baixa o que faltar e extrai a base CNPJ da UF
|
||||
npm run recalc-scores # recalcula os scores
|
||||
npm run outreach # executa automação, quando habilitada
|
||||
npm run build:launcher # gera o launcher Windows
|
||||
```
|
||||
|
||||
O botão **Baixar e extrair base** faz o mesmo fluxo em um clique. O downloader escolhe o snapshot público mais recente, mantém arquivos `.part` para retomada e valida tamanho e estrutura EOCD/diretório central dos ZIPs antes da extração. Um snapshot novo exige por padrão 8 GiB livres; ao retomar o mesmo snapshot, ZIPs finais confirmados e `.part` com metadata reduzem esse requisito, preservando uma margem mínima de 2 GiB para extração. O conjunto completo anterior fica em `.rfb-backup` até o novo snapshot ser validado. Se o índice ficar offline com o conjunto atual incompleto, o backup é validado e restaurado; finals e manifesto incompletos são movidos para `.rfb-incomplete*`, e `.part` permanece disponível quando a restauração não troca de snapshot. Para extrair somente com os arquivos locais já completos, use `npm run extract -- --uf=BA --no-download`.
|
||||
|
||||
## Estrutura
|
||||
|
||||
```text
|
||||
public/ interface web
|
||||
prospeccao/config.json nichos e serviço neutro inicial
|
||||
scripts/ comandos operacionais e build do launcher
|
||||
src/capture/ captura de leads
|
||||
src/treatment/ consultas e dashboard
|
||||
src/outreach/ templates, limites e automação
|
||||
src/whatsapp/ sessão local e envio
|
||||
src/server/ servidor, autenticação e rotas
|
||||
launcher/ launcher Windows
|
||||
data/ banco e estado local ignorados
|
||||
```
|
||||
|
||||
## Solução de problemas
|
||||
|
||||
- **A tela não abre:** confirme `npm install`, rode `npm start` e acesse `http://127.0.0.1:3910`.
|
||||
- **Porta ocupada:** defina outra `APP_PORT` no `.env` e reinicie.
|
||||
- **QR não aparece:** clique em **Conectar / gerar QR**. Se o QR expirou ou a tentativa ficou presa, **Atualizar QR** reinicia o pareamento e solicita um QR novo.
|
||||
- **WhatsApp desconectado:** tente conectar novamente. Se a sessão estiver inválida, use **Sair e esquecer sessão** e vincule de novo.
|
||||
- **Mensagem recusada:** confira conexão, número com DDI/DDD, limite diário e lista de bloqueio.
|
||||
- **Captura sem resultados:** confirme a base CNPJ da UF, as fontes escolhidas e `GOOGLE_PLACES_API_KEY` caso use Google Places.
|
||||
- **Launcher não compila:** instale o .NET SDK ou execute a aplicação diretamente com `npm start`.
|
||||
|
||||
## Boas práticas
|
||||
|
||||
Mantenha o servidor em `127.0.0.1`, use senhas individuais, preserve a automação desligada até revisar templates e limites, e respeite privacidade, consentimento e pedidos de opt-out.
|
||||
@@ -0,0 +1,583 @@
|
||||
# Commercial Outreach Pilot Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a supervised, measurable WhatsApp prospecting operation for GTI Vendas and G-Delivery with approved daily queues, gradual number verification, inbound-reply pauses, a manual commercial funnel, and conservative scheduling.
|
||||
|
||||
**Architecture:** Add a campaign layer that references existing leads, verification, opt-out, and provider-send records without duplicating their authority. Queue preparation, scheduling, execution, inbound handling, pipeline state, metrics, and UI each live in focused modules with transactional boundaries at approval, reservation, reply, and stage changes.
|
||||
|
||||
**Tech Stack:** Node.js 24 ESM, `node:test`, SQLite `DatabaseSync`, Baileys 7.0.0-rc14, browser JavaScript, Playwright browser acceptance tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work only in `MAQUINA COMERCIAL PESSOAL`.
|
||||
- Products are exactly `GTI Vendas` and `G-Delivery`.
|
||||
- The sender is `Gustavo - Representante Comercial do Grupo GTI`.
|
||||
- Do not put price, links, CNPJ, source-database details, unsupported features, unapproved integrations, technical deadlines, or guaranteed results in the initial message.
|
||||
- Every initial message contains a simple refusal mechanism.
|
||||
- The pilot has no follow-up.
|
||||
- Campaign order is G-Delivery food delivery, GTI Vendas food/markets, then GTI Vendas retail/services.
|
||||
- Operating windows are Monday-Friday `09:30-11:30` and `14:00-17:00` local time.
|
||||
- Daily campaign progression is `10`, `12`, `15`, and `18`; weekly increases require explicit approval.
|
||||
- Campaign sends use random `12-25` minute intervals and at most four attempts in a rolling hour.
|
||||
- Manual and automatic daily limits are separate and displayed separately.
|
||||
- Previously unverified checks per day are capped at `ceil(1.5 * approved campaign daily limit)`.
|
||||
- Any inbound message pauses the equivalent phone/lead; no automatic commercial reply is sent.
|
||||
- Unknown delivery is terminal and never retried automatically.
|
||||
- Automation starts disabled after every server restart.
|
||||
- Do not send real messages from automated tests.
|
||||
- Do not create a Git commit unless the user explicitly requests one.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Campaign Persistence and Fixed Catalog
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/db.mjs`
|
||||
- Create: `src/outreach/campaign-store.mjs`
|
||||
- Create: `src/outreach/campaign-catalog.mjs`
|
||||
- Test: `tests/campaign-store.test.mjs`
|
||||
- Modify: `tests/db.test.mjs`
|
||||
- Modify: `tests/personal-branding.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `CAMPAIGN_PRODUCTS`, `CAMPAIGN_STAGES`, `CAMPAIGN_DEFINITIONS`
|
||||
- Produces: `seedCampaigns(db)`, `listCampaigns(db)`, `getCampaign(db, id)`
|
||||
- Produces: `getLeadCommercialState(db, leadId)`, `setLeadCommercialStage(db, input)`
|
||||
- Produces migrations v15 tables consumed by all later tasks
|
||||
|
||||
- [ ] **Step 1: Write failing migration and seed tests**
|
||||
|
||||
```js
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
||||
assert.ok(tables.includes('outreach_campaigns'));
|
||||
assert.ok(tables.includes('outreach_campaign_variants'));
|
||||
assert.ok(tables.includes('outreach_campaign_queue'));
|
||||
assert.ok(tables.includes('outreach_inbound_replies'));
|
||||
assert.ok(tables.includes('outreach_lead_states'));
|
||||
assert.ok(tables.includes('outreach_stage_events'));
|
||||
assert.ok(tables.includes('outreach_appointments'));
|
||||
seedCampaigns(db);
|
||||
assert.deepEqual(listCampaigns(db).map((c) => c.produto), ['G-Delivery', 'GTI Vendas', 'GTI Vendas']);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-store.test.mjs tests/db.test.mjs tests/personal-branding.test.mjs`
|
||||
|
||||
Expected: FAIL because v15, campaign catalog, and campaign helpers do not exist and branding tests still reject approved Grupo GTI names.
|
||||
|
||||
- [ ] **Step 3: Append migration v15**
|
||||
|
||||
Add these table responsibilities and constraints in one appended migration:
|
||||
|
||||
```sql
|
||||
CREATE TABLE outreach_campaigns (
|
||||
id INTEGER PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
produto TEXT NOT NULL CHECK (produto IN ('GTI Vendas','G-Delivery')),
|
||||
status TEXT NOT NULL DEFAULT 'paused' CHECK (status IN ('draft','paused','ready','running','completed')),
|
||||
pause_reason TEXT,
|
||||
week_number INTEGER NOT NULL DEFAULT 1 CHECK (week_number BETWEEN 1 AND 4),
|
||||
approved_daily_limit INTEGER NOT NULL DEFAULT 10 CHECK (approved_daily_limit IN (10,12,15,18)),
|
||||
segments_json TEXT NOT NULL,
|
||||
windows_json TEXT NOT NULL DEFAULT '[{"start":"09:30","end":"11:30"},{"start":"14:00","end":"17:00"}]',
|
||||
interval_min_ms INTEGER NOT NULL DEFAULT 720000,
|
||||
interval_max_ms INTEGER NOT NULL DEFAULT 1500000,
|
||||
hourly_limit INTEGER NOT NULL DEFAULT 4,
|
||||
verification_multiplier REAL NOT NULL DEFAULT 1.5,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE outreach_campaign_variants (
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL CHECK (code IN ('A','B','C')),
|
||||
mensagem TEXT NOT NULL,
|
||||
ativo INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(campaign_id, code)
|
||||
);
|
||||
|
||||
CREATE TABLE outreach_campaign_queue (
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id) ON DELETE CASCADE,
|
||||
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
|
||||
numero TEXT NOT NULL,
|
||||
segmento TEXT NOT NULL,
|
||||
rendered_message TEXT NOT NULL,
|
||||
selection_reason TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('preview','approved','scheduled','reserved','sent','cancelled','unknown','error')),
|
||||
scheduled_at TEXT,
|
||||
outreach_send_id INTEGER REFERENCES outreach_sends(id),
|
||||
cancel_reason TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_campaign_queue_active_lead
|
||||
ON outreach_campaign_queue(campaign_id, lead_id)
|
||||
WHERE status IN ('preview','approved','scheduled','reserved','sent','unknown');
|
||||
CREATE INDEX idx_campaign_queue_due ON outreach_campaign_queue(status, scheduled_at);
|
||||
|
||||
CREATE TABLE outreach_inbound_replies (
|
||||
id INTEGER PRIMARY KEY,
|
||||
provider_message_id TEXT NOT NULL UNIQUE,
|
||||
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
|
||||
numero TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE outreach_lead_states (
|
||||
lead_id INTEGER PRIMARY KEY REFERENCES leads(id) ON DELETE CASCADE,
|
||||
stage TEXT NOT NULL,
|
||||
first_reply_at TEXT,
|
||||
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) ON DELETE CASCADE,
|
||||
campaign_id INTEGER REFERENCES outreach_campaigns(id) ON DELETE SET NULL,
|
||||
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) ON DELETE CASCADE,
|
||||
campaign_id INTEGER REFERENCES outreach_campaigns(id) ON DELETE SET NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('demonstracao','teste_g_delivery')),
|
||||
participant_name TEXT,
|
||||
company_name TEXT,
|
||||
preferred_day TEXT,
|
||||
preferred_time TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
Add lead-linked campaign tables to `resetLeadsBase` deletion order; preserve opt-outs.
|
||||
|
||||
- [ ] **Step 4: Implement catalog and store helpers**
|
||||
|
||||
Define three campaigns with exact codes `gdelivery-food`, `gti-food`, `gti-retail`; exact segment arrays from the specification; exact products; and six unique catalog variants. Seed A/B/C G-Delivery rows into `gdelivery-food` and seed the same A/B/C GTI Vendas texts into both GTI campaigns, producing nine campaign-owned rows from six unique approved texts. `seedCampaigns` inserts missing campaign/variant rows but does not overwrite an edited status, week, or approved limit.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/campaign-store.test.mjs tests/db.test.mjs tests/personal-branding.test.mjs`
|
||||
|
||||
Expected: PASS; migration remains idempotent; reset deletes lead-linked campaign records and preserves opt-outs.
|
||||
|
||||
### Task 2: Safe Campaign Message Rendering
|
||||
|
||||
**Files:**
|
||||
- Create: `src/outreach/campaign-messages.mjs`
|
||||
- Test: `tests/campaign-messages.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `naturalBusinessName(lead): string`
|
||||
- Produces: `managementFocus(segment): string`
|
||||
- Produces: `renderCampaignMessage(template, lead): string`
|
||||
- Produces: `variantForLead(variants, leadId): object`
|
||||
|
||||
- [ ] **Step 1: Write failing rendering tests**
|
||||
|
||||
```js
|
||||
assert.equal(naturalBusinessName({ empresa: '61.590.668 A ARAUJO', legal_name: 'A ARAUJO LTDA' }), '');
|
||||
assert.equal(naturalBusinessName({ empresa: 'Pizzaria Central' }), 'Pizzaria Central');
|
||||
assert.equal(managementFocus('restaurantes'), 'vendas, estoque, comandas e emissão fiscal');
|
||||
assert.equal(variantForLead([{ code: 'A' }, { code: 'B' }, { code: 'C' }], 4).code, 'B');
|
||||
assert.doesNotMatch(renderCampaignMessage(template, lead), /CNPJ|R\$|http/i);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-messages.test.mjs`
|
||||
|
||||
Expected: FAIL because the focused campaign renderer does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement deterministic safe rendering**
|
||||
|
||||
Use `leadId % activeVariants.length` for stable A/B/C assignment. Replace `{foco_gestao}` and the opening greeting. When the trade name is natural, render `Olá, pessoal da {nome_fantasia}, tudo bem?`; otherwise preserve `Olá, tudo bem?` or `Olá!` according to the approved variant. Reject a rendered initial message when it contains a URL, a CNPJ-shaped 14-digit number, `R$`, or lacks both `Gustavo` and `Grupo GTI` or the refusal sentence `não volto a entrar em contato`.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/campaign-messages.test.mjs tests/scoring.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Gradual Verification and Queue Preview
|
||||
|
||||
**Files:**
|
||||
- Create: `src/outreach/campaign-queue.mjs`
|
||||
- Modify: `src/outreach/campaign-store.mjs`
|
||||
- Test: `tests/campaign-queue.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `prepareDailyQueue({ db, whatsapp, campaignId, now }): Promise<{checked, previewed, skipped}>`
|
||||
- Produces: `listCampaignQueue(db, campaignId, status?)`
|
||||
- Consumes exact helpers from Tasks 1-2 and existing verification/opt-out helpers
|
||||
|
||||
- [ ] **Step 1: Write failing eligibility and cap tests**
|
||||
|
||||
Cover campaign segment order, no prior product send, latest confirmed equivalent number, opt-out, inbound-replied state, active queue dedupe, stable variants, and exact verification cap:
|
||||
|
||||
```js
|
||||
const summary = await prepareDailyQueue({ db, whatsapp, campaignId, now });
|
||||
assert.ok(summary.checked <= Math.ceil(campaign.approved_daily_limit * 1.5));
|
||||
assert.ok(summary.previewed <= campaign.approved_daily_limit);
|
||||
assert.equal(new Set(queue.map((q) => q.lead_id)).size, queue.length);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-queue.test.mjs`
|
||||
|
||||
Expected: FAIL because no campaign eligibility/preparation service exists.
|
||||
|
||||
- [ ] **Step 3: Implement sparse candidate selection and verification**
|
||||
|
||||
Query only the active campaign's segment list, ordered by `prioridade ASC, score DESC, id ASC`, and exclude prior same-product `outreach_sends`, replies, opt-outs, and active queue rows in SQL. Process already-confirmed candidates first. Call `whatsapp.checkNumber` sequentially for at most `ceil(1.5 * approved_daily_limit)` unverified candidates, persist every result, and never retry `erro_temporario` in the same preparation run.
|
||||
|
||||
Insert preview rows transactionally with immutable variant ID, rendered text, normalized number, segment, and a selection reason such as `gdelivery-food:prioridade-1`.
|
||||
|
||||
- [ ] **Step 4: Verify no full-base scan**
|
||||
|
||||
Add a test with 10,000 out-of-segment leads and inspect `EXPLAIN QUERY PLAN`; require `idx_leads_segmento` or another selective campaign index and bounded candidate results.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/campaign-queue.test.mjs tests/db.test.mjs`
|
||||
|
||||
Expected: PASS with no provider send calls.
|
||||
|
||||
### Task 4: Scheduling, Approval, and Campaign API
|
||||
|
||||
**Files:**
|
||||
- Create: `src/outreach/campaign-schedule.mjs`
|
||||
- Create: `src/server/routes/campaigns.mjs`
|
||||
- Modify: `src/server/index.mjs`
|
||||
- Test: `tests/campaign-schedule.test.mjs`
|
||||
- Test: `tests/campaign-routes.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `generateDailySchedule({ date, count, windows, minMs, maxMs, hourlyLimit, random }): Date[]`
|
||||
- Produces routes under `/api/campaigns`
|
||||
- Consumes `prepareDailyQueue` and campaign-store queue transitions
|
||||
|
||||
- [ ] **Step 1: Write failing schedule tests**
|
||||
|
||||
Assert Monday-Friday only, exact windows, 12-25 minute requested spacing, no more than four timestamps in any rolling 60 minutes, deterministic injected random, lunch break, and at most 18 slots.
|
||||
|
||||
- [ ] **Step 2: Run schedule tests and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-schedule.test.mjs`
|
||||
|
||||
Expected: FAIL because schedule generation does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement schedule generation**
|
||||
|
||||
Advance through both windows in local time. For each random interval, enforce `candidate >= fourthMostRecent + 60 minutes` before accepting. If no valid slot remains, return fewer timestamps; never spill into lunch, another date, or weekend.
|
||||
|
||||
- [ ] **Step 4: Write failing route tests**
|
||||
|
||||
Required admin routes:
|
||||
|
||||
```text
|
||||
GET /api/campaigns
|
||||
GET /api/campaigns/:id
|
||||
POST /api/campaigns/:id/prepare
|
||||
GET /api/campaigns/:id/queue
|
||||
POST /api/campaigns/:id/approve
|
||||
DELETE /api/campaigns/:id/queue/:queueId
|
||||
POST /api/campaigns/:id/start
|
||||
POST /api/campaigns/:id/pause
|
||||
PUT /api/campaigns/:id/week
|
||||
```
|
||||
|
||||
Assert non-admin mutation is 403; approval accepts explicit queue IDs only; deletion cannot remove reserved/sent rows; start requires at least one scheduled row and WhatsApp READY; week changes map exactly `1->10`, `2->12`, `3->15`, `4->18` and never advance implicitly.
|
||||
|
||||
- [ ] **Step 5: Implement transactional transitions and routes**
|
||||
|
||||
Approval schedules selected preview rows and cancels unselected rows with `cancel_reason='removed_by_operator'`. Campaign start changes `ready|paused -> running`; pause stores a required reason. Return manual and campaign daily usage as separate fields.
|
||||
|
||||
- [ ] **Step 6: Run route tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/campaign-schedule.test.mjs tests/campaign-routes.test.mjs tests/server-whatsapp-integration.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 5: Due Queue Worker, Recovery, and Separate Limits
|
||||
|
||||
**Files:**
|
||||
- Create: `src/outreach/campaign-worker.mjs`
|
||||
- Modify: `src/db.mjs`
|
||||
- Modify: `src/outreach/scheduler.mjs`
|
||||
- Modify: `src/outreach/run.mjs`
|
||||
- Modify: `scripts/servidor.mjs`
|
||||
- Modify: `scripts/enviar-outreach.mjs`
|
||||
- Test: `tests/campaign-worker.test.mjs`
|
||||
- Modify: `tests/scheduler.test.mjs`
|
||||
- Modify: `tests/server-shutdown.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `runDueCampaignQueue({ db, whatsapp, now, signal, log }): Promise<summary>`
|
||||
- Produces: `pauseCampaignsAfterRestart(db): number`
|
||||
- Produces: `reserveCampaignQueueSend(db, { queueId, now }): { queueId, sendId }`
|
||||
- Changes `reserveOutreachSend` so its manual limit query counts only `origem='manual'`; automatic campaign limits are enforced by `reserveCampaignQueueSend`
|
||||
|
||||
- [ ] **Step 1: Write failing separate-limit and due-item tests**
|
||||
|
||||
Assert manual sends count only `origem='manual'`; campaign sends count only the campaign's automatic attempts. Due worker sends only `scheduled_at <= now`, never preview/approved/future rows, and handles one due row at a time.
|
||||
|
||||
- [ ] **Step 2: Run worker tests and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-worker.test.mjs tests/outreach-routes.test.mjs tests/scheduler.test.mjs`
|
||||
|
||||
Expected: FAIL on missing worker and shared-origin limit.
|
||||
|
||||
- [ ] **Step 3: Implement atomic campaign reservation**
|
||||
|
||||
Inside `whatsapp.sendText.beforeSend`, run one transaction that re-reads queue/campaign/lead state and requires: campaign running, queue scheduled and due, READY connection, confirmed number, no equivalent opt-out, no inbound reply, no same-product active send, automatic count below campaign limit, and rolling-hour attempts below four. Transition queue to `reserved` and create `outreach_sends` together; return both IDs.
|
||||
|
||||
In `afterSend`, finalize provider history and queue status exactly once. Map delivery unknown to queue `unknown`, definite error to `error`, and success to `sent`.
|
||||
|
||||
- [ ] **Step 4: Replace raw-lead scheduler execution**
|
||||
|
||||
Run the due worker every 60 seconds while retaining overlap prevention and abort-aware shutdown. Make `runOutreachBatch` and `scripts/enviar-outreach.mjs` consume the same approved due queue; neither may select raw leads.
|
||||
|
||||
- [ ] **Step 5: Implement restart safety**
|
||||
|
||||
Before scheduler start, set every `running` campaign to `paused` with `pause_reason='server_restart'`, set legacy `outreach.ativo=false`, recover reserved queue rows by joining `outreach_sends`, and preserve scheduled rows without sending until explicit start.
|
||||
|
||||
- [ ] **Step 6: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/campaign-worker.test.mjs tests/scheduler.test.mjs tests/server-shutdown.test.mjs tests/outreach-routes.test.mjs tests/outreach.test.mjs`
|
||||
|
||||
Expected: PASS; no raw-lead automatic path remains.
|
||||
|
||||
### Task 6: Inbound Reply Detection and Send-Race Protection
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/whatsapp/manager.mjs`
|
||||
- Create: `src/outreach/inbound.mjs`
|
||||
- Modify: `scripts/servidor.mjs`
|
||||
- Modify: `src/outreach/campaign-worker.mjs`
|
||||
- Test: `tests/whatsapp-manager.test.mjs`
|
||||
- Test: `tests/inbound-outreach.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Adds manager option `onInboundMessage(event)`
|
||||
- Event: `{ messageId: string, jid: string, number: string, receivedAt: string }`
|
||||
- Produces: `recordInboundReply(db, event): { inserted, leadId, cancelled }`
|
||||
|
||||
- [ ] **Step 1: Write failing manager event tests**
|
||||
|
||||
Emit `messages.upsert` and assert only new, non-`fromMe`, one-to-one `@s.whatsapp.net` messages produce one callback. Ignore groups, broadcasts, status JIDs, duplicates, and messages without an ID.
|
||||
|
||||
- [ ] **Step 2: Run manager tests and verify RED**
|
||||
|
||||
Run: `node --test tests/whatsapp-manager.test.mjs`
|
||||
|
||||
Expected: FAIL because inbound events are not subscribed.
|
||||
|
||||
- [ ] **Step 3: Implement provider-neutral inbound event publication**
|
||||
|
||||
Do not classify text or send a reply. Normalize only the sender number and timestamp; preserve existing outbound `messages.update` behavior.
|
||||
|
||||
- [ ] **Step 4: Write failing persistence/race tests**
|
||||
|
||||
For an inbound number, find the latest campaign send across equivalent variants, insert by unique provider ID, set current stage `respondeu`, set `first_reply_at` once, append a stage event, and cancel every preview/approved/scheduled row for equivalent numbers. Reprocessing the same provider ID is idempotent.
|
||||
|
||||
Block a worker in `beforeSend`, record an inbound reply, then release it; assert provider `sendMessage` is never called.
|
||||
|
||||
- [ ] **Step 5: Implement inbound transaction and startup injection**
|
||||
|
||||
Inject `onInboundMessage: (event) => recordInboundReply(db, event)` in `scripts/servidor.mjs`. Log persistence failures without throwing through the provider EventEmitter.
|
||||
|
||||
- [ ] **Step 6: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/whatsapp-manager.test.mjs tests/inbound-outreach.test.mjs tests/campaign-worker.test.mjs`
|
||||
|
||||
Expected: PASS and no automatic outbound reply.
|
||||
|
||||
### Task 7: Manual Pipeline and Appointment API
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/outreach/campaign-store.mjs`
|
||||
- Modify: `src/server/routes/campaigns.mjs`
|
||||
- Modify: `src/treatment/queries.mjs`
|
||||
- Test: `tests/pipeline.test.mjs`
|
||||
- Test: `tests/campaign-routes.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `listPendingReplies(db)`
|
||||
- Produces: `setLeadCommercialStage(db, { leadId, campaignId, stage, note })`
|
||||
- Produces: `saveAppointment(db, input)`
|
||||
- Adds current stage/history/appointments to `getLead`
|
||||
|
||||
- [ ] **Step 1: Write failing stage and appointment tests**
|
||||
|
||||
Accept only the 11 specification stages. Every change updates current state and appends history in one transaction. `opt_out` requires `addOptOut` for the equivalent phone. `demonstracao_agendada` requires participant, company, preferred day, and preferred time. `teste_g_delivery_iniciado` creates a test appointment without demo time requirements.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify RED**
|
||||
|
||||
Run: `node --test tests/pipeline.test.mjs tests/campaign-routes.test.mjs`
|
||||
|
||||
Expected: FAIL because stage/appointment APIs do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement routes and enrichment**
|
||||
|
||||
```text
|
||||
GET /api/campaigns/replies/pending
|
||||
PUT /api/campaigns/leads/:leadId/stage
|
||||
POST /api/campaigns/leads/:leadId/appointments
|
||||
```
|
||||
|
||||
Return stage history newest first, appointments newest first, and provider/send history without duplicating ACK fields.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/pipeline.test.mjs tests/campaign-routes.test.mjs tests/personal-backend.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 8: Campaign Metrics and Automatic Pause Policies
|
||||
|
||||
**Files:**
|
||||
- Create: `src/outreach/campaign-metrics.mjs`
|
||||
- Modify: `src/outreach/campaign-worker.mjs`
|
||||
- Modify: `src/outreach/campaign-store.mjs`
|
||||
- Modify: `src/server/routes/campaigns.mjs`
|
||||
- Test: `tests/campaign-metrics.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `campaignMetrics(db, campaignId, filters?)`
|
||||
- Produces: `evaluateCampaignHealth(db, campaignId): { pause, reason }`
|
||||
|
||||
- [ ] **Step 1: Write failing metric tests**
|
||||
|
||||
Cover previewed, approved, removed, attempted, provider statuses, replies, first-response time, positive/negative/opt-out, demos, tests, sales, and grouping by product/segment/variant.
|
||||
|
||||
- [ ] **Step 2: Write failing pause tests**
|
||||
|
||||
Exact policies:
|
||||
|
||||
```js
|
||||
unknownCount > 0 // unknown_outcome
|
||||
attemptsToday >= 10 && failureRateToday > 0.10 // high_failure_rate
|
||||
contacts >= 20 && negativeOrOptOutRate > 0.10 // high_negative_rate
|
||||
contacts >= 50 && replyRate < 0.05 // low_reply_rate
|
||||
```
|
||||
|
||||
Assert equality at 10% does not pause because the requirement says exceeds 10%. Assert an automatic pause never changes itself back to running.
|
||||
|
||||
- [ ] **Step 3: Run tests and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-metrics.test.mjs`
|
||||
|
||||
Expected: FAIL because campaign metrics/policies do not exist.
|
||||
|
||||
- [ ] **Step 4: Implement metrics and policy hooks**
|
||||
|
||||
Evaluate health after every send finalization and manual stage change. On pause, store the reason and cancel no historical rows; future scheduled rows remain visible but ineligible until an explicit resume. A variant receives `eligibleForComparison=true` only at 40 sends for that exact campaign/segment/variant; declare a winner only when all active compared variants satisfy that threshold.
|
||||
|
||||
- [ ] **Step 5: Add metrics route and verify GREEN**
|
||||
|
||||
Route: `GET /api/campaigns/:id/metrics?segmento=&variant=`
|
||||
|
||||
Run: `node --test tests/campaign-metrics.test.mjs tests/campaign-routes.test.mjs tests/campaign-worker.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 9: Supervised Campaign Browser Workspace
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/index.html`
|
||||
- Create: `public/js/campanhas.js`
|
||||
- Modify: `public/js/app.js`
|
||||
- Modify: `public/js/tratamento.js`
|
||||
- Modify: `public/css/app.css`
|
||||
- Modify: `tests/personal-branding.test.mjs`
|
||||
- Create: `tests/campaign-ui.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Adds top-level `Campanhas` tab
|
||||
- Consumes campaign, queue, reply, stage, appointment, and metrics routes
|
||||
- Does not construct outbound text in the browser
|
||||
|
||||
- [ ] **Step 1: Write failing UI behavior tests**
|
||||
|
||||
Using the existing VM DOM harness, test campaign cards, week/limit display, prepare action, preview rows with exact server-rendered text, per-row removal, explicit approval, start/pause controls, separate manual/automatic usage, pending replies, stage classification, appointment form, metrics filters, and a simulated post-server-restart response showing `paused` with reason `server_restart`.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify RED**
|
||||
|
||||
Run: `node --test tests/campaign-ui.test.mjs tests/personal-branding.test.mjs`
|
||||
|
||||
Expected: FAIL because no campaign workspace exists.
|
||||
|
||||
- [ ] **Step 3: Build the focused campaign tab**
|
||||
|
||||
Keep existing visual language. Use separate sections for campaign status, daily preview, pending replies, funnel, and metrics. Require confirmation before start, weekly increase, and opt-out. Show pause reasons prominently. Never expose an editable campaign message after approval; removal is allowed, mutation is not.
|
||||
|
||||
- [ ] **Step 4: Extend lead detail**
|
||||
|
||||
Show current stage, first reply, stage history, campaign/send history, and appointments. Keep manual send available but do not prefill it with browser-generated generic campaign copy.
|
||||
|
||||
- [ ] **Step 5: Add responsive behavior and verify GREEN**
|
||||
|
||||
At widths below 760 px, campaign cards stack, tables remain horizontally scrollable, action controls become full-width, and no fixed-width dialog exceeds the viewport.
|
||||
|
||||
Run: `node --test tests/campaign-ui.test.mjs tests/personal-branding.test.mjs`
|
||||
|
||||
Expected: PASS; `node --check public/js/campanhas.js` also passes.
|
||||
|
||||
### Task 10: Real Browser Acceptance and Final Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `package-lock.json`
|
||||
- Create: `playwright.config.mjs`
|
||||
- Create: `tests/browser/server.mjs`
|
||||
- Create: `tests/browser/campaign-pilot.spec.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Adds `npm run test:browser`
|
||||
- Browser test server uses an isolated temporary SQLite database and fake WhatsApp manager
|
||||
- Never connects to or sends through the real provider
|
||||
|
||||
- [ ] **Step 1: Install the browser test dependency**
|
||||
|
||||
Run: `npm.cmd install --save-dev @playwright/test`
|
||||
|
||||
Run: `npx.cmd playwright install chromium`
|
||||
|
||||
- [ ] **Step 2: Create isolated browser test server**
|
||||
|
||||
Open a temporary database, seed admin/campaigns/sample leads/verifications, inject a fake READY manager that records sends in memory, and start `createAppServer` on `127.0.0.1:3911`. Delete the temporary database on shutdown.
|
||||
|
||||
- [ ] **Step 3: Write desktop and mobile acceptance tests**
|
||||
|
||||
Configure Chromium projects at `1440x900` and `390x844`. Seed one pending inbound reply in the isolated test database before browser startup. Test login, campaign prepare, preview removal, approval, explicit start, separate limits, pause reason, pending reply classification, demonstration scheduling, and metrics rendering. Assert fake provider send count remains zero unless the test advances a due scheduled item explicitly.
|
||||
|
||||
- [ ] **Step 4: Run all automated verification**
|
||||
|
||||
Run: `npm.cmd test`
|
||||
|
||||
Expected: all Node tests pass.
|
||||
|
||||
Run: `npm.cmd run test:browser`
|
||||
|
||||
Expected: desktop and mobile projects pass with no console errors.
|
||||
|
||||
Run: `npm.cmd audit --omit=dev`
|
||||
|
||||
Expected: zero production vulnerabilities.
|
||||
|
||||
- [ ] **Step 5: Restart and perform non-sending smoke checks**
|
||||
|
||||
Verify migration v15, server-restart campaign pause, WhatsApp status, campaign preview, manual and automatic counters, queue approval, pending-reply UI, and mobile layout. Do not start a real campaign or send a real message without Gustavo's explicit recipient-level authorization.
|
||||
@@ -0,0 +1,321 @@
|
||||
# WhatsApp Verification and Manual Send Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Send manual messages to the exact visible destination and use persisted WhatsApp evidence, rather than phone syntax, for verified labels and automatic outreach.
|
||||
|
||||
**Architecture:** Introduce a provider-independent Brazilian phone identity utility and a sparse SQLite verification cache. Keep Baileys responsible for bounded provider operations, routes responsible for persisting outcomes, and lead queries responsible for deriving the latest equivalent-number status.
|
||||
|
||||
**Tech Stack:** Node.js 24, ESM, `node:test`, SQLite `DatabaseSync`, Baileys 7.0.0-rc14, browser JavaScript.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work only in `MAQUINA COMERCIAL PESSOAL`.
|
||||
- Verify numbers on demand; never scan the full RFB base.
|
||||
- The visible manual number is the authoritative destination.
|
||||
- `telefone_valido` means syntactic phone validity only.
|
||||
- Automatic outreach uses only persisted `confirmado` numbers.
|
||||
- Send timeout means unknown outcome and must not invite retry.
|
||||
- Do not create a Git commit unless the user explicitly requests one.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Brazilian Phone Identity and Opt-Out Equivalence
|
||||
|
||||
**Files:**
|
||||
- Create: `src/phone.mjs`
|
||||
- Modify: `src/scoring.mjs`
|
||||
- Modify: `src/whatsapp/manager.mjs`
|
||||
- Modify: `src/db.mjs`
|
||||
- Test: `tests/scoring.test.mjs`
|
||||
- Test: `tests/personal-backend.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `normalizePhone(value): string`
|
||||
- Produces: `equivalentPhoneNumbers(value): string[]`, exact digits first
|
||||
- Consumed by: validation, Baileys lookup, verification cache, and opt-out helpers
|
||||
|
||||
- [ ] **Step 1: Write failing utility and opt-out tests**
|
||||
|
||||
```js
|
||||
assert.deepEqual(equivalentPhoneNumbers('5575999091780'), ['5575999091780', '557599091780']);
|
||||
assert.deepEqual(equivalentPhoneNumbers('557599091780'), ['557599091780', '5575999091780']);
|
||||
addOptOut(db, '5575999091780', 'solicitado');
|
||||
assert.equal(isOptedOut(db, '557599091780'), true);
|
||||
assert.equal(removeOptOut(db, '557599091780'), true);
|
||||
assert.equal(isOptedOut(db, '5575999091780'), false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify RED**
|
||||
|
||||
Run: `node --test tests/scoring.test.mjs tests/personal-backend.test.mjs`
|
||||
|
||||
Expected: FAIL because `equivalentPhoneNumbers` is absent and alternate opt-out variants are not blocked.
|
||||
|
||||
- [ ] **Step 3: Implement the shared utility and use it in opt-outs**
|
||||
|
||||
```js
|
||||
export function normalizePhone(value = '') {
|
||||
return String(value).replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function equivalentPhoneNumbers(value = '') {
|
||||
const digits = normalizePhone(value);
|
||||
const numbers = digits ? [digits] : [];
|
||||
if (!digits.startsWith('55')) return numbers;
|
||||
const prefix = digits.slice(0, 4);
|
||||
const local = digits.slice(4);
|
||||
if (local.length === 9 && local.startsWith('9')) numbers.push(`${prefix}${local.slice(1)}`);
|
||||
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
|
||||
return [...new Set(numbers)];
|
||||
}
|
||||
```
|
||||
|
||||
Replace the manager's private candidate builder and make `isOptedOut`, `addOptOut`, and `removeOptOut` query or delete every returned variant inside existing database transactions.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/scoring.test.mjs tests/personal-backend.test.mjs tests/whatsapp-manager.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Sparse Verification Cache and Derived Lead State
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/db.mjs`
|
||||
- Modify: `src/treatment/queries.mjs`
|
||||
- Test: `tests/db.test.mjs`
|
||||
- Test: `tests/personal-backend.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `saveWhatsAppVerification(db, result): object`
|
||||
- Produces: `getWhatsAppVerification(db, numero): object` with `status: 'nao_consultado'` when absent
|
||||
- Produces on lead rows: `whatsapp_status`, `whatsapp_numero_resolvido`, `whatsapp_checked_at`
|
||||
|
||||
- [ ] **Step 1: Write failing migration and helper tests**
|
||||
|
||||
```js
|
||||
saveWhatsAppVerification(db, {
|
||||
numero: '5575999091780', status: 'confirmado',
|
||||
numeroResolvido: '557599091780', variante: 'sem_nono_digito'
|
||||
});
|
||||
assert.equal(getWhatsAppVerification(db, '557599091780').status, 'confirmado');
|
||||
assert.equal(getWhatsAppVerification(db, '5511999990001').status, 'nao_consultado');
|
||||
```
|
||||
|
||||
Also insert a lead and assert `getLead` and `listLeads` expose the latest equivalent cached result.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify RED**
|
||||
|
||||
Run: `node --test tests/db.test.mjs tests/personal-backend.test.mjs`
|
||||
|
||||
Expected: FAIL because migration v14 and verification helpers do not exist.
|
||||
|
||||
- [ ] **Step 3: Add migration v14 and helper queries**
|
||||
|
||||
```sql
|
||||
CREATE TABLE whatsapp_number_verifications (
|
||||
numero TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL CHECK (status IN ('confirmado','nao_cadastrado','erro_temporario')),
|
||||
numero_resolvido TEXT,
|
||||
variante TEXT,
|
||||
erro TEXT,
|
||||
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_whatsapp_verification_status
|
||||
ON whatsapp_number_verifications(status, checked_at DESC);
|
||||
```
|
||||
|
||||
Use `equivalentPhoneNumbers(numero)` to read all variants and order by `checked_at DESC, rowid DESC`. Upsert only the requested normalized number. Extend lead list/detail projection without changing `telefone_valido`.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/db.test.mjs tests/personal-backend.test.mjs tests/dashboard.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Bounded Baileys Lookup and Send Operations
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/whatsapp/manager.mjs`
|
||||
- Test: `tests/whatsapp-manager.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- `createWhatsAppManager` adds `lookupTimeoutMs = 10000` and `messageTimeoutMs = 30000`
|
||||
- `checkNumber(number)` returns `numero`, `status`, `registered`, `resolvedNumber`, `variant`, and `candidatesChecked`
|
||||
- `sendText(...)` returns `messageId`, `resolvedNumber`, and `variant`
|
||||
- Lookup timeout error has `statusCode = 504`, `code = 'WHATSAPP_LOOKUP_TIMEOUT'`
|
||||
- Resolution failures expose requested-number metadata so routes can persist `nao_cadastrado` or `erro_temporario`
|
||||
- Send timeout error has `statusCode = 504`, `code = 'WHATSAPP_SEND_TIMEOUT'`, `deliveryUnknown = true`, and resolved-number metadata
|
||||
|
||||
- [ ] **Step 1: Write failing timeout and result-contract tests**
|
||||
|
||||
Use unresolved provider promises with 10 ms configured limits. Assert lookup rejects with `WHATSAPP_LOOKUP_TIMEOUT`; assert send reserves once, finalizes once, rejects with `deliveryUnknown`, invalidates the uncertain socket, and prevents the next queue item from using that socket.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify RED**
|
||||
|
||||
Run: `node --test tests/whatsapp-manager.test.mjs`
|
||||
|
||||
Expected: FAIL because provider calls currently wait forever and results omit resolved metadata.
|
||||
|
||||
- [ ] **Step 3: Implement a bounded provider helper**
|
||||
|
||||
```js
|
||||
function withTimeout(promise, timeoutMs, createError) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(createError()), timeoutMs);
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
```
|
||||
|
||||
Apply it separately to `socket.onWhatsApp` and `socket.sendMessage`. A send timeout must invalidate the uncertain socket and enter the existing reconnect lifecycle before the queue can use a provider again. Preserve the pending-send counter, persistence callbacks, ACK race handling, and no-automatic-retry behavior.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/whatsapp-manager.test.mjs`
|
||||
|
||||
Expected: PASS with no unhandled rejection or leaked timer warning.
|
||||
|
||||
### Task 4: Persist Checks and Honor the Visible Manual Destination
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/whatsapp.mjs`
|
||||
- Modify: `src/server/routes/outreach.mjs`
|
||||
- Modify: `src/db.mjs`
|
||||
- Test: `tests/whatsapp-routes.test.mjs`
|
||||
- Test: `tests/outreach-routes.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- `registerWhatsAppRoutes(router, { db, whatsapp, toDataURL })`
|
||||
- `finishOutreachSend(db, sendId, { messageId, error, unknown })`
|
||||
- Manual send response retains existing fields and may add resolved-number metadata
|
||||
|
||||
- [ ] **Step 1: Replace the incorrect route expectation with failing destination tests**
|
||||
|
||||
```js
|
||||
assert.equal(sent[0].number, '5511999990001');
|
||||
assert.equal(sent[0].message, 'Oi Empresa Real de Santos');
|
||||
assert.equal(db.prepare('SELECT lead_id FROM outreach_sends').get().lead_id, 1);
|
||||
```
|
||||
|
||||
Add route tests for confirmed, non-registered, and lookup-timeout cache writes. Add a send-timeout test asserting status `202`, `status: 'resultado_desconhecido'`, and database status `desconhecido`.
|
||||
|
||||
- [ ] **Step 2: Run route tests and verify RED**
|
||||
|
||||
Run: `node --test tests/whatsapp-routes.test.mjs tests/outreach-routes.test.mjs`
|
||||
|
||||
Expected: FAIL because the lead phone currently overrides `body.numero` and routes do not persist verification.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal route changes**
|
||||
|
||||
Set the destination with:
|
||||
|
||||
```js
|
||||
const rawNumber = String(body?.numero || '').trim();
|
||||
const context = lead || clientContext;
|
||||
const message = renderTemplate(messageTemplate, { ...context, whatsapp: number });
|
||||
```
|
||||
|
||||
Persist check outcomes through `saveWhatsAppVerification`. For a lookup timeout save `erro_temporario` before returning 504. For a send timeout finalize the reserved row with `unknown: true` and return the non-retry warning.
|
||||
|
||||
- [ ] **Step 4: Run route and integration tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/whatsapp-routes.test.mjs tests/outreach-routes.test.mjs tests/server-whatsapp-integration.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 5: Restrict Automatic Outreach to Confirmed Numbers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/outreach/run.mjs`
|
||||
- Test: `tests/outreach.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `whatsapp_number_verifications` and equivalent Brazilian variants
|
||||
- Produces: unchanged `runOutreachBatch` summary contract
|
||||
|
||||
- [ ] **Step 1: Write failing candidate-selection tests**
|
||||
|
||||
Insert otherwise identical leads in the four verification states. Assert only `confirmado` is passed to `sendText`. Add an alternate-variant confirmed record and assert it qualifies the stored lead number.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run: `node --test tests/outreach.test.mjs`
|
||||
|
||||
Expected: FAIL because current SQL selects every syntactically valid phone.
|
||||
|
||||
- [ ] **Step 3: Add confirmed-verification selection**
|
||||
|
||||
Select syntactically valid candidates, then retain only rows whose `getWhatsAppVerification(db, lead.whatsapp).status === 'confirmado'`. Keep the current daily limit, product dedupe, business hours, cancellation, and queue-time policy checks unchanged.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/outreach.test.mjs tests/scheduler.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 6: Correct UI Semantics, Polling, and Lead Context
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/index.html`
|
||||
- Modify: `public/js/app.js`
|
||||
- Modify: `public/js/tratamento.js`
|
||||
- Test: `tests/personal-branding.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- `window.MaquinaSelecionarLeadManual(lead)` remains available
|
||||
- Add `window.MaquinaLimparLeadManual()` or an equivalent bound clear button
|
||||
- Lead API fields consumed: `whatsapp_status`, `whatsapp_numero_resolvido`, `whatsapp_checked_at`
|
||||
|
||||
- [ ] **Step 1: Write failing static UI regressions**
|
||||
|
||||
Assert the HTML and scripts contain `Telefone: todos`, `Com telefone`, a separate WhatsApp status label, a clear-context control, and stable-state polling. Assert misleading strings such as `WhatsApp valido` are absent from `tratamento.js`.
|
||||
|
||||
- [ ] **Step 2: Run the UI test and verify RED**
|
||||
|
||||
Run: `node --test tests/personal-branding.test.mjs`
|
||||
|
||||
Expected: FAIL on current WhatsApp-as-phone labels and READY polling stop.
|
||||
|
||||
- [ ] **Step 3: Implement UI copy and behavior**
|
||||
|
||||
Use `telefone_valido` only for telephone labels and score text. Render WhatsApp verification independently from `lead.whatsapp_status`. Make `scheduleWhatsAppPolling` use approximately 1 second for transitional states and 5 seconds for stable states while the dialog remains open. The clear action sets `manualLeadContext = null` and rerenders context without clearing number or message.
|
||||
|
||||
- [ ] **Step 4: Run UI and treatment tests and verify GREEN**
|
||||
|
||||
Run: `node --test tests/personal-branding.test.mjs tests/dashboard.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 7: End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only; fix only regressions caused by Tasks 1-6
|
||||
|
||||
**Interfaces:**
|
||||
- No new interfaces
|
||||
|
||||
- [ ] **Step 1: Run the complete test suite**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: all tests pass with no warnings or unhandled rejections.
|
||||
|
||||
- [ ] **Step 2: Run the production dependency audit**
|
||||
|
||||
Run: `npm audit --omit=dev`
|
||||
|
||||
Expected: zero vulnerabilities.
|
||||
|
||||
- [ ] **Step 3: Restart and smoke-test the local server**
|
||||
|
||||
Verify `GET /api/whatsapp/status`, the settings dialog polling, lead selection, number editing, context clearing, and the check-number endpoint. Do not send an uncontrolled real message.
|
||||
|
||||
- [ ] **Step 4: Inspect the final diff**
|
||||
|
||||
Run: `git status --short` and `git diff --check`.
|
||||
|
||||
Expected: only intended project files changed and no whitespace errors. Do not revert unrelated existing changes.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Redesign visual: paleta SaaS dark (app inteiro) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Trocar a identidade visual inteira do app (login + 4 abas + modais) de uma paleta clara/laranja para uma paleta escura única estilo SaaS (Linear/Vercel), com o card de campanha ganhando um tratamento "Dispatch Board" (borda de status colorida + números tabulares monoespaçados), e o toast passando a indicar sucesso/erro por cor.
|
||||
|
||||
**Architecture:** `public/css/app.css` é quase inteiramente orientado por variáveis CSS (`:root`); a troca de paleta é uma substituição do bloco de tokens + remoção do bloco `@media (prefers-color-scheme: dark)` (o app deixa de ter modo claro). Três gradientes hardcoded (parceiros do laranja atual) viram um novo token `--accent-light`. Tratamento "Dispatch Board" é CSS adicional em `.campaign-card` e nos números de estatística. Toast colorido é uma pequena mudança em `public/js/api.js` (inferência pelo prefixo "Erro" na própria mensagem) + 2 classes CSS novas — sem tocar nos ~40 call-sites de `toast(...)` espalhados pelos arquivos JS.
|
||||
|
||||
**Tech Stack:** HTML/CSS/JS vanilla (sem framework de build), Node `--test`, Playwright.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Este repositório NÃO tem HEAD/commits e commits não são permitidos neste projeto — nenhuma etapa deste plano inclui `git commit`. Ignore os passos de commit do template padrão de planos.
|
||||
- Não há testes automatizados de aparência visual. A verificação "passou" de cada tarefa é: (a) suíte existente 100% verde, (b) checagem visual ao vivo no navegador via Claude Browser.
|
||||
- Servidor de dev roda em `http://127.0.0.1:3910` via `node scripts/servidor.mjs`, já ativo nesta sessão — arquivos estáticos (`public/**`) são servidos direto do disco, um `navigate` com `force: true` já pega mudanças de CSS/JS sem precisar reiniciar o processo Node. Só reiniciar o processo Node se mudar algo em `src/**`.
|
||||
- Design aprovado está em `docs/superpowers/specs/2026-07-30-dark-saas-redesign-design.md` — os valores exatos de cor vêm de lá, não reinvente.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Trocar os tokens de cor em `app.css` para a paleta SaaS dark única
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/css/app.css:1-47` (bloco `:root` e `@media (prefers-color-scheme: dark)`)
|
||||
- Modify: `public/css/app.css:80` (`.brand-monogram`)
|
||||
- Modify: `public/css/app.css:189` (`.btn-primary`)
|
||||
- Modify: `public/css/app.css:520` (`.progress-fill`)
|
||||
- Modify: `public/css/app.css:587` (`.rank-fill, .funil-fill`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: todo o resto do CSS (que já usa `var(--x)` em quase tudo) herda a nova paleta automaticamente. Nenhuma outra tarefa precisa saber os valores exatos — só que os tokens `--page`, `--surface`, `--ink`, `--accent`, `--good`, `--warning`, `--critical`, `--radius*`, `--shadow*` continuam existindo com os mesmos nomes.
|
||||
|
||||
- [ ] **Step 1: Substituir o bloco `:root` (linhas 2-26) por estes valores**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--page: #0b0c10;
|
||||
--page-2: #0d0e13;
|
||||
--surface: #16171d;
|
||||
--surface-2: #1d1f27;
|
||||
--ink: #f4f4f6;
|
||||
--ink-2: #a4a6b0;
|
||||
--muted: #6c6e79;
|
||||
--grid: #2a2c35;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #6a63f6;
|
||||
--accent-light: #9d97ff;
|
||||
--accent-2: #4f8cff;
|
||||
--accent-ink: #ffffff;
|
||||
--bar: #6a63f6;
|
||||
--good: #22c55e;
|
||||
--good-text: #4ade80;
|
||||
--warning: #f5a524;
|
||||
--critical: #fb5b7c;
|
||||
--critical-bg: rgba(251, 91, 124, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apagar inteiramente o bloco `@media (prefers-color-scheme: dark) { ... }` que vem logo depois (linhas 28-47 do arquivo original)**
|
||||
|
||||
O app passa a ter uma única aparência, sempre escura — não há mais um segundo bloco de tokens para modo claro/escuro.
|
||||
|
||||
- [ ] **Step 3: Trocar as 3 cores hardcoded que combinavam com o laranja antigo pelo novo `--accent-light`**
|
||||
|
||||
Em `.brand-monogram`:
|
||||
```css
|
||||
.brand-monogram { background: linear-gradient(135deg, var(--accent), var(--accent-light)); color: #fff; font-weight: 900; letter-spacing: -0.06em; box-shadow: var(--shadow-soft); }
|
||||
```
|
||||
|
||||
Em `.btn-primary`:
|
||||
```css
|
||||
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent-light)); border-color: transparent; color: var(--accent-ink); }
|
||||
```
|
||||
|
||||
Em `.progress-fill`:
|
||||
```css
|
||||
.progress-fill { height: 100%; width: 3%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 999px; transition: width 0.4s; }
|
||||
```
|
||||
|
||||
Em `.rank-fill, .funil-fill`:
|
||||
```css
|
||||
.rank-fill, .funil-fill { height: 100%; min-width: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--bar), var(--accent-light)); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rodar a suíte de testes existente para confirmar que nada de lógica quebrou**
|
||||
|
||||
Run: `cd "D:/PROJETOS/COMERCIAL GTI/MAQUINA COMERCIAL PESSOAL" && node --test`
|
||||
Expected: todos os testes continuam passando (a suíte não valida cor/CSS, só comportamento — uma troca de paleta não deveria derrubar nenhum). Preste atenção especial ao teste `interface e arquivos operacionais usam somente a identidade pessoal` (`tests/personal-branding.test.mjs`) — ele lê `public/css/app.css` procurando strings de marca "Grupo GTI"; os valores hex novos não contêm essas strings, então deve continuar passando.
|
||||
|
||||
- [ ] **Step 5: Checagem visual ao vivo — página de login**
|
||||
|
||||
Usando o Claude Browser: `navigate` para `http://127.0.0.1:3910` com `force: true`, tirar um `read_page`/`get_page_text` e confirmar visualmente (screenshot, se a pane permitir) que o fundo está escuro (`#0b0c10`-ish), o card de login com fundo `#16171d`-ish, texto claro legível, sem nenhum resquício de fundo claro/branco aparecendo.
|
||||
|
||||
- [ ] **Step 6: Checagem visual ao vivo — aba Capturar Leads (já vem ativa por padrão) e aba Tratar Leads**
|
||||
|
||||
Clicar na aba "Tratar Leads", conferir que os `stat-tiles`, tabela de leads, badges de classificação (excelente/bom/médio/baixo) e gráficos de ranking estão legíveis e com contraste adequado no fundo escuro novo. Não deve haver texto escuro sobre fundo escuro (herança de alguma cor que não passou a usar variável).
|
||||
|
||||
Não há commit nesta etapa (projeto sem git commits).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Tratamento "Dispatch Board" nos cards de campanha e números de estatística
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/css/app.css` (seção `/* campanhas */`, a partir de `.campaign-cards`)
|
||||
- Modify: `public/css/app.css:522-525` (`.stat-row .mini b`)
|
||||
- Modify: `public/css/app.css:562` (`.tile .valor`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: tokens `--muted`, `--critical`, `--good`, `--radius`, `--border` de Task 1.
|
||||
- Produces: nada que outra tarefa dependa — mudança visual isolada.
|
||||
|
||||
- [ ] **Step 1: Adicionar borda de status colorida à esquerda do card de campanha**
|
||||
|
||||
Na seção `/* campanhas */` de `app.css`, depois da regra `.campaign-card { margin-bottom: 0; }`, adicionar:
|
||||
|
||||
```css
|
||||
.campaign-card {
|
||||
border-left: 3px solid var(--muted);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.campaign-card:has(.chip.warn) { border-left-color: var(--critical); }
|
||||
.campaign-card:has(.chip.ok) { border-left-color: var(--good); }
|
||||
```
|
||||
|
||||
(`:has()` funciona nos navegadores modernos — Chrome/Edge 105+, Firefox 121+ — e o card já usa `.chip.warn` para pausada e `.chip.ok` para ativa, ver `public/js/campanhas.js` função `renderCard`, então não precisa mexer no JS.)
|
||||
|
||||
- [ ] **Step 2: Números tabulares monoespaçados nos contadores de uso/limite e nas estatísticas**
|
||||
|
||||
```css
|
||||
.campaign-usage span,
|
||||
.stat-row .mini b,
|
||||
.tile .valor {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rodar a suíte de testes existente**
|
||||
|
||||
Run: `node --test`
|
||||
Expected: todos os testes continuam passando (mudança é puramente visual/CSS, nenhuma classe foi removida, `.chip.warn`/`.chip.ok` continuam existindo do mesmo jeito).
|
||||
|
||||
- [ ] **Step 4: Checagem visual ao vivo — aba Campanhas**
|
||||
|
||||
Navegar para a aba Campanhas, conferir visualmente: cada card de campanha tem uma borda colorida na lateral esquerda (cinza-neutro para pronta, vermelha para pausada, verde para ativa/rodando); os números de "Manual: X/Y" e "Automático: X/Y" e o bloco de métricas (Visualizados/Aprovados/Enviados/Responderam, visível clicando "Ver fila" numa campanha) estão em fonte monoespaçada tabular. Confirmar que o texto "Pausado: ..." e os botões continuam legíveis e clicáveis.
|
||||
|
||||
Não há commit nesta etapa.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Toast colorido por sucesso/erro
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/js/api.js:82-94` (função `toast`)
|
||||
- Modify: `public/css/app.css:682-696` (`.toast`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `document.getElementById('toast')` (já existe em `index.html`/`login.html`, id `toast`).
|
||||
- Produces: `toast(message, ms)` continua com a mesma assinatura pública — nenhum call-site em `app.js`/`tratamento.js`/`captura.js`/`campanhas.js` precisa mudar.
|
||||
|
||||
**Por que não tem um teste automatizado nesta tarefa:** `tests/campaign-ui.test.mjs` roda `app.js`/`campanhas.js` dentro de um `vm.createContext` cujo objeto de contexto já define `API`, `el`, `h`, `dataBr`, `fmt` como mocks manuais (não carregados de `api.js`). Se `public/js/api.js` fosse executado nesse mesmo contexto via `vm.runInContext` para testar o `toast()` real, o `const API = {...}` de dentro de `api.js` sombrearia/substituiria o `API` mockado que os outros ~15 testes desse arquivo dependem para interceptar chamadas de rede — quebrando a suíte inteira. Isolar só a função `toast()` do resto de `api.js` exigiria transformar o arquivo num módulo ES (`export`), o que está fora do escopo desta tarefa (o frontend inteiro usa `<script>` simples, sem `import`/`export`). Por isso a verificação aqui é só a checagem visual ao vivo do Step 3, como o próprio spec já previa.
|
||||
|
||||
- [ ] **Step 1: Implementar a inferência de tipo em `toast()`**
|
||||
|
||||
Em `public/js/api.js`, substituir a função `toast` inteira:
|
||||
|
||||
```javascript
|
||||
function toast(message, ms = 3500) {
|
||||
const box = el('toast');
|
||||
box.textContent = message;
|
||||
box.classList.remove('hidden', 'erro', 'sucesso');
|
||||
box.classList.add(/^erro/i.test(message) ? 'erro' : 'sucesso');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar a suíte inteira, confirmando que a mudança em `api.js` não quebrou nada**
|
||||
|
||||
Run: `cd "D:/PROJETOS/COMERCIAL GTI/MAQUINA COMERCIAL PESSOAL" && node --test`
|
||||
Expected: todos os testes continuam passando — `api.js` não é carregado pelo harness de teste (ver nota acima), então essa mudança não deveria afetar nenhum teste existente; isso só confirma que nada mais no repo depende do comportamento antigo de `toast()`.
|
||||
|
||||
- [ ] **Step 3: Estilizar as duas variantes no CSS**
|
||||
|
||||
Em `app.css`, na regra `.toast { ... }`, adicionar logo depois:
|
||||
|
||||
```css
|
||||
.toast.sucesso { background: var(--surface-2); color: var(--good-text); border: 1px solid color-mix(in srgb, var(--good) 45%, transparent); }
|
||||
.toast.erro { background: var(--surface-2); color: var(--critical); border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent); }
|
||||
```
|
||||
|
||||
(A regra base `.toast { background: var(--ink); color: var(--page); }` continua existindo em `app.css` — essas duas classes sobrescrevem `background`/`color` especificamente para os dois estados coloridos; sem `.sucesso`/`.erro`, nunca deve sobrar um toast sem uma das duas, já que `toast()` sempre aplica uma.)
|
||||
|
||||
- [ ] **Step 4: Checagem visual ao vivo de um toast de cada tipo**
|
||||
|
||||
No navegador (Claude Browser), disparar uma ação que gera um toast de sucesso real (ex.: clicar em "Preparar fila" numa campanha) e conferir visualmente que o toast aparece esverdeado; depois provocar um erro real (ex.: tentar uma ação inválida) e conferir que fica avermelhado. Usar `document.getElementById('toast').className` via `javascript_tool` pra confirmar a classe aplicada sem depender só do visual.
|
||||
|
||||
Não há commit nesta etapa.
|
||||
|
||||
---
|
||||
|
||||
## Verificação final (depois das 3 tarefas)
|
||||
|
||||
- [ ] Rodar `node --test` uma última vez — suíte completa 100% verde.
|
||||
- [ ] Rodar `npx playwright test tests/browser/campaign-pilot.spec.mjs --reporter=line` — 42/42.
|
||||
- [ ] Passar pelas 4 abas + modal de Configurações no navegador real, conferindo que não sobrou nenhum resquício de fundo/texto claro (herança de alguma regra que não usava variável).
|
||||
- [ ] Confirmar que as campanhas reais do usuário continuam com o mesmo `status`/`pause_reason` de antes desta sessão (nenhuma ação de estado foi tocada, só CSS/JS de apresentação) — checar via `fetch('/api/campaigns')`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
# Commercial Outreach Pilot Design
|
||||
|
||||
## Goal
|
||||
|
||||
Create a supervised WhatsApp prospecting operation for Gustavo, commercial representative of Grupo GTI, focused on qualified conversations and demonstrations for GTI Vendas and G-Delivery without treating volume as the primary objective.
|
||||
|
||||
The pilot must preserve the operator's control, identify replies before any further automation, honor opt-outs, avoid unverified numbers, and produce enough evidence to improve targeting and copy.
|
||||
|
||||
## Current Context
|
||||
|
||||
- The lead base contains 2,680 companies in Santo Antonio de Jesus, Bahia.
|
||||
- The current database groups 2,066 leads under ERP or management interest and 614 under WhatsApp self-service interest.
|
||||
- The current automation is disabled.
|
||||
- The current generic template, `Meu servico`, does not represent either approved product.
|
||||
- The existing `5-12` second interval is not suitable for this pilot and must not be reused.
|
||||
- The WhatsApp number is more than two years old, has normal personal-use history, has never been restricted, and will become exclusively commercial.
|
||||
- The number will remain on regular WhatsApp during the pilot. A future migration to WhatsApp Business or an official integration remains an operational option.
|
||||
- Gustavo is authorized to use his number and prospect for both products.
|
||||
- Grupo GTI handles demonstrations, implementation, support, and closing after qualification.
|
||||
|
||||
## Commercial Identity
|
||||
|
||||
- Sender: `Gustavo - Representante Comercial do Grupo GTI`.
|
||||
- Company: `Grupo GTI`.
|
||||
- ERP product: `GTI Vendas`.
|
||||
- Online-order product: `G-Delivery`.
|
||||
- Authorized social proof: Grupo GTI has more than seven years of operation and more than 100 clients in Santo Antonio de Jesus and the surrounding region.
|
||||
- Prices, implementation fees, links, and detailed conditions must not appear in the first message.
|
||||
- The message must not promise unavailable functionality, unapproved integrations or customizations, technical deadlines, or guaranteed commercial results.
|
||||
|
||||
## Product Positioning
|
||||
|
||||
### GTI Vendas
|
||||
|
||||
GTI Vendas centralizes sales, inventory, invoicing, tabs or orders, finance, and administrative processes. It is sold through a personalized presentation because configuration depends on the client's operation.
|
||||
|
||||
Primary segments:
|
||||
|
||||
- Restaurants.
|
||||
- Markets, grocery stores, and minimarkets.
|
||||
- Bakeries and convenience stores.
|
||||
- Clothing stores.
|
||||
- Building-material stores.
|
||||
- Auto-parts stores and mechanical workshops.
|
||||
- Other commerce requiring integrated management.
|
||||
|
||||
Commercial path:
|
||||
|
||||
1. Ask one segment-specific operational question.
|
||||
2. Continue by text if the lead replies.
|
||||
3. Identify interest without performing a complete technical diagnosis.
|
||||
4. Collect participant name, company, preferred day, and preferred time.
|
||||
5. Hand the scheduled demonstration to the Grupo GTI team in the office.
|
||||
|
||||
### G-Delivery
|
||||
|
||||
G-Delivery gives a food business its own order link, with a fixed monthly fee and no fee per order. The client may self-register and test the product for seven days or request a demonstration.
|
||||
|
||||
Primary segments:
|
||||
|
||||
- Hamburger restaurants.
|
||||
- Pizzerias.
|
||||
- Acai shops.
|
||||
- Meal-prep and lunch-box businesses.
|
||||
- Snack bars.
|
||||
- Other businesses clearly focused on meal delivery.
|
||||
|
||||
Commercial path:
|
||||
|
||||
1. Ask whether the business has its own order channel or depends on commission-charging applications.
|
||||
2. Continue by text if the lead replies.
|
||||
3. Offer either a seven-day self-service test or a demonstration.
|
||||
4. For a demonstration, collect participant name, company, preferred day, and preferred time.
|
||||
5. Hand the scheduled demonstration to the Grupo GTI team in the office.
|
||||
|
||||
## Campaign Priority
|
||||
|
||||
Campaigns run sequentially so that early evidence can improve later campaigns:
|
||||
|
||||
1. G-Delivery for hamburger restaurants, pizzerias, acai shops, meal-prep businesses, and snack bars.
|
||||
2. GTI Vendas for restaurants, markets, grocery stores, minimarkets, bakeries, and convenience stores.
|
||||
3. GTI Vendas for clothing, building materials, auto parts, mechanical workshops, and remaining eligible commerce.
|
||||
|
||||
Overlapping food businesses receive the first offer selected by this order. A second product is introduced only when the conversation reveals a relevant need; the pilot does not automatically send a second product message.
|
||||
|
||||
## Lead Eligibility
|
||||
|
||||
A lead may enter a daily queue only when all conditions are true:
|
||||
|
||||
- It belongs to the campaign's approved segments.
|
||||
- It has never received an initial campaign message for that product.
|
||||
- Its latest equivalent-number verification state is `confirmado`.
|
||||
- It is not opted out under either Brazilian ninth-digit variant.
|
||||
- It has not replied to a previous commercial message.
|
||||
- It has no pending, accepted, sent, delivered, read, or unknown campaign send for the same product.
|
||||
- It has not been manually removed from the daily preview.
|
||||
|
||||
Phone verification is gradual. Per operating day, the system may check at most `ceil(1.5 * the approved daily send limit)` previously unverified numbers. It never scans the entire lead base in one operation. If that cap does not produce enough confirmed numbers, the daily queue remains below its send limit.
|
||||
|
||||
No initial customer-exclusion list is required. Existing clients may be contacted, but any reply immediately removes them from automation and allows manual classification.
|
||||
|
||||
## Personalization
|
||||
|
||||
- Use the trade name only when present and natural.
|
||||
- Do not use long legal names, CNPJ values, or business names dominated by numbers.
|
||||
- Without a reliable trade name, begin with `Ola, tudo bem?`.
|
||||
- Do not claim that the business uses delivery, marketplaces, tabs, invoicing, or any specific process. Ask instead.
|
||||
- Do not mention the RFB source or imply prior personal knowledge.
|
||||
- Message variants are fixed and auditable. No generative AI writes outbound messages during execution.
|
||||
- Rendering may replace the opening greeting with `Ola, pessoal da {nome_fantasia}, tudo bem?` when the trade name passes the natural-name rule. Otherwise it preserves the generic greeting. The rest of the approved variant remains unchanged.
|
||||
|
||||
Approved `foco_gestao` mappings:
|
||||
|
||||
- Restaurants: `vendas, estoque, comandas e emissao fiscal`.
|
||||
- Markets and grocery commerce: `vendas, estoque e emissao fiscal`.
|
||||
- Mechanical workshops: `ordens de servico, estoque e financeiro`.
|
||||
- Other commerce: `vendas, estoque, financeiro e notas fiscais`.
|
||||
|
||||
## Approved Initial Messages
|
||||
|
||||
Every message must include the simple refusal sentence. Diacritics may be used in the final UI text; the examples below define meaning and structure.
|
||||
|
||||
### GTI Vendas - Variant A
|
||||
|
||||
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
### GTI Vendas - Variant B
|
||||
|
||||
> Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
### GTI Vendas - Variant C
|
||||
|
||||
> Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
### G-Delivery - Variant A
|
||||
|
||||
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
### G-Delivery - Variant B
|
||||
|
||||
> Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
### G-Delivery - Variant C
|
||||
|
||||
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
|
||||
|
||||
The queue records the exact variant and fully rendered text before approval. Retries never choose a different variant.
|
||||
|
||||
## Cadence
|
||||
|
||||
The pilot has no follow-up. Each eligible lead receives at most one initial message for the selected product.
|
||||
|
||||
Operating windows:
|
||||
|
||||
- Monday through Friday, `09:30-11:30` and `14:00-17:00` local time.
|
||||
- No weekend sends.
|
||||
- Public holidays are handled by the supervisor pausing or not approving that day's queue; automatic holiday-calendar integration is outside the pilot.
|
||||
|
||||
Daily progression:
|
||||
|
||||
- Week 1: up to 10 messages per operating day.
|
||||
- Week 2: up to 12 messages per operating day.
|
||||
- Week 3: up to 15 messages per operating day.
|
||||
- Week 4: up to 18 messages per operating day.
|
||||
|
||||
The supervised campaign quota and the application's manual-send quota are separate. A manual test or individual send does not reduce the automatic campaign balance, and the UI displays both totals independently.
|
||||
|
||||
Timing rules:
|
||||
|
||||
- Random interval from 12 to 25 minutes between attempts.
|
||||
- At most four send attempts in any rolling hour.
|
||||
- Sends are distributed across both windows rather than exhausting the quota at the start of the day.
|
||||
- A weekly increase is never automatic. Gustavo reviews metrics and explicitly approves the next limit.
|
||||
- The number's age, daily volume, and delay do not guarantee protection against restriction.
|
||||
|
||||
## Supervised Daily Operation
|
||||
|
||||
1. The application selects never-contacted leads from the active campaign.
|
||||
2. It gradually verifies unverified phone numbers up to the daily `1.5 * send limit` cap.
|
||||
3. It removes unregistered numbers, opt-outs, replied leads, duplicates, and prior sends.
|
||||
4. It creates a daily preview containing business display name, phone, segment, product, variant, rendered message, scheduled window, and selection reason.
|
||||
5. Gustavo approves the queue, removes individual entries, or pauses the campaign.
|
||||
6. Approved entries receive immutable scheduled timestamps distributed under the cadence rules.
|
||||
7. The worker reserves each send atomically immediately before provider use.
|
||||
8. Replies, opt-outs, connection failures, and uncertain sends take precedence over scheduled work.
|
||||
9. The end-of-day summary records outcomes and unresolved items.
|
||||
|
||||
Automation starts disabled after every server restart. Starting or resuming a day requires explicit operator action during the pilot.
|
||||
|
||||
## Reply Handling
|
||||
|
||||
Inbound detection uses a hybrid model:
|
||||
|
||||
- Any inbound message immediately pauses that lead and removes scheduled work.
|
||||
- The application places the conversation in `Respostas pendentes`.
|
||||
- It does not generate or send an automatic commercial reply.
|
||||
- Gustavo reads the conversation on the phone and manually classifies it.
|
||||
|
||||
Pipeline stages:
|
||||
|
||||
- `fila_preparada`
|
||||
- `aprovado_para_envio`
|
||||
- `enviado`
|
||||
- `respondeu`
|
||||
- `interessado_gti_vendas`
|
||||
- `interessado_g_delivery`
|
||||
- `demonstracao_agendada`
|
||||
- `teste_g_delivery_iniciado`
|
||||
- `sem_interesse`
|
||||
- `opt_out`
|
||||
- `venda_fechada`
|
||||
|
||||
Explicit requests to stop are acknowledged respectfully and added to permanent opt-out. Ambiguous replies only pause the lead for manual review.
|
||||
|
||||
## Manual Conversation Guidance
|
||||
|
||||
### Positive GTI Vendas Reply
|
||||
|
||||
Continue by text, acknowledge the response, ask at most one brief question if necessary, and offer a personalized presentation. Once accepted, collect participant name, company, preferred day, and preferred time.
|
||||
|
||||
### Positive G-Delivery Reply
|
||||
|
||||
Continue by text, confirm how orders are received, and offer either the seven-day self-registration test or a demonstration. For a demonstration, collect participant name, company, preferred day, and preferred time.
|
||||
|
||||
### No Interest
|
||||
|
||||
Thank the person, confirm that no further contact will be made, and classify the conversation as `sem_interesse` plus permanent opt-out when the reply requests no more contact.
|
||||
|
||||
## Automatic Pauses
|
||||
|
||||
Pause the campaign when any condition occurs:
|
||||
|
||||
- WhatsApp disconnects, enters an error state, or loses its session.
|
||||
- A send has an unknown outcome.
|
||||
- Daily provider/send failures exceed 10 percent after at least 10 attempts.
|
||||
- Negative or opt-out responses exceed 10 percent after at least 20 contacts.
|
||||
- Overall response remains below 5 percent after at least 50 contacts.
|
||||
- The operator presses the immediate-pause control.
|
||||
|
||||
An automatic pause never resumes itself. Gustavo reviews the reason and explicitly resumes or ends the campaign.
|
||||
|
||||
## Metrics
|
||||
|
||||
Metrics are segmented by campaign, product, segment, and exact message variant:
|
||||
|
||||
- Previewed, approved, removed, and attempted messages.
|
||||
- Accepted, server-acknowledged, delivered, read, unknown, and failed sends.
|
||||
- Replies and time to first reply.
|
||||
- Positive replies.
|
||||
- No-interest replies and opt-outs.
|
||||
- GTI Vendas demonstrations scheduled.
|
||||
- G-Delivery demonstrations and seven-day tests started.
|
||||
- Sales closed.
|
||||
|
||||
Derived rates:
|
||||
|
||||
- Reply rate.
|
||||
- Positive-reply rate.
|
||||
- Reply-to-demonstration or test conversion.
|
||||
- Demonstration/test-to-sale conversion.
|
||||
- Opt-out/negative rate.
|
||||
- Delivery failure and unknown-outcome rates.
|
||||
|
||||
The dashboard may display small samples but must not declare a winning variant until that exact product/segment/variant group has at least 40 sends. The first month is a learning pilot; no artificial sales target is imposed.
|
||||
|
||||
## Data Model Boundaries
|
||||
|
||||
The implementation should add focused records for:
|
||||
|
||||
- Campaign definitions and current approved weekly limit.
|
||||
- Fixed campaign message variants.
|
||||
- Daily queue items with immutable rendered message and scheduled timestamp.
|
||||
- Campaign status and pause reason.
|
||||
- Inbound reply detection and first-reply timestamp.
|
||||
- Manual pipeline stage and stage-event history.
|
||||
- Demonstration/test scheduling notes.
|
||||
|
||||
Existing lead source data, WhatsApp verification cache, opt-outs, provider delivery history, and message ACK timestamps remain authoritative in their current tables. The campaign layer references them rather than duplicating their meaning.
|
||||
|
||||
## Reliability And Error Handling
|
||||
|
||||
- Queue creation and send reservation are transactional.
|
||||
- A lead cannot have two active queue entries for the same campaign/product.
|
||||
- A scheduled entry rechecks reply, opt-out, verification, connection, daily limit, hourly limit, and campaign status immediately before sending.
|
||||
- Unknown delivery remains terminal and is never retried automatically.
|
||||
- Server restart recovers in-progress queue items without duplicating sends.
|
||||
- An inbound message that races with a scheduled send wins when detected before provider invocation.
|
||||
- A connection or database error produces a visible pause reason and operator action, not a silent retry loop.
|
||||
- The application stores no generated free-form outbound copy beyond the approved rendered message.
|
||||
|
||||
## Compliance And Risk
|
||||
|
||||
- Every first message identifies Gustavo and Grupo GTI.
|
||||
- Every first message includes a simple refusal mechanism.
|
||||
- Equivalent Brazilian phone variants share opt-out identity.
|
||||
- Contact history is retained only as needed for deduplication, audit, and commercial metrics.
|
||||
- No message includes source-database details or unnecessary personal data.
|
||||
- Cold outreach and device-linked automation retain restriction and policy risk. Volume controls reduce operational exposure but cannot eliminate it.
|
||||
- A migration to WhatsApp Business or an official messaging integration may be evaluated after pilot evidence.
|
||||
|
||||
## Testing And Acceptance
|
||||
|
||||
Automated tests must cover:
|
||||
|
||||
- Product/segment campaign selection and overlap priority.
|
||||
- Natural trade-name fallback and safe message rendering.
|
||||
- All six fixed message variants and prohibited-content checks.
|
||||
- Gradual verification without full-base scanning.
|
||||
- Daily progression, two windows, 12-25 minute delays, and rolling four-per-hour limit.
|
||||
- Preview approval, item removal, immutable variants, and explicit weekly increase.
|
||||
- Atomic reservation and duplicate prevention.
|
||||
- Reply race, immediate lead pause, and pending-response queue.
|
||||
- Permanent equivalent-number opt-out.
|
||||
- Disconnect, session error, unknown outcome, failure-rate, negative-rate, low-response, and manual pauses.
|
||||
- Restart recovery without resend.
|
||||
- Pipeline stages, stage history, demonstration/test notes, and metrics.
|
||||
- No follow-up generation during the pilot.
|
||||
- Browser-level supervised daily flow on desktop and mobile.
|
||||
|
||||
No automated or browser test sends a real message unless Gustavo explicitly authorizes that individual recipient and test.
|
||||
@@ -0,0 +1,100 @@
|
||||
# WhatsApp Verification and Manual Send Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make the visible manual phone field the authoritative send destination and distinguish a syntactically valid telephone from a number confirmed by WhatsApp.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Work only in `MAQUINA COMERCIAL PESSOAL`.
|
||||
- Keep the RFB telephone value as source data; do not claim it is a WhatsApp account without a real lookup.
|
||||
- Verify numbers on demand. Do not scan the full RFB lead base.
|
||||
- Automatic outreach may use only numbers with a persisted successful verification.
|
||||
- Brazilian mobile variants with and without the ninth digit represent the same opt-out identity.
|
||||
- A send timeout is an unknown delivery outcome, not proof of failure.
|
||||
|
||||
## Data Model
|
||||
|
||||
Add a `whatsapp_number_verifications` cache keyed by the normalized requested phone number. Each record stores:
|
||||
|
||||
- `numero`: digits submitted for verification.
|
||||
- `status`: `confirmado`, `nao_cadastrado`, or `erro_temporario`.
|
||||
- `numero_resolvido`: number returned in the registered WhatsApp JID, when confirmed.
|
||||
- `variante`: `exata`, `sem_nono_digito`, or `com_nono_digito`, when confirmed.
|
||||
- `erro`: temporary lookup error text, otherwise empty.
|
||||
- `checked_at`: lookup timestamp.
|
||||
|
||||
No row for any equivalent variant means `nao_consultado`. Lead queries inspect all equivalent variants and use the most recent cached result. This sparse cache avoids updating every lead and allows repeated or equivalent lead phone values to reuse evidence.
|
||||
|
||||
`telefone_valido` keeps its existing meaning: the value has a valid Brazilian DDI, DDD, and phone shape. It must not be renamed in storage or treated as WhatsApp verification.
|
||||
|
||||
## Number Identity
|
||||
|
||||
Move Brazilian variant generation to a provider-independent phone utility shared by WhatsApp resolution and opt-out persistence. For a valid Brazilian mobile number, the utility returns the exact number first and then the equivalent with or without the ninth digit.
|
||||
|
||||
Opt-out lookup checks all equivalent variants. Adding an opt-out removes conflicting equivalent records and stores one normalized representation. Removing an opt-out removes all equivalent records. This closes both route-time and queue-time bypasses.
|
||||
|
||||
## Manual Send Flow
|
||||
|
||||
1. The browser submits `numero`, `mensagem`, and optional `leadId`.
|
||||
2. The server validates that the optional lead exists, but uses it only for template context, product metadata, and send history linkage.
|
||||
3. The server always validates and sends to `body.numero`. It never substitutes `lead.whatsapp`.
|
||||
4. WhatsApp resolution checks the exact number and its Brazilian equivalent under a bounded timeout.
|
||||
5. A successful lookup or definite non-registration updates the verification cache.
|
||||
6. If the submitted number is equivalent to the selected lead's stored phone, the cached result describes that lead phone. An unrelated edited destination remains a separate phone-level result and does not relabel the stored lead phone.
|
||||
7. The send reservation rechecks opt-out and daily limit inside the serialized send queue.
|
||||
8. A successful `sendMessage` returns the provider message ID and resolved destination for normal ACK tracking.
|
||||
|
||||
The visible destination is therefore authoritative even while the selected lead continues to provide variables such as company and city.
|
||||
|
||||
## Automatic Outreach Flow
|
||||
|
||||
Automatic candidate selection matches each stored lead number against all equivalent cached variants and includes it only when the most recent matching state is `confirmado`. Syntactic `telefone_valido = 1` remains required but is not sufficient.
|
||||
|
||||
Numbers in `nao_consultado`, `nao_cadastrado`, or `erro_temporario` are skipped. The batch does not perform implicit bulk verification.
|
||||
|
||||
## Timeouts and Errors
|
||||
|
||||
- `onWhatsApp` has a configurable bounded timeout. Timeout produces `erro_temporario`, HTTP 504 for an explicit check, and no definitive registration claim.
|
||||
- `sendMessage` has a separate configurable bounded timeout.
|
||||
- When `sendMessage` times out after the send reservation, the local send becomes `desconhecido`. The API returns an accepted-style warning that the message may have been sent and instructs the operator not to resend without checking the phone.
|
||||
- A definite non-registration returns HTTP 422 and stores `nao_cadastrado`.
|
||||
- Other pre-send provider failures remain errors and are persisted through the existing send finalization path.
|
||||
|
||||
The underlying provider promise is observed after a timeout to avoid unhandled rejection, but a late result does not trigger an automatic retry.
|
||||
|
||||
## User Interface
|
||||
|
||||
- Replace labels that infer WhatsApp from `telefone_valido` with `Telefone valido`, `Com telefone`, or `Sem telefone`.
|
||||
- Show a separate WhatsApp state in lead details: `Nao consultado`, `Confirmado`, `Nao cadastrado`, or `Erro temporario`.
|
||||
- The treatment table phone column is labeled `Telefone`.
|
||||
- The search placeholder refers to telephone rather than WhatsApp.
|
||||
- Selecting a lead fills context and its phone, but later edits remain authoritative.
|
||||
- Add a visible action to clear selected lead context without clearing the typed destination or message.
|
||||
- While the settings dialog is open, connection polling continues in every state. Transitional states poll faster than stable states.
|
||||
|
||||
## API Behavior
|
||||
|
||||
`POST /api/whatsapp/check-number` accepts `numero` and optional `leadId`. It returns the persisted state, resolved number, variant, and timestamp. A lead ID is validation/context only; the checked number always comes from `numero`.
|
||||
|
||||
Lead list and detail responses expose a derived `whatsapp_status`, `whatsapp_numero_resolvido`, and `whatsapp_checked_at` from the cache for the lead's stored phone.
|
||||
|
||||
Existing outreach status and ACK APIs remain compatible.
|
||||
|
||||
## Testing
|
||||
|
||||
Automated regressions must prove:
|
||||
|
||||
- An edited manual number wins over the selected lead's stored phone.
|
||||
- The selected lead still supplies template variables and `lead_id` history.
|
||||
- Missing or invalid leads and destinations retain explicit errors.
|
||||
- Verification persists confirmed, non-registered, and temporary-error states.
|
||||
- An unrelated edited destination does not alter the selected lead's derived status.
|
||||
- Automatic outreach selects only confirmed numbers.
|
||||
- Either Brazilian variant triggers the same opt-out in manual and queued sends.
|
||||
- Lookup timeout returns a temporary result without sending.
|
||||
- Send timeout records an unknown outcome and warns against retry.
|
||||
- UI copy no longer represents syntactic phone validity as WhatsApp confirmation.
|
||||
- Polling continues after `READY` and selected lead context can be cleared.
|
||||
|
||||
Run focused tests after each change, then the complete Node test suite and dependency audit. Perform a browser-level manual send-flow check without sending an uncontrolled real message.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Redesign visual: paleta SaaS dark (app inteiro)
|
||||
|
||||
## Contexto
|
||||
|
||||
Depois de uma sessão extensa corrigindo bugs funcionais da aba Campanhas, o usuário pediu para melhorar o frontend: visual/design + UX de feedback, aplicado ao app inteiro (não só Campanhas). Foram apresentadas 3 direções visuais via mockup (Artifact) com o card de campanha real: Dispatch Board (painel escuro técnico), Route Card (papel/serifada, verde-pinho), Night Shift (roxo-ameixa/dourado). O usuário escolheu Dispatch Board, depois pediu para trocar a paleta para um estilo "SaaS dark" (tipo Linear/Vercel).
|
||||
|
||||
## Decisões confirmadas com o usuário
|
||||
|
||||
- **Modo único, sempre escuro.** Sem alternância clara/escura — o app vira dark-first como identidade fixa, não uma preferência de sistema.
|
||||
- **Escopo: app inteiro.** Login, as 4 abas (Capturar Leads, Tratar Leads, Campanhas, Configurações), modais, tabelas, badges.
|
||||
- **UX incluído nesta leva:** toast colorido por sucesso/erro (verde/vermelho). Estados de "carregando" nos botões ficaram fora do escopo por enquanto.
|
||||
- **Manter a estrutura "Dispatch Board"** nos cards de campanha e blocos de estatística: números em fonte monoespaçada tabular, indicador de status como borda colorida à esquerda do card (em vez de só um chip).
|
||||
|
||||
## Achado técnico relevante
|
||||
|
||||
`public/css/app.css` é quase inteiramente orientado por variáveis CSS (`:root` + `@media (prefers-color-scheme: dark)`), sem cores hardcoded em `login.html`, `index.html` ou nos arquivos `.js` (confirmado por grep). As únicas exceções são 3 gradientes em `app.css` que combinam `var(--accent)` com um tom claro hardcoded (`#ff965b`, `#ff8b47`, `#ffb36d`) — todos parceiros do laranja atual, que precisam de um substituto para o novo roxo-índigo. Isso torna a troca de paleta uma mudança de baixo risco, concentrada quase toda em um bloco de tokens.
|
||||
|
||||
## Paleta nova (substitui `:root`; remove o bloco `@media (prefers-color-scheme: dark)` — deixa de existir modo claro)
|
||||
|
||||
```
|
||||
--page: #0b0c10
|
||||
--page-2: #0d0e13
|
||||
--surface: #16171d
|
||||
--surface-2: #1d1f27
|
||||
--ink: #f4f4f6
|
||||
--ink-2: #a4a6b0
|
||||
--muted: #6c6e79
|
||||
--grid: #2a2c35
|
||||
--border: rgba(255, 255, 255, 0.08)
|
||||
--accent: #6a63f6
|
||||
--accent-light: #9d97ff (novo token — substitui os 3 gradientes hardcoded)
|
||||
--accent-2: #4f8cff
|
||||
--accent-ink: #ffffff
|
||||
--bar: #6a63f6
|
||||
--good: #22c55e
|
||||
--good-text: #4ade80
|
||||
--warning: #f5a524
|
||||
--critical: #fb5b7c
|
||||
--critical-bg: rgba(251, 91, 124, 0.12)
|
||||
--radius-sm: 8px
|
||||
--radius: 12px
|
||||
--radius-lg: 16px
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45)
|
||||
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.3)
|
||||
```
|
||||
|
||||
`body` usa `font: 14px/1.5 Inter, ui-sans-serif, system-ui, ...` — mantém Inter (já era a fonte usada; SaaS dark real como Linear também usa Inter).
|
||||
|
||||
## Componentes com tratamento específico "Dispatch Board"
|
||||
|
||||
- **`.campaign-card`**: ganha `border-left: 3px solid var(--muted)`, com `border-left-color: var(--critical)` quando pausada e `var(--good)` quando ativa (rodando).
|
||||
- **Números de uso/limite** (`.campaign-usage`, `.stat-row .mini b`, `.tile .valor`): passam a usar `font-variant-numeric: tabular-nums` com uma pilha que prioriza `ui-monospace, SFMono-Regular, Consolas, monospace` para leitura tipo "painel".
|
||||
- Chips de status (`.chip`, `.chip.ok`, `.chip.warn`) continuam existindo (não são removidos) — só herdam as cores novas automaticamente, via variáveis.
|
||||
|
||||
## Toast colorido (UX)
|
||||
|
||||
`public/js/api.js`, função `toast(message, ms)`: hoje sempre estiliza igual. Passa a inferir o tipo pela própria mensagem — se começar com `"Erro"` (case-insensitive), aplica um estado de erro (borda/texto vermelho); caso contrário, estado de sucesso (borda/texto verde). Isso evita precisar tocar em cada um dos ~40 pontos de chamada de `toast(...)` espalhados por `app.js`, `tratamento.js`, `captura.js` e `campanhas.js` — a lógica fica centralizada em `api.js` e no CSS (`.toast.erro`, `.toast.sucesso`).
|
||||
|
||||
## Fora de escopo (explicitamente adiado)
|
||||
|
||||
- Estados de "carregando" em botões durante ações assíncronas.
|
||||
- Qualquer redesign de copy/textos, ícones, ou reestruturação de layout além da troca de paleta/raio/sombra e do tratamento Dispatch Board nos cards de campanha.
|
||||
- Modo claro (removido, não mantido como alternativa).
|
||||
|
||||
## Verificação
|
||||
|
||||
Não há testes automatizados de aparência visual neste projeto. A verificação é:
|
||||
1. Suíte de testes existente (`node --test`, Playwright) continua 100% verde — nenhuma lógica funcional deve quebrar.
|
||||
2. Checagem visual ao vivo no navegador (via Claude Browser) de cada tela: login, Capturar Leads, Tratar Leads, Campanhas (cards, fila, modais), Configurações (modal grande, conexão WhatsApp, templates), toast de sucesso e de erro.
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Maquina-Comercial-Pessoal</AssemblyName>
|
||||
<RootNamespace>MaquinaComercialPessoalLauncher</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
|
||||
// Launcher da Maquina Comercial Pessoal:
|
||||
// 1. localiza a pasta do projeto (procura package.json + scripts/servidor.mjs);
|
||||
// 2. se o servidor ja responde em http://127.0.0.1:3910, 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 MaquinaComercialPessoalLauncher
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
private static void LoadDotEnv(string root)
|
||||
{
|
||||
string envPath = Path.Combine(root, ".env");
|
||||
if (!File.Exists(envPath)) return;
|
||||
foreach (string rawLine in File.ReadAllLines(envPath))
|
||||
{
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#")) continue;
|
||||
int separator = line.IndexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
string key = line.Substring(0, separator).Trim();
|
||||
string value = line.Substring(separator + 1).Trim();
|
||||
if (key.Length > 0 && Environment.GetEnvironmentVariable(key) == null)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BaseUrl(string root)
|
||||
{
|
||||
LoadDotEnv(root);
|
||||
string port = Environment.GetEnvironmentVariable("APP_PORT");
|
||||
int parsedPort;
|
||||
if (!int.TryParse(port, out parsedPort) || parsedPort < 1 || parsedPort > 65535)
|
||||
{
|
||||
port = "3910";
|
||||
}
|
||||
else
|
||||
{
|
||||
port = parsedPort.ToString();
|
||||
}
|
||||
return "http://127.0.0.1:" + port;
|
||||
}
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
Console.Title = "Maquina Comercial Pessoal";
|
||||
Console.WriteLine("=== Maquina Comercial Pessoal ===");
|
||||
|
||||
string root = LocateProjectDir();
|
||||
if (root == null)
|
||||
{
|
||||
return Fail("Pasta do projeto nao encontrada (procurei package.json + scripts/servidor.mjs).\n" +
|
||||
"Deixe o Maquina-Comercial-Pessoal.exe dentro da pasta do projeto.");
|
||||
}
|
||||
|
||||
string url = BaseUrl(root);
|
||||
|
||||
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())
|
||||
{
|
||||
if ((int)response.StatusCode != 200) return false;
|
||||
using (StreamReader reader = new StreamReader(response.GetResponseStream()))
|
||||
{
|
||||
string body = reader.ReadToEnd();
|
||||
string compact = body.Replace(" ", "").Replace("\r", "").Replace("\n", "").Replace("\t", "");
|
||||
return (int)response.StatusCode == 200 &&
|
||||
compact.IndexOf("\"app\":\"maquina-comercial-pessoal\"", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1753
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "maquina-comercial-pessoal",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Aplicacao pessoal local para captura, tratamento e contato de leads.",
|
||||
"scripts": {
|
||||
"start": "node scripts/servidor.mjs",
|
||||
"test": "node --test",
|
||||
"test:browser": "playwright test",
|
||||
"capture": "node scripts/capturar-leads.mjs",
|
||||
"extract": "node scripts/extrair-base-cnpj.mjs",
|
||||
"download-rfb": "node scripts/baixar-base-rfb.mjs",
|
||||
"recalc-scores": "node scripts/recalcular-scores.mjs",
|
||||
"outreach": "node scripts/enviar-outreach.mjs",
|
||||
"build:launcher": "powershell -ExecutionPolicy Bypass -File scripts/build-launcher.ps1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"baileys": "7.0.0-rc14",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/browser',
|
||||
testMatch: '*.spec.mjs',
|
||||
timeout: 60000,
|
||||
expect: { timeout: 15000 },
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:3911',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'desktop',
|
||||
use: {
|
||||
viewport: { width: 1440, height: 900 },
|
||||
browserName: 'chromium',
|
||||
launchOptions: { args: ['--no-sandbox', '--disable-setuid-sandbox'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
use: {
|
||||
viewport: { width: 390, height: 844 },
|
||||
browserName: 'chromium',
|
||||
launchOptions: { args: ['--no-sandbox', '--disable-setuid-sandbox'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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 baixar/retomar somente os 21 ZIPs necessários da Receita Federal (cerca de 6 GB):
|
||||
|
||||
```powershell
|
||||
npm run download-rfb
|
||||
```
|
||||
|
||||
Snapshots novos exigem 8 GiB livres antes de tocar a base existente. Na retomada do mesmo snapshot, arquivos finais confirmados e parciais retomáveis são descontados, mantendo ao menos 2 GiB para extração. A base completa anterior permanece em `.rfb-backup` até a substituta ser validada; estados incompletos preservados usam `.rfb-incomplete*`.
|
||||
|
||||
Comando para baixar o que estiver faltando e extrair uma UF (gera o CSV
|
||||
`estabelecimentos-<UF>.csv` usado pela captura):
|
||||
|
||||
```powershell
|
||||
npm run extract -- --uf=BA
|
||||
```
|
||||
|
||||
Use `--no-download` apenas para exigir a base local completa sem consultar ou baixar o snapshot atual.
|
||||
|
||||
Comando para capturar leads usando a base já extraída:
|
||||
|
||||
```powershell
|
||||
npm run capture -- --sources=cnpj-base --uf=BA --cities="Ilheus"
|
||||
```
|
||||
|
||||
Com enriquecimento pela BrasilAPI, inclua a fonte `brasilapi`:
|
||||
|
||||
```powershell
|
||||
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
@@ -0,0 +1,753 @@
|
||||
/* Máquina Comercial Pessoal - interface principal */
|
||||
:root {
|
||||
--page: #0b0c10;
|
||||
--page-2: #0d0e13;
|
||||
--surface: #16171d;
|
||||
--surface-2: #1d1f27;
|
||||
--ink: #f4f4f6;
|
||||
--ink-2: #a4a6b0;
|
||||
--muted: #6c6e79;
|
||||
--grid: #2a2c35;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #6a63f6;
|
||||
--accent-light: #9d97ff;
|
||||
--accent-2: #4f8cff;
|
||||
--accent-ink: #ffffff;
|
||||
--bar: #6a63f6;
|
||||
--good: #22c55e;
|
||||
--good-text: #4ade80;
|
||||
--warning: #f5a524;
|
||||
--critical: #fb5b7c;
|
||||
--critical-bg: rgba(251, 91, 124, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* { 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: 32px; display: grid; place-items: center; border-radius: 9px; }
|
||||
.brand-mark-lg { height: 62px; width: 62px; border-radius: 17px; font-size: 21px; }
|
||||
.brand-monogram { background: linear-gradient(135deg, var(--accent), var(--accent-light)); color: #fff; font-weight: 900; letter-spacing: -0.06em; box-shadow: var(--shadow-soft); }
|
||||
.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), var(--accent-light)); 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; }
|
||||
.loading::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
animation: btn-spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes btn-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading::before { animation: none; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.whatsapp-connection-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
border-color: color-mix(in srgb, var(--good) 42%, var(--border));
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--good) 10%, var(--surface)), var(--surface-2));
|
||||
}
|
||||
.connection-copy p { margin: 6px 0; }
|
||||
.connection-number { color: var(--good-text); font-weight: 800; }
|
||||
.connection-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.qr-panel { min-height: 190px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px dashed var(--grid); border-radius: var(--radius); background: var(--surface); text-align: center; }
|
||||
#whatsapp-qr { display: block; width: 100%; max-width: 280px; height: auto; border-radius: 10px; }
|
||||
.connection-state.state-ready { color: var(--good-text); border-color: color-mix(in srgb, var(--good) 35%, var(--border)); background: color-mix(in srgb, var(--good) 12%, var(--surface)); }
|
||||
.connection-state.state-qr_required,
|
||||
.connection-state.state-starting,
|
||||
.connection-state.state-authenticating,
|
||||
.connection-state.state-reconnecting { border-color: color-mix(in srgb, var(--warning) 38%, var(--border)); background: color-mix(in srgb, var(--warning) 13%, var(--surface)); }
|
||||
.connection-state.state-error,
|
||||
.connection-state.state-disconnected { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 32%, var(--border)); background: var(--critical-bg); }
|
||||
.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-lead-context { padding: 10px 12px; border-radius: 12px; background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--border); }
|
||||
.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; }
|
||||
.opt-out-form { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) auto; gap: 10px; align-items: end; }
|
||||
.opt-out-form input { width: 100%; min-width: 0 !important; }
|
||||
.opt-out-item .btn-danger { margin-left: auto; }
|
||||
|
||||
.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)); }
|
||||
.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), var(--accent-light)); 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), var(--accent-light)); }
|
||||
.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); }
|
||||
|
||||
/* campanhas */
|
||||
.campaign-cards { display: flex; flex-direction: column; gap: 12px; }
|
||||
.campaign-card { margin-bottom: 0; }
|
||||
.campaign-card {
|
||||
border-left: 3px solid var(--muted);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.campaign-card:has(.chip.warn) { border-left-color: var(--critical); }
|
||||
.campaign-card:has(.chip.ok) { border-left-color: var(--good); }
|
||||
.campaign-card h3 { color: var(--ink); }
|
||||
.campaign-details { font-size: 13px; color: var(--ink-2); }
|
||||
.campaign-details > * { white-space: nowrap; }
|
||||
.campaign-usage span { margin-right: 12px; }
|
||||
.campaign-usage span,
|
||||
.stat-row .mini b,
|
||||
.tile .valor {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
.pause-reason { font-weight: 750; color: var(--critical); }
|
||||
#campaign-queue .muted { padding: 8px 0; }
|
||||
#metrics-filters { gap: 12px; align-items: end; margin-top: 8px; flex-wrap: wrap; }
|
||||
#metrics-filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; color: var(--ink-2); }
|
||||
#metrics-filters select { min-width: 140px; }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.toast.sucesso { background: var(--surface-2); color: var(--good-text); border: 1px solid color-mix(in srgb, var(--good) 45%, transparent); }
|
||||
.toast.erro { background: var(--surface-2); color: var(--critical); border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent); }
|
||||
.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, .whatsapp-connection-card { 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%; }
|
||||
.connection-actions > *, .opt-out-form .btn { width: 100%; }
|
||||
.opt-out-form { grid-template-columns: 1fr; }
|
||||
.qr-panel { min-height: 150px; }
|
||||
#f-busca, .tile.tile-wide { grid-column: span 1; }
|
||||
.campaign-details { flex-direction: column; align-items: flex-start; }
|
||||
.campaign-actions { flex-direction: column; width: 100%; }
|
||||
.campaign-actions .btn { width: 100%; }
|
||||
#campaign-queue-table { min-width: 600px; }
|
||||
#metrics-filters { flex-direction: column; align-items: stretch; }
|
||||
#metrics-filters select { width: 100%; }
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Máquina Comercial Pessoal</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23f06a25%22/><text x=%2232%22 y=%2241%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2226%22 font-weight=%22700%22 fill=%22white%22>MC</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark brand-monogram" aria-hidden="true">MC</span>
|
||||
<span class="brand-name">Máquina Comercial Pessoal</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.
|
||||
O primeiro uso baixa cerca de <strong>6 GB</strong> da Receita Federal e depois extrai a UF. O tempo depende da internet e pode passar de uma hora; downloads interrompidos são retomados.</p>
|
||||
<button class="btn" id="btn-extrair">Baixar e extrair base</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="">Telefone: todos</option>
|
||||
<option value="sim">Com telefone</option>
|
||||
<option value="nao">Sem telefone</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 telefone">
|
||||
<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>Telefone</th><th>Status 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="card">
|
||||
<div class="row space-between">
|
||||
<h2>Campanhas</h2>
|
||||
</div>
|
||||
<div id="campaign-cards" class="campaign-cards"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-queue-preview">
|
||||
<div class="row space-between">
|
||||
<h2>Fila do dia</h2>
|
||||
</div>
|
||||
<div id="campaign-queue">
|
||||
<p class="muted">Selecione uma campanha e clique em "Preparar fila" para gerar itens.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Respostas pendentes</h2>
|
||||
</div>
|
||||
<div id="pending-replies" class="lista">
|
||||
<p class="muted">Nenhuma resposta pendente.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Classificação de estágio</h2>
|
||||
</div>
|
||||
<div id="stage-classification">
|
||||
<p class="muted">Classifique leads que responderam para acompanhar o funil comercial.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Agendar demonstração / teste</h2>
|
||||
<button class="btn btn-primary btn-sm" id="btn-schedule-appointment" type="button">Agendar</button>
|
||||
</div>
|
||||
<div id="appointment-form">
|
||||
<p class="muted">Após classificar um lead, use o botão "Classificar" para agendar demonstração ou teste.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Métricas</h2>
|
||||
</div>
|
||||
<div id="metrics-filters" class="row"></div>
|
||||
<div id="campaign-metrics" style="margin-top:10px">
|
||||
<p class="muted">Selecione uma campanha e prepare a fila para ver métricas.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>CSV + XLS</strong> — relatório completo com os filtros atuais</label>
|
||||
</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">Conexão local, mensagens, limites e lista de bloqueio.</p>
|
||||
</div>
|
||||
<span class="chip" id="outreach-status"></span>
|
||||
</div>
|
||||
|
||||
<section class="settings-card whatsapp-connection-card">
|
||||
<div class="connection-copy">
|
||||
<div class="row">
|
||||
<label class="settings-title">Conexão WhatsApp</label>
|
||||
<span class="chip connection-state" id="whatsapp-state" role="status" aria-live="polite">Consultando...</span>
|
||||
</div>
|
||||
<p class="muted">Conexão local via dispositivo vinculado. A automação inicia desligada.</p>
|
||||
<p id="whatsapp-number" class="connection-number hidden"></p>
|
||||
<p id="whatsapp-error" class="erro hidden"></p>
|
||||
<div class="connection-actions admin-only">
|
||||
<button class="btn btn-primary" id="btn-whatsapp-connect" type="button">Conectar / gerar QR</button>
|
||||
<button class="btn-ghost" id="btn-whatsapp-refresh-qr" type="button">Atualizar QR</button>
|
||||
<button class="btn-ghost" id="btn-whatsapp-disconnect" type="button">Desconectar</button>
|
||||
<button class="btn-danger" id="btn-whatsapp-logout" type="button">Sair e esquecer sessão</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qr-panel">
|
||||
<img id="whatsapp-qr" class="hidden" alt="QR code para vincular o WhatsApp">
|
||||
<span id="whatsapp-qr-placeholder" class="muted">O QR aparecerá aqui quando solicitado.</span>
|
||||
<span id="whatsapp-qr-expiry" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-principal admin-only">
|
||||
<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">Revise o número, a mensagem e o contexto real do lead antes de enviar.</p>
|
||||
<div class="manual-send-card">
|
||||
<label class="full">Número do WhatsApp
|
||||
<input type="text" id="manual-numero" placeholder="5511999999999" inputmode="numeric">
|
||||
</label>
|
||||
<label class="full">Mensagem
|
||||
<textarea id="manual-mensagem" rows="3" placeholder="Digite uma mensagem pessoal e respeitosa..."></textarea>
|
||||
</label>
|
||||
<div class="manual-lead-context full" id="manual-lead-context">Envio avulso, sem lead selecionado.</div>
|
||||
<button class="btn-ghost btn-sm" id="btn-limpar-lead-manual" type="button">Limpar contexto do lead</button>
|
||||
<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>Alterar minha senha</summary>
|
||||
<p class="muted">Disponível para qualquer usuário autenticado. A nova senha deve ter ao menos 8 caracteres.</p>
|
||||
<div class="opt-out-form">
|
||||
<label>Senha atual <input type="password" id="senha-atual" autocomplete="current-password"></label>
|
||||
<label>Nova senha <input type="password" id="senha-nova" minlength="8" autocomplete="new-password"></label>
|
||||
<label>Confirmar nova senha <input type="password" id="senha-confirmacao" minlength="8" autocomplete="new-password"></label>
|
||||
<button class="btn btn-primary" id="btn-alterar-senha" type="button">Alterar senha</button>
|
||||
</div>
|
||||
<p id="senha-status" class="muted"></p>
|
||||
</details>
|
||||
|
||||
<details class="settings-section admin-only">
|
||||
<summary>Templates automáticos</summary>
|
||||
<p class="muted">Somente mensagem e estado ativo. Os templates aparecem após configurar seus serviços.</p>
|
||||
<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 admin-only">
|
||||
<summary>Lista de bloqueio / opt-out</summary>
|
||||
<p class="muted">Números nesta lista nunca recebem envios manuais ou automáticos.</p>
|
||||
<div class="opt-out-form">
|
||||
<label>Número <input type="text" id="optout-numero" placeholder="5511999999999" inputmode="numeric"></label>
|
||||
<label>Motivo <input type="text" id="optout-motivo" placeholder="Pedido do contato"></label>
|
||||
<button class="btn btn-primary" id="btn-optout-add" type="button">Adicionar</button>
|
||||
</div>
|
||||
<p id="optout-status" class="muted"></p>
|
||||
<div id="optout-list" class="lista"></div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section zona-risco admin-only">
|
||||
<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 admin-only" 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="8" 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>
|
||||
|
||||
<!-- Modal inicio de campanha -->
|
||||
<dialog id="modal-campaign-start">
|
||||
<div class="modal-body">
|
||||
<h2>Iniciar campanha</h2>
|
||||
<p class="muted">Confirma o início da <strong class="campaign-start-name">campanha</strong>?</p>
|
||||
<p class="muted">A automação começará a enviar mensagens dentro da janela configurada.</p>
|
||||
<p class="muted erro hidden" id="modal-campaign-start-erro"></p>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
|
||||
<button class="btn btn-primary btn-confirm-start" id="btn-confirm-start">Iniciar</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal classificacao de estagio -->
|
||||
<dialog id="modal-campaign-stage">
|
||||
<div class="modal-body">
|
||||
<h2>Classificar lead</h2>
|
||||
<div class="field">
|
||||
<label for="inp-stage">Estágio</label>
|
||||
<select id="inp-stage"></select>
|
||||
</div>
|
||||
<p class="muted erro hidden" id="modal-campaign-stage-erro"></p>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
|
||||
<button class="btn btn-primary btn-save-stage" id="btn-save-stage">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal agendamento -->
|
||||
<dialog id="modal-campaign-appointment">
|
||||
<div class="modal-body">
|
||||
<h2>Agendar demonstração / teste</h2>
|
||||
<div class="field">
|
||||
<label for="inp-appointment-lead-id">ID do lead</label>
|
||||
<input type="number" id="inp-appointment-lead-id" min="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inp-appointment-kind">Tipo</label>
|
||||
<select id="inp-appointment-kind">
|
||||
<option value="demonstracao">Demonstração</option>
|
||||
<option value="teste_g_delivery">Teste G-Delivery</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inp-appointment-participant">Participante</label>
|
||||
<input type="text" id="inp-appointment-participant" placeholder="Nome do contato">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inp-appointment-company">Empresa</label>
|
||||
<input type="text" id="inp-appointment-company" placeholder="Nome da empresa">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="inp-appointment-day">Dia preferido</label>
|
||||
<input type="date" id="inp-appointment-day">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inp-appointment-time">Horário preferido</label>
|
||||
<input type="time" id="inp-appointment-time">
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted erro hidden" id="modal-campaign-appointment-erro"></p>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
|
||||
<button class="btn btn-primary btn-confirm-appointment" id="btn-confirm-appointment">Agendar</button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,117 @@
|
||||
/* 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); },
|
||||
delete(path, body) { return this.send('DELETE', 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', 'erro', 'sucesso');
|
||||
box.classList.add(/^erro/i.test(message) ? 'erro' : 'sucesso');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
|
||||
}
|
||||
|
||||
// Envolve uma acao assincrona disparada por um botao: desabilita + mostra
|
||||
// spinner (classe .loading, ver app.css) enquanto `fn` esta em andamento,
|
||||
// restaura no final independente de sucesso ou erro. Se o botao ja estava
|
||||
// desabilitado/carregando (chamada reentrante), nao mexe no estado dele —
|
||||
// so quem entrou "limpo" e responsavel por restaurar.
|
||||
async function comCarregando(button, fn) {
|
||||
if (!button) return fn();
|
||||
const jaCarregando = button.disabled;
|
||||
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/* Shell da Máquina Comercial Pessoal: sessão, abas, WhatsApp e configurações. */
|
||||
(async function init() {
|
||||
let usuarioAtual;
|
||||
try {
|
||||
({ user: usuarioAtual } = await API.get('/api/auth/me'));
|
||||
} catch {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = usuarioAtual.role === 'admin';
|
||||
let manualLeadContext = null;
|
||||
let whatsappPollTimer = null;
|
||||
let whatsappGeneration = 0;
|
||||
let whatsappPollBusyGeneration = null;
|
||||
let lastQrExpiry = null;
|
||||
const pollingStates = new Set(['STARTING', 'QR_REQUIRED', 'AUTHENTICATING', 'RECONNECTING']);
|
||||
const stateLabels = {
|
||||
STOPPED: 'Parado', STARTING: 'Iniciando', QR_REQUIRED: 'QR necessário',
|
||||
AUTHENTICATING: 'Autenticando', READY: 'Conectado', RECONNECTING: 'Reconectando',
|
||||
DISCONNECTED: 'Desconectado', ERROR: 'Erro'
|
||||
};
|
||||
|
||||
el('usuario-logado').textContent = `${usuarioAtual.username} (${isAdmin ? 'admin' : 'comercial'})`;
|
||||
el('btn-usuarios').classList.toggle('hidden', !isAdmin);
|
||||
for (const control of document.querySelectorAll('.admin-only')) control.classList.toggle('hidden', !isAdmin);
|
||||
|
||||
el('btn-sair').addEventListener('click', async () => {
|
||||
const button = el('btn-sair');
|
||||
await comCarregando(button, async () => {
|
||||
await API.post('/api/auth/logout');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
});
|
||||
|
||||
async function carregarListaUsuarios() {
|
||||
const { users } = await API.get('/api/users');
|
||||
el('lista-usuarios').replaceChildren(...users.map((user) => h('div', { class: 'row space-between' },
|
||||
h('span', {}, user.username),
|
||||
h('span', { class: 'muted' }, user.role === 'admin' ? 'Administrador' : 'Comercial'))));
|
||||
}
|
||||
|
||||
el('btn-usuarios').addEventListener('click', async () => {
|
||||
const button = el('btn-usuarios');
|
||||
el('modal-usuarios').showModal();
|
||||
await comCarregando(button, async () => {
|
||||
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();
|
||||
const form = el('form-novo-usuario');
|
||||
const button = form.querySelector('button[type=submit]');
|
||||
await comCarregando(button, async () => {
|
||||
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}`); }
|
||||
});
|
||||
});
|
||||
|
||||
for (const tab of document.querySelectorAll('.tab')) {
|
||||
tab.addEventListener('click', () => {
|
||||
for (const item of document.querySelectorAll('.tab')) {
|
||||
item.classList.toggle('active', item === tab);
|
||||
item.setAttribute('aria-selected', item === 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();
|
||||
});
|
||||
}
|
||||
|
||||
function stopWhatsAppPolling() {
|
||||
clearTimeout(whatsappPollTimer);
|
||||
whatsappPollTimer = null;
|
||||
}
|
||||
|
||||
function ownsWhatsAppGeneration(generation) {
|
||||
return generation === whatsappGeneration && el('modal-settings').open;
|
||||
}
|
||||
|
||||
function nextWhatsAppGeneration() {
|
||||
stopWhatsAppPolling();
|
||||
whatsappGeneration += 1;
|
||||
return whatsappGeneration;
|
||||
}
|
||||
|
||||
function scheduleWhatsAppPolling(state, generation) {
|
||||
stopWhatsAppPolling();
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
const delay = pollingStates.has(state) ? 1000 : 5000;
|
||||
whatsappPollTimer = setTimeout(() => refreshWhatsAppStatus(true, generation), delay);
|
||||
}
|
||||
|
||||
function renderWhatsAppStatus(status, generation) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return false;
|
||||
const state = status?.state || 'STOPPED';
|
||||
const stateEl = el('whatsapp-state');
|
||||
stateEl.textContent = stateLabels[state] || state;
|
||||
stateEl.className = `chip connection-state state-${state.toLowerCase()}`;
|
||||
const connectedNumber = status?.number || status?.numero || status?.phone || status?.connectedNumber;
|
||||
el('whatsapp-number').textContent = connectedNumber ? `Número conectado: ${connectedNumber}` : '';
|
||||
el('whatsapp-number').classList.toggle('hidden', !connectedNumber);
|
||||
el('whatsapp-error').textContent = status?.error || status?.erro || '';
|
||||
el('whatsapp-error').classList.toggle('hidden', !(status?.error || status?.erro));
|
||||
if (state !== 'QR_REQUIRED') hideQr();
|
||||
return true;
|
||||
}
|
||||
|
||||
function hideQr() {
|
||||
el('whatsapp-qr').removeAttribute('src');
|
||||
el('whatsapp-qr').classList.add('hidden');
|
||||
el('whatsapp-qr-placeholder').classList.remove('hidden');
|
||||
el('whatsapp-qr-expiry').textContent = '';
|
||||
lastQrExpiry = null;
|
||||
}
|
||||
|
||||
async function loadQr(generation) {
|
||||
if (!isAdmin || !ownsWhatsAppGeneration(generation)) return;
|
||||
try {
|
||||
const { qr, expiresAt } = await API.get('/api/whatsapp/qr');
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
if (expiresAt !== lastQrExpiry || !el('whatsapp-qr').getAttribute('src')) {
|
||||
el('whatsapp-qr').src = qr;
|
||||
lastQrExpiry = expiresAt;
|
||||
}
|
||||
el('whatsapp-qr').classList.remove('hidden');
|
||||
el('whatsapp-qr-placeholder').classList.add('hidden');
|
||||
el('whatsapp-qr-expiry').textContent = expiresAt ? `Expira em ${dataBr(expiresAt)}` : '';
|
||||
} catch (error) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
el('whatsapp-qr-expiry').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWhatsAppStatus(fromPoll = false, generation = whatsappGeneration) {
|
||||
if (!ownsWhatsAppGeneration(generation) || whatsappPollBusyGeneration === generation) return;
|
||||
whatsappPollBusyGeneration = generation;
|
||||
try {
|
||||
const status = await API.get('/api/whatsapp/status');
|
||||
if (!renderWhatsAppStatus(status, generation)) return;
|
||||
if (status.state === 'QR_REQUIRED') await loadQr(generation);
|
||||
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
|
||||
} catch (error) {
|
||||
if (!renderWhatsAppStatus({ state: 'ERROR', error: error.message }, generation)) return;
|
||||
if (!fromPoll) toast(`WhatsApp: ${error.message}`);
|
||||
scheduleWhatsAppPolling('ERROR', generation);
|
||||
} finally {
|
||||
if (whatsappPollBusyGeneration === generation) whatsappPollBusyGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function whatsappAction(path) {
|
||||
const generation = nextWhatsAppGeneration();
|
||||
try {
|
||||
const result = await API.post(path);
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
const status = result.status || await API.get('/api/whatsapp/status');
|
||||
if (!renderWhatsAppStatus(status, generation)) return;
|
||||
if (status.state === 'QR_REQUIRED') await loadQr(generation);
|
||||
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
|
||||
} catch (error) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
toast(`WhatsApp: ${error.message}`);
|
||||
await refreshWhatsAppStatus(false, generation);
|
||||
}
|
||||
}
|
||||
|
||||
el('btn-whatsapp-connect').addEventListener('click', () => comCarregando(el('btn-whatsapp-connect'), () => whatsappAction('/api/whatsapp/connect')));
|
||||
el('btn-whatsapp-refresh-qr').addEventListener('click', () => comCarregando(el('btn-whatsapp-refresh-qr'), () => whatsappAction('/api/whatsapp/refresh-qr')));
|
||||
el('btn-whatsapp-disconnect').addEventListener('click', () => comCarregando(el('btn-whatsapp-disconnect'), () => whatsappAction('/api/whatsapp/disconnect')));
|
||||
el('btn-whatsapp-logout').addEventListener('click', async () => {
|
||||
if (!window.confirm('Sair do WhatsApp e apagar a sessão vinculada neste computador?')) return;
|
||||
await comCarregando(el('btn-whatsapp-logout'), () => whatsappAction('/api/whatsapp/logout'));
|
||||
});
|
||||
|
||||
function renderLeadContext() {
|
||||
if (!manualLeadContext) {
|
||||
el('manual-lead-context').textContent = 'Envio avulso, sem lead selecionado.';
|
||||
return;
|
||||
}
|
||||
const lead = manualLeadContext;
|
||||
el('manual-lead-context').textContent = [
|
||||
`Lead: ${lead.empresa || lead.legal_name || 'Empresa não informada'}`,
|
||||
lead.cidade ? `${lead.cidade}${lead.uf ? `/${lead.uf}` : ''}` : '',
|
||||
lead.segmento_label || lead.segmento || '',
|
||||
lead.cnpj || ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
window.MaquinaSelecionarLeadManual = (lead) => {
|
||||
manualLeadContext = lead || null;
|
||||
renderLeadContext();
|
||||
};
|
||||
|
||||
window.MaquinaLimparLeadManual = () => {
|
||||
manualLeadContext = null;
|
||||
renderLeadContext();
|
||||
};
|
||||
el('btn-limpar-lead-manual').addEventListener('click', window.MaquinaLimparLeadManual);
|
||||
|
||||
async function carregarOutreach() {
|
||||
const [{ settings }, { templates }, status] = await Promise.all([
|
||||
API.get('/api/settings'), API.get('/api/outreach/templates'), API.get('/api/outreach/status')
|
||||
]);
|
||||
el('outreach-ativo').checked = settings['outreach.ativo'] === 'true';
|
||||
el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10;
|
||||
el('outreach-status').textContent = `Consumidos do limite hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`;
|
||||
|
||||
if (!templates.length) {
|
||||
el('outreach-templates').replaceChildren(h('p', { class: 'muted' },
|
||||
'Nenhum template disponível. Eles aparecerão após configurar seus serviços.'));
|
||||
} else {
|
||||
el('outreach-templates').replaceChildren(...templates.map((template) => h('div', {
|
||||
class: 'item outreach-template', 'data-produto': template.produto
|
||||
},
|
||||
h('div', { class: 'row space-between' },
|
||||
h('strong', {}, template.produto),
|
||||
h('label', { class: 'radio' },
|
||||
h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: template.ativo ? '' : null }), ' Ativo')),
|
||||
h('textarea', { class: 'outreach-template-mensagem', rows: 3 }, template.mensagem || ''))));
|
||||
}
|
||||
|
||||
const sendStatusLabels = { pendente: 'Preparando', aceito: 'Aguardando confirmação', enviado: 'Enviado ao servidor', entregue: 'Entregue', lido: 'Lido', desconhecido: 'Resultado desconhecido', erro: 'Falhou' };
|
||||
el('outreach-ultimos').replaceChildren(...status.ultimos.map((send) => h('div', { class: 'item' },
|
||||
h('div', {}, `${sendStatusLabels[send.status] || send.status} · ${send.numero || send.empresa || 'número não informado'}`),
|
||||
h('div', { class: 'sub' }, `${dataBr(send.sent_at)}${send.erro ? ` · ${send.erro}` : ''}`))));
|
||||
if (!status.ultimos.length) el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.'));
|
||||
}
|
||||
|
||||
async function carregarOptOuts() {
|
||||
const { optOuts } = await API.get('/api/outreach/opt-outs');
|
||||
const list = el('optout-list');
|
||||
if (!optOuts.length) {
|
||||
list.replaceChildren(h('p', { class: 'muted' }, 'Nenhum número bloqueado.'));
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(...optOuts.map((item) => {
|
||||
let rowButton;
|
||||
rowButton = h('button', {
|
||||
class: 'btn-danger btn-sm', type: 'button',
|
||||
onclick: () => comCarregando(rowButton, () => removerOptOut(item.numero))
|
||||
}, 'Remover');
|
||||
return h('div', { class: 'item opt-out-item' },
|
||||
h('div', {}, h('strong', {}, item.numero),
|
||||
h('div', { class: 'sub' }, `${item.motivo || 'Sem motivo'} · ${dataBr(item.created_at)}`)),
|
||||
rowButton);
|
||||
}));
|
||||
}
|
||||
|
||||
async function removerOptOut(numero) {
|
||||
try {
|
||||
await API.delete(`/api/outreach/opt-outs/${encodeURIComponent(numero)}`);
|
||||
el('optout-status').textContent = 'Número removido da lista.';
|
||||
await carregarOptOuts();
|
||||
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
||||
}
|
||||
|
||||
el('btn-optout-add').addEventListener('click', async () => {
|
||||
const button = el('btn-optout-add');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.post('/api/outreach/opt-outs', {
|
||||
numero: el('optout-numero').value.trim(), motivo: el('optout-motivo').value.trim()
|
||||
});
|
||||
el('optout-numero').value = '';
|
||||
el('optout-motivo').value = '';
|
||||
el('optout-status').textContent = 'Número adicionado à lista.';
|
||||
await carregarOptOuts();
|
||||
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-settings').addEventListener('click', async () => {
|
||||
const button = el('btn-settings');
|
||||
el('modal-settings').showModal();
|
||||
const generation = nextWhatsAppGeneration();
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]);
|
||||
} catch (error) { toast(`Erro ao carregar configurações: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-enviar-manual').addEventListener('click', async () => {
|
||||
const button = el('btn-enviar-manual');
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
const statusEl = el('manual-status');
|
||||
statusEl.classList.remove('status-ok', 'status-error');
|
||||
statusEl.textContent = 'Enviando...';
|
||||
try {
|
||||
const result = await API.post('/api/outreach/enviar-manual', {
|
||||
numero: el('manual-numero').value.trim(),
|
||||
mensagem: el('manual-mensagem').value,
|
||||
leadId: manualLeadContext?.id || null,
|
||||
lead: manualLeadContext
|
||||
});
|
||||
const uncertain = result.status === 'resultado_desconhecido' || result.status === 'persistencia_incerta';
|
||||
const warning = Boolean(result.aviso);
|
||||
statusEl.textContent = uncertain || warning
|
||||
? result.aviso
|
||||
: result.ok
|
||||
? `Aceito pelo WhatsApp; aguardando confirmação${result.messageId ? ` · ID ${result.messageId}` : ''}`
|
||||
: result.erro;
|
||||
statusEl.classList.toggle('status-ok', Boolean(result.ok) && !warning);
|
||||
statusEl.classList.toggle('status-error', !result.ok || warning);
|
||||
await carregarOutreach();
|
||||
} catch (error) {
|
||||
statusEl.textContent = `Erro: ${error.message}`;
|
||||
statusEl.classList.add('status-error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
el('btn-toggle-outreach-log').addEventListener('click', () => {
|
||||
const hidden = el('outreach-ultimos').classList.toggle('hidden');
|
||||
el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios';
|
||||
});
|
||||
|
||||
el('btn-salvar-outreach').addEventListener('click', async () => {
|
||||
const button = el('btn-salvar-outreach');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
for (const item of el('outreach-templates').querySelectorAll('.outreach-template')) {
|
||||
await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, {
|
||||
mensagem: item.querySelector('.outreach-template-mensagem').value,
|
||||
ativo: item.querySelector('.outreach-template-ativo').checked
|
||||
});
|
||||
}
|
||||
await API.put('/api/settings', {
|
||||
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||
});
|
||||
await carregarOutreach();
|
||||
toast('Templates salvos');
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-alterar-senha').addEventListener('click', async () => {
|
||||
const button = el('btn-alterar-senha');
|
||||
const status = el('senha-status');
|
||||
const novaSenha = el('senha-nova').value;
|
||||
if (novaSenha !== el('senha-confirmacao').value) {
|
||||
status.textContent = 'A confirmação não corresponde à nova senha.';
|
||||
return;
|
||||
}
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.put('/api/auth/password', { senhaAtual: el('senha-atual').value, novaSenha });
|
||||
el('senha-atual').value = '';
|
||||
el('senha-nova').value = '';
|
||||
el('senha-confirmacao').value = '';
|
||||
status.textContent = 'Senha alterada. Entre novamente.';
|
||||
if (result.relogin) window.location.href = '/login.html';
|
||||
} catch (error) {
|
||||
status.textContent = `Erro: ${error.message}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-salvar-settings').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = el('btn-salvar-settings');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.put('/api/settings', {
|
||||
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||
});
|
||||
stopWhatsAppPolling();
|
||||
el('modal-settings').close();
|
||||
toast('Configurações salvas');
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('modal-settings').addEventListener('close', nextWhatsAppGeneration);
|
||||
el('btn-limpar-base').addEventListener('click', () => {
|
||||
el('reset-base-senha').value = '';
|
||||
el('reset-base-erro').classList.add('hidden');
|
||||
stopWhatsAppPolling();
|
||||
el('modal-settings').close();
|
||||
el('modal-reset-base').showModal();
|
||||
});
|
||||
el('btn-cancelar-reset-base').addEventListener('click', () => el('modal-reset-base').close());
|
||||
el('btn-confirmar-reset-base').addEventListener('click', async () => {
|
||||
const button = el('btn-confirmar-reset-base');
|
||||
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;
|
||||
}
|
||||
await comCarregando(button, async () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const dialog of document.querySelectorAll('dialog')) {
|
||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
|
||||
}
|
||||
|
||||
renderLeadContext();
|
||||
Tratamento.bindEvents();
|
||||
Campanhas.bindEvents();
|
||||
try { await Captura.init(); } catch (error) { toast(`Erro ao carregar: ${error.message}`, 8000); }
|
||||
})();
|
||||
@@ -0,0 +1,414 @@
|
||||
window.campanhasInit = window.campanhasInit || (() => {
|
||||
let carregado = false;
|
||||
let refreshTimer = null;
|
||||
let selectedCampaignId = null;
|
||||
let currentQueue = [];
|
||||
let startCampaignId = null;
|
||||
let stageLeadId = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
draft: 'Rascunho', paused: 'Pausada', ready: 'Pronta',
|
||||
running: 'Ativa', completed: 'Concluída'
|
||||
};
|
||||
|
||||
const QUEUE_STATUS_LABELS = {
|
||||
preview: 'Pré-visualização', approved: 'Aprovado', scheduled: 'Agendado',
|
||||
reserved: 'Reservado', sent: 'Enviado', unknown: 'Resultado desconhecido',
|
||||
error: 'Falhou', cancelled: 'Cancelado'
|
||||
};
|
||||
const REMOVABLE_QUEUE_STATUSES = new Set(['preview', 'approved', 'scheduled']);
|
||||
|
||||
// Cada valor precisa existir em CAMPAIGN_STAGES (src/outreach/campaign-catalog.mjs) —
|
||||
// o backend rejeita qualquer outro com 400. De propósito NÃO inclui os estágios
|
||||
// automáticos do pipeline anteriores a uma resposta (fila_preparada,
|
||||
// aprovado_para_envio, enviado): este dialogo so abre para leads que ja
|
||||
// responderam (stage 'respondeu'), entao esses 3 nunca se aplicam aqui.
|
||||
const STAGE_OPTIONS = [
|
||||
['respondeu', 'Respondeu'],
|
||||
['interessado_gti_vendas', 'Interessado — GTI Vendas'],
|
||||
['interessado_g_delivery', 'Interessado — G-Delivery'],
|
||||
['demonstracao_agendada', 'Demonstração agendada'],
|
||||
['teste_g_delivery_iniciado', 'Teste G-Delivery iniciado'],
|
||||
['venda_fechada', 'Venda fechada'],
|
||||
['sem_interesse', 'Sem interesse'],
|
||||
['opt_out', 'Opt-out']
|
||||
];
|
||||
|
||||
function hojeLocalYMD() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function modalErro(id, message) {
|
||||
const erroEl = el(id);
|
||||
if (!erroEl) return;
|
||||
erroEl.textContent = message || '';
|
||||
erroEl.classList.toggle('hidden', !message);
|
||||
}
|
||||
|
||||
function invalidar() {
|
||||
carregado = false;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
if (document.querySelector('#tab-campanhas.active')) ativar();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
async function ativar() {
|
||||
if (carregado) return;
|
||||
carregado = true;
|
||||
await Promise.all([carregarCampanhas(), carregarReplies(), carregarStageClassification(), carregarMetrics()]);
|
||||
}
|
||||
|
||||
async function carregarCampanhas() {
|
||||
const { campaigns } = await API.get('/api/campaigns');
|
||||
el('campaign-cards').replaceChildren(...campaigns.map(renderCard));
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
const statusClass = c.status === 'running' ? 'chip ok' : c.status === 'paused' ? 'chip warn' : 'chip';
|
||||
const actions = [];
|
||||
if (c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-preview-${c.id}`, onclick: () => prepararFila(c.id) }, 'Preparar fila'));
|
||||
}
|
||||
if (c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-start-${c.id}`, onclick: () => iniciarCampanha(c.id) }, 'Iniciar'));
|
||||
}
|
||||
if (c.status === 'running') {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-pause-${c.id}`, onclick: () => pausarCampanha(c.id) }, 'Pausar'));
|
||||
}
|
||||
if (c.status === 'running' || c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-queue-${c.id}`, onclick: () => carregarFila(c.id) }, 'Ver fila'));
|
||||
}
|
||||
if (c.week_number < 4) {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-advance-week-${c.id}`, onclick: () => avancarSemana(c.id, c.week_number) }, 'Avançar semana'));
|
||||
}
|
||||
|
||||
return h('div', { class: 'card campaign-card', 'data-campaign-id': c.id },
|
||||
h('div', { class: 'row space-between' },
|
||||
h('h3', { style: 'margin:0;font-size:16px' }, c.name),
|
||||
h('span', { class: statusClass }, STATUS_LABELS[c.status] || c.status)
|
||||
),
|
||||
h('div', { class: 'campaign-details row', style: 'margin-top:8px' },
|
||||
h('span', { class: 'campaign-week' }, `Semana ${c.week_number}`),
|
||||
h('span', { class: 'campaign-limit' }, `Limite: ${c.approved_daily_limit}/dia`),
|
||||
c.pause_reason ? h('span', { class: 'pause-reason muted', style: 'color:var(--critical);font-weight:750' }, `Pausado: ${c.pause_reason}`) : null
|
||||
),
|
||||
h('div', { class: 'campaign-usage row', style: 'margin-top:6px;font-size:12.5px;color:var(--ink-2)' },
|
||||
h('span', { class: 'manual-usage' }, `Manual: ${c.usage?.manual?.used || 0}/${c.usage?.manual?.limit || 0}`),
|
||||
h('span', { class: 'auto-usage' }, `Automático: ${c.usage?.campaign?.used || 0}/${c.approved_daily_limit}`)
|
||||
),
|
||||
actions.length ? h('div', { class: 'campaign-actions row', style: 'margin-top:8px' }, ...actions) : null
|
||||
);
|
||||
}
|
||||
|
||||
async function prepararFila(campaignId) {
|
||||
const button = el(`btn-preview-${campaignId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.post(`/api/campaigns/${campaignId}/prepare`);
|
||||
toast(`Preparados: ${result.summary.previewed} fila, ${result.summary.checked} verificados, ${result.summary.skipped} pulados`);
|
||||
await carregarFila(campaignId);
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function carregarFila(campaignId) {
|
||||
const button = el(`btn-queue-${campaignId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
selectedCampaignId = campaignId;
|
||||
// Sem filtro de status: uma campanha em andamento ja tem itens em scheduled/sent/etc,
|
||||
// nao so preview, e o operador precisa poder ver o que esta acontecendo com eles.
|
||||
const { queue } = await API.get(`/api/campaigns/${campaignId}/queue`);
|
||||
currentQueue = queue || [];
|
||||
const container = el('campaign-queue');
|
||||
if (!queue || !queue.length) {
|
||||
container.replaceChildren(h('p', { class: 'muted' }, 'Fila vazia. Clique em "Preparar fila" para gerar itens.'));
|
||||
return;
|
||||
}
|
||||
const previewCount = queue.filter((r) => r.status === 'preview').length;
|
||||
container.replaceChildren(
|
||||
h('div', { class: 'table-wrap' },
|
||||
h('table', { id: 'campaign-queue-table' },
|
||||
h('thead', {},
|
||||
h('tr', {},
|
||||
h('th', {}, 'Remover'), h('th', {}, 'Empresa'),
|
||||
h('th', {}, 'Mensagem'), h('th', {}, 'Segmento'),
|
||||
h('th', {}, 'Status'), h('th', {}, 'Motivo de seleção')
|
||||
)
|
||||
),
|
||||
h('tbody', {}, ...queue.map((row) =>
|
||||
h('tr', { class: 'queue-row', 'data-queue-id': row.id },
|
||||
h('td', {}, REMOVABLE_QUEUE_STATUSES.has(row.status) ? h('button', {
|
||||
class: 'btn-danger btn-sm btn-remove-queue',
|
||||
id: `btn-remove-queue-${row.id}`,
|
||||
'data-queue-id': row.id,
|
||||
onclick: () => removerItemFila(row.id, campaignId)
|
||||
}, 'Remover') : null),
|
||||
h('td', {}, row.empresa || '—'),
|
||||
h('td', { style: 'white-space:normal;max-width:400px' }, row.rendered_message || '—'),
|
||||
h('td', {}, row.segmento || '—'),
|
||||
h('td', {}, QUEUE_STATUS_LABELS[row.status] || row.status),
|
||||
h('td', { style: 'font-size:12px;color:var(--muted)' }, row.selection_reason || '—')
|
||||
)
|
||||
))
|
||||
)
|
||||
),
|
||||
h('div', { class: 'row space-between', style: 'margin-top:12px' },
|
||||
previewCount
|
||||
? h('button', { class: 'btn btn-primary', id: 'btn-approve', onclick: () => aprovarFila(campaignId) },
|
||||
`Aprovar fila (${previewCount} itens)`)
|
||||
: null,
|
||||
h('span', { class: 'muted', id: 'queue-count' }, `${queue.length} itens na fila`)
|
||||
)
|
||||
);
|
||||
} catch (error) { toast(`Erro ao carregar fila: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function removerItemFila(queueId, campaignId) {
|
||||
const button = el(`btn-remove-queue-${queueId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
await API.delete(`/api/campaigns/${campaignId}/queue/${queueId}`);
|
||||
toast('Item removido da fila');
|
||||
await carregarFila(campaignId);
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function aprovarFila(campaignId) {
|
||||
const queueIds = currentQueue.filter((row) => row.status === 'preview').map((row) => row.id);
|
||||
if (!queueIds.length) { toast('Nenhum item na fila para aprovar'); return; }
|
||||
const button = el('btn-approve');
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.post(`/api/campaigns/${campaignId}/approve`, { queueIds, date: hojeLocalYMD() });
|
||||
toast(`Fila aprovada: ${result.scheduled || 0} itens agendados`);
|
||||
await carregarFila(campaignId);
|
||||
await carregarCampanhas();
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
function iniciarCampanha(campaignId) {
|
||||
startCampaignId = campaignId;
|
||||
modalErro('modal-campaign-start-erro', '');
|
||||
el('modal-campaign-start').showModal();
|
||||
}
|
||||
|
||||
function pausarCampanha(campaignId) {
|
||||
const reason = window.prompt('Motivo da pausa:');
|
||||
if (!reason || !reason.trim()) return;
|
||||
const button = el(`btn-pause-${campaignId}`);
|
||||
comCarregando(button, () => API.post(`/api/campaigns/${campaignId}/pause`, { reason: reason.trim() })
|
||||
.then(() => { toast('Campanha pausada'); carregarCampanhas(); })
|
||||
.catch((error) => toast(`Erro: ${error.message}`)));
|
||||
}
|
||||
|
||||
function avancarSemana(campaignId, currentWeek) {
|
||||
const nextWeek = currentWeek + 1;
|
||||
if (!window.confirm(`Avançar de semana ${currentWeek} para semana ${nextWeek}? O limite diário aprovado muda para o novo patamar.`)) return;
|
||||
const button = el(`btn-advance-week-${campaignId}`);
|
||||
comCarregando(button, () => API.put(`/api/campaigns/${campaignId}/week`, { week: nextWeek })
|
||||
.then(() => { toast(`Campanha avançou para semana ${nextWeek}`); carregarCampanhas(); })
|
||||
.catch((error) => toast(`Erro: ${error.message}`)));
|
||||
}
|
||||
|
||||
async function carregarReplies() {
|
||||
try {
|
||||
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
|
||||
const campaignNames = new Map(campaigns.map((c) => [c.id, c.name]));
|
||||
const { pending } = await API.get('/api/campaigns/replies/pending');
|
||||
const container = el('pending-replies');
|
||||
if (!pending || !pending.length) {
|
||||
container.replaceChildren(h('p', { class: 'muted' }, 'Nenhuma resposta pendente.'));
|
||||
return;
|
||||
}
|
||||
container.replaceChildren(...pending.map((r) =>
|
||||
h('div', { class: 'item' },
|
||||
h('div', {},
|
||||
h('strong', {}, r.empresa || r.whatsapp || '—'),
|
||||
h('div', { class: 'sub' }, `${campaignNames.get(r.campaign_id) || ''} · ${dataBr(r.updated_at)}`)
|
||||
),
|
||||
h('div', { style: 'font-size:12.5px;color:var(--ink-2);flex:1' }, r.note || ''),
|
||||
h('button', {
|
||||
class: 'btn-ghost btn-sm', onclick: () => abrirStageDialog(r.lead_id)
|
||||
}, 'Classificar')
|
||||
)
|
||||
));
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
function abrirStageDialog(leadId) {
|
||||
stageLeadId = leadId;
|
||||
const inp = el('inp-stage');
|
||||
if (inp) {
|
||||
inp.replaceChildren(...STAGE_OPTIONS.map(([value, label]) => h('option', { value }, label)));
|
||||
inp.value = 'respondeu';
|
||||
}
|
||||
modalErro('modal-campaign-stage-erro', '');
|
||||
el('modal-campaign-stage').showModal();
|
||||
}
|
||||
|
||||
async function carregarStageClassification() {
|
||||
el('stage-classification').replaceChildren(
|
||||
h('p', { class: 'muted' }, 'Use a seção de respostas pendentes para classificar leads por estágio.')
|
||||
);
|
||||
}
|
||||
|
||||
async function carregarMetrics() {
|
||||
try {
|
||||
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
|
||||
const filters = el('metrics-filters');
|
||||
const display = el('campaign-metrics');
|
||||
|
||||
if (!campaigns.length) {
|
||||
filters.replaceChildren(h('p', { class: 'muted' }, 'Sem campanhas para métricas.'));
|
||||
display.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentos = [...new Set(campaigns.flatMap((c) => c.segments || []))];
|
||||
const variants = ['A', 'B', 'C'];
|
||||
|
||||
filters.replaceChildren(
|
||||
h('label', {}, 'Segmento',
|
||||
h('select', { id: 'filtro-metrics-segmento', onchange: () => carregarMetricsData() },
|
||||
h('option', { value: '' }, 'Todos'),
|
||||
...segmentos.map((s) => h('option', { value: s }, s))
|
||||
)
|
||||
),
|
||||
h('label', {}, 'Variante',
|
||||
h('select', { id: 'filtro-metrics-variant', onchange: () => carregarMetricsData() },
|
||||
h('option', { value: '' }, 'Todas'),
|
||||
...variants.map((v) => h('option', { value: v }, v))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
display.replaceChildren(h('p', { class: 'muted' }, 'Selecione uma campanha e clique em "Ver fila" para ver métricas detalhadas.'));
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
async function carregarMetricsData() {
|
||||
if (!selectedCampaignId) return;
|
||||
const segmento = (el('filtro-metrics-segmento') || {}).value || '';
|
||||
const variant = (el('filtro-metrics-variant') || {}).value || '';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmento) params.set('segmento', segmento);
|
||||
if (variant) params.set('variant', variant);
|
||||
const { metrics } = await API.get(`/api/campaigns/${selectedCampaignId}/metrics?${params}`);
|
||||
// Node.replaceChildren() stringifies non-Node args (null -> the text "null"),
|
||||
// unlike h() which filters null children. Build the list and drop nulls first.
|
||||
el('campaign-metrics').replaceChildren(
|
||||
...[
|
||||
h('div', { class: 'stat-row' },
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.previewed || 0)),
|
||||
h('span', {}, 'Visualizados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.approved || 0)),
|
||||
h('span', {}, 'Aprovados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.sent || 0)),
|
||||
h('span', {}, 'Enviados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.replies || 0)),
|
||||
h('span', {}, 'Responderam')
|
||||
)
|
||||
),
|
||||
metrics.firstResponseTimeAvg != null
|
||||
? h('p', { class: 'muted' }, `Tempo médio de primeira resposta: ${Math.round(metrics.firstResponseTimeAvg)} min`)
|
||||
: null
|
||||
].filter(Boolean)
|
||||
);
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
// Todos os listeners abaixo sao anexados UMA VEZ, aqui, sobre botoes fixos e
|
||||
// compartilhados do HTML (os modais nao sao recriados por campanha/lead).
|
||||
// Anexar de novo a cada abertura de modal (como antes) acumula handlers com
|
||||
// closures antigas e dispara acoes duplicadas/erradas ao confirmar.
|
||||
function bindEvents() {
|
||||
const confirmStartBtn = el('btn-confirm-start');
|
||||
if (confirmStartBtn) {
|
||||
confirmStartBtn.addEventListener('click', async () => {
|
||||
if (!startCampaignId) return;
|
||||
await comCarregando(confirmStartBtn, async () => {
|
||||
try {
|
||||
await API.post(`/api/campaigns/${startCampaignId}/start`);
|
||||
el('modal-campaign-start').close();
|
||||
toast('Campanha iniciada');
|
||||
await carregarCampanhas();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-start-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const saveStageBtn = el('btn-save-stage');
|
||||
if (saveStageBtn) {
|
||||
saveStageBtn.addEventListener('click', async () => {
|
||||
if (!stageLeadId) return;
|
||||
const stage = (el('inp-stage') || {}).value;
|
||||
if (!stage) return;
|
||||
await comCarregando(saveStageBtn, async () => {
|
||||
try {
|
||||
await API.put(`/api/campaigns/leads/${stageLeadId}/stage`, { stage });
|
||||
el('modal-campaign-stage').close();
|
||||
toast('Estágio atualizado');
|
||||
carregarReplies();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-stage-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleBtn = el('btn-schedule-appointment');
|
||||
if (scheduleBtn) {
|
||||
scheduleBtn.addEventListener('click', () => {
|
||||
modalErro('modal-campaign-appointment-erro', '');
|
||||
el('modal-campaign-appointment').showModal();
|
||||
});
|
||||
}
|
||||
|
||||
const confirmAppointmentBtn = el('btn-confirm-appointment');
|
||||
if (confirmAppointmentBtn) {
|
||||
confirmAppointmentBtn.addEventListener('click', async () => {
|
||||
const leadId = Number((el('inp-appointment-lead-id') || {}).value || 0);
|
||||
const kind = (el('inp-appointment-kind') || {}).value || 'demonstracao';
|
||||
const participant = (el('inp-appointment-participant') || {}).value || '';
|
||||
const company = (el('inp-appointment-company') || {}).value || '';
|
||||
const day = (el('inp-appointment-day') || {}).value || '';
|
||||
const time = (el('inp-appointment-time') || {}).value || '';
|
||||
if (!leadId) { toast('Selecione um lead'); return; }
|
||||
await comCarregando(confirmAppointmentBtn, async () => {
|
||||
try {
|
||||
await API.post(`/api/campaigns/leads/${leadId}/appointments`, { kind, participantName: participant, companyName: company, preferredDay: day, preferredTime: time });
|
||||
el('modal-campaign-appointment').close();
|
||||
toast('Agendamento salvo');
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-appointment-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ativar, invalidar, bindEvents };
|
||||
})();
|
||||
|
||||
window.Campanhas = window.campanhasInit;
|
||||
@@ -0,0 +1,407 @@
|
||||
/* 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 comCarregando(el('btn-extrair'), () => API.post('/api/extractions', { uf: u.uf }));
|
||||
toast(`Download e extração de ${u.uf} iniciados — a primeira execução usa cerca de 6 GB`);
|
||||
acompanharJob(jobId, `Baixando e 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 comCarregando(el('btn-capturar'), () => 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() {
|
||||
try {
|
||||
await comCarregando(el('btn-cancelar'), async () => {
|
||||
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`;
|
||||
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'
|
||||
? `Baixando e 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 };
|
||||
})();
|
||||
@@ -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,676 @@
|
||||
/* 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'
|
||||
};
|
||||
const WHATSAPP_STATUS_LABELS = {
|
||||
nao_consultado: 'Não consultado',
|
||||
confirmado: 'Confirmado',
|
||||
nao_cadastrado: 'Não cadastrado',
|
||||
erro_temporario: 'Erro temporário'
|
||||
};
|
||||
|
||||
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 telefone' : 'sem telefone');
|
||||
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 telefone', 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: '11', class: 'muted' }, 'Nenhum lead com este filtro.')));
|
||||
return;
|
||||
}
|
||||
|
||||
const abrirWhatsAppLead = async (event, lead) => {
|
||||
event.stopPropagation();
|
||||
if (!lead.whatsapp) {
|
||||
toast('Este lead não tem telefone cadastrado');
|
||||
return;
|
||||
}
|
||||
let leadCompleto = lead;
|
||||
await comCarregando(event.currentTarget, async () => {
|
||||
try {
|
||||
leadCompleto = await API.get(`/api/leads/${lead.id}`);
|
||||
} catch {
|
||||
leadCompleto = lead;
|
||||
}
|
||||
});
|
||||
const settingsButton = el('btn-settings');
|
||||
settingsButton.click();
|
||||
setTimeout(() => {
|
||||
window.MaquinaSelecionarLeadManual?.(leadCompleto);
|
||||
el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp;
|
||||
const empresa = leadCompleto.empresa || lead.empresa;
|
||||
const cidade = leadCompleto.cidade || lead.cidade;
|
||||
el('manual-mensagem').value = `Olá! Encontrei a ${empresa}${cidade ? ` em ${cidade}` : ''} e gostaria de apresentar meu serviço. 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', {}, whatsappStatusCell(lead)),
|
||||
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 whatsappCheckedAt(value) {
|
||||
if (!value) return '';
|
||||
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`;
|
||||
return Number.isNaN(Date.parse(normalized)) ? '' : dataBr(value);
|
||||
}
|
||||
|
||||
function whatsappStatusCell(lead) {
|
||||
const status = lead.whatsapp_status || 'nao_consultado';
|
||||
const detalhes = [];
|
||||
if (lead.whatsapp_numero_resolvido) detalhes.push(`Número confirmado: ${lead.whatsapp_numero_resolvido}`);
|
||||
const checkedAt = whatsappCheckedAt(lead.whatsapp_checked_at);
|
||||
if (checkedAt) detalhes.push(`Consultado em ${checkedAt}`);
|
||||
return h('div', { class: 'whatsapp-verification' },
|
||||
h('span', { class: `badge whatsapp-${status}` }, WHATSAPP_STATUS_LABELS[status] || status),
|
||||
detalhes.length ? h('div', { class: 'sub' }, detalhes.join(' · ')) : null);
|
||||
}
|
||||
|
||||
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('Telefone 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, 'Telefone 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, 'Telefone 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('Telefone', lead.whatsapp),
|
||||
linha('Status WhatsApp', WHATSAPP_STATUS_LABELS[lead.whatsapp_status] || lead.whatsapp_status || 'Não consultado'),
|
||||
linha('Número WhatsApp confirmado', lead.whatsapp_numero_resolvido),
|
||||
linha('WhatsApp consultado em', whatsappCheckedAt(lead.whatsapp_checked_at)),
|
||||
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: '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) => {
|
||||
const botao = h('button', {
|
||||
type: 'button', class: 'chip', onclick: async () => {
|
||||
await comCarregando(botao, async () => {
|
||||
atalho.set();
|
||||
await atualizar();
|
||||
await atualizarRecapExport();
|
||||
});
|
||||
}
|
||||
}, atalho.label);
|
||||
return botao;
|
||||
}));
|
||||
}
|
||||
|
||||
async function abrirModalExport(event) {
|
||||
const botao = event?.currentTarget || el('btn-exportar');
|
||||
await comCarregando(botao, async () => {
|
||||
const total = await atualizarRecapExport();
|
||||
if (!total) {
|
||||
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
|
||||
return;
|
||||
}
|
||||
renderAtalhosExport();
|
||||
|
||||
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 }
|
||||
};
|
||||
|
||||
try {
|
||||
const { jobId, totalEstimado } = await comCarregando(el('btn-confirmar-export'), () => 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 CSV + XLS', legado: 'Importação antiga' };
|
||||
|
||||
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', (event) => {
|
||||
comCarregando(event.currentTarget, async () => {
|
||||
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
|
||||
await atualizar();
|
||||
});
|
||||
});
|
||||
// pg-prev/pg-next: nao usam comCarregando de proposito. carregarTabela() ja
|
||||
// controla o atributo disabled destes botoes conforme a pagina atual
|
||||
// (limite inicio/fim); o finally do comCarregando reabilitaria o botao
|
||||
// incondicionalmente ao final, conflitando com esse controle de borda.
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
return { ativar, invalidar, bindEvents };
|
||||
})();
|
||||
|
||||
window.Tratamento = Tratamento;
|
||||
@@ -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>Máquina Comercial Pessoal - Login</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23f06a25%22/><text x=%2232%22 y=%2241%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2226%22 font-weight=%22700%22 fill=%22white%22>MC</text></svg>">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
|
||||
<main class="login-wrap">
|
||||
<form class="card login-card" id="form-login">
|
||||
<div class="brand login-brand">
|
||||
<span class="brand-mark brand-mark-lg brand-monogram" aria-hidden="true">MC</span>
|
||||
<span class="brand-name">Máquina Comercial Pessoal</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,18 @@
|
||||
import { loadDotEnv, paths } from '../src/env.mjs';
|
||||
import { downloadRfbDataset } from '../src/extract/rfb-downloader.mjs';
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
downloadRfbDataset({
|
||||
rawDir: paths.rfbRaw,
|
||||
onProgress: (progress) => {
|
||||
const pct = progress.pct == null ? '' : `${progress.pct}% `;
|
||||
console.log(`[RFB] ${pct}${progress.message}`);
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.cancelled) process.exitCode = 2;
|
||||
else console.log(`[RFB] snapshot ${result.snapshot} pronto: ${result.downloaded.length} baixados, ${result.skipped.length} reutilizados.`);
|
||||
}).catch((error) => {
|
||||
console.error(`[RFB] erro: ${error.stack || error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$target = Join-Path $root "Maquina-Comercial-Pessoal.exe"
|
||||
$source = Join-Path $root "launcher\Program.cs"
|
||||
$dotnet = Get-Command dotnet -ErrorAction SilentlyContinue
|
||||
$sdk = if ($dotnet) { & dotnet --list-sdks 2>$null } else { $null }
|
||||
|
||||
if ($sdk) {
|
||||
$project = Join-Path $root "launcher\Maquina-Comercial-Pessoal.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 "Maquina-Comercial-Pessoal.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,123 @@
|
||||
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';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createJobLease } from '../src/jobs/lease.mjs';
|
||||
|
||||
// CLI de captura (uso avancado / testes). A interface principal e o app:
|
||||
// node scripts/servidor.mjs (ou o Maquina-Comercial-Pessoal.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 active = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get();
|
||||
if (active) throw new Error(`ja existe uma captura em andamento (#${active.id})`);
|
||||
const runId = createCaptureRun(db, { ...params, segments: segments.map((s) => s.name) });
|
||||
const lease = createJobLease({ db, table: 'capture_runs', id: runId, ownerToken: randomUUID() });
|
||||
let summary;
|
||||
try {
|
||||
summary = await runCapture({
|
||||
db,
|
||||
runId,
|
||||
params,
|
||||
emit: (p) => console.log(`[captura] ${p.phase}: ${p.message || ''}`),
|
||||
log: (m) => console.log(`[captura] ${m}`)
|
||||
});
|
||||
} finally {
|
||||
lease.close();
|
||||
}
|
||||
|
||||
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,23 @@
|
||||
import { loadDotEnv, paths } from '../src/env.mjs';
|
||||
import { openDb, ensureSeeds } from '../src/db.mjs';
|
||||
import { runOutreachBatch } from '../src/outreach/run.mjs';
|
||||
import { createWhatsAppManager, hasWhatsAppSession } from '../src/whatsapp/manager.mjs';
|
||||
|
||||
// Dispara um lote pela sessao local existente, respeitando as mesmas regras do servidor.
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const log = (message) => console.log(`[outreach] ${message}`);
|
||||
|
||||
const db = openDb();
|
||||
await ensureSeeds(db, { log });
|
||||
const whatsapp = createWhatsAppManager({ authDir: paths.whatsappAuth, log });
|
||||
if (await hasWhatsAppSession(paths.whatsappAuth)) {
|
||||
await whatsapp.connect().catch((error) => log(`conexao local falhou: ${error.message}`));
|
||||
}
|
||||
|
||||
const resumo = await runOutreachBatch({ db, whatsapp, log });
|
||||
console.log(JSON.stringify(resumo, null, 2));
|
||||
|
||||
await whatsapp.close();
|
||||
db.close();
|
||||
@@ -0,0 +1,90 @@
|
||||
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';
|
||||
import { downloadRfbDataset, validateRfbRawFiles } from '../src/extract/rfb-downloader.mjs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createJobLease } from '../src/jobs/lease.mjs';
|
||||
|
||||
// Extrai a base CNPJ de uma UF inteira a partir dos zips da Receita Federal:
|
||||
// node scripts/extrair-base-cnpj.mjs --uf=BA
|
||||
// Por padrao baixa/retoma os arquivos necessarios antes de extrair. Use
|
||||
// --no-download somente quando a base local completa ja estiver disponivel.
|
||||
|
||||
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 active = db.prepare("SELECT id, uf FROM extractions WHERE status IN ('pending','running')").get();
|
||||
if (active) throw new Error(`ja existe uma extracao em andamento (#${active.id}, ${active.uf})`);
|
||||
const inserted = db.prepare(
|
||||
"INSERT INTO extractions (uf, status, started_at) VALUES (?, 'running', datetime('now'))"
|
||||
).run(uf);
|
||||
const extractionId = Number(inserted.lastInsertRowid);
|
||||
const lease = createJobLease({ db, table: 'extractions', id: extractionId, ownerToken: randomUUID() });
|
||||
|
||||
try {
|
||||
if (!args.has('no-download')) {
|
||||
await downloadRfbDataset({
|
||||
rawDir,
|
||||
onProgress: (p) => console.log(`[download] ${p.pct ?? 0}% ${p.message}`)
|
||||
});
|
||||
}
|
||||
await validateRfbRawFiles(rawDir);
|
||||
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 {
|
||||
lease.close();
|
||||
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,82 @@
|
||||
import { loadDotEnv, APP_PORT, paths } from '../src/env.mjs';
|
||||
import { openDb, ensureSeeds, recoverPendingOutreach, updateOutreachMessageStatus } 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 { pauseCampaignsAfterRestart, recoverReservedCampaignQueue } from '../src/outreach/campaign-worker.mjs';
|
||||
import { recordInboundReply } from '../src/outreach/inbound.mjs';
|
||||
import { createWhatsAppManager, hasWhatsAppSession } from '../src/whatsapp/manager.mjs';
|
||||
|
||||
// Servidor local da Maquina Comercial Pessoal.
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const log = (message) => console.log(`[servidor] ${message}`);
|
||||
|
||||
const db = openDb();
|
||||
await ensureSeeds(db, { log });
|
||||
recoverOrphans(db, log);
|
||||
recoverPendingOutreach(db);
|
||||
pauseCampaignsAfterRestart(db);
|
||||
recoverReservedCampaignQueue(db);
|
||||
|
||||
const jobs = createJobManager({ db });
|
||||
const whatsapp = createWhatsAppManager({
|
||||
authDir: paths.whatsappAuth,
|
||||
log,
|
||||
onMessageStatus: (event) => updateOutreachMessageStatus(db, event),
|
||||
onInboundMessage: (event) => recordInboundReply(db, event)
|
||||
});
|
||||
if (await hasWhatsAppSession(paths.whatsappAuth)) {
|
||||
whatsapp.connect().catch((error) => log(`whatsapp: reconexao inicial falhou: ${error.message}`));
|
||||
}
|
||||
const server = createAppServer({ db, jobs, whatsapp, log });
|
||||
const stopScheduler = startOutreachScheduler({ db, whatsapp, log });
|
||||
|
||||
const port = APP_PORT;
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
log(`Maquina Comercial Pessoal rodando em http://127.0.0.1:${port}`);
|
||||
log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown() {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log('encerrando...');
|
||||
const watchdog = setTimeout(() => process.exit(1), 30_000);
|
||||
watchdog.unref();
|
||||
|
||||
const shutdownTasks = [
|
||||
stopScheduler().catch((error) => log(`outreach: falha ao encerrar scheduler: ${error.message}`)),
|
||||
jobs.close().catch((error) => log(`jobs: falha ao encerrar: ${error.message}`)),
|
||||
whatsapp.close().catch((error) => log(`whatsapp: falha ao encerrar: ${error.message}`))
|
||||
];
|
||||
let resourceTimer;
|
||||
const resourcesTimedOut = await Promise.race([
|
||||
Promise.allSettled(shutdownTasks).then(() => false),
|
||||
new Promise((resolve) => {
|
||||
resourceTimer = setTimeout(() => resolve(true), 20_000);
|
||||
resourceTimer.unref();
|
||||
})
|
||||
]);
|
||||
clearTimeout(resourceTimer);
|
||||
if (resourcesTimedOut) log('encerramento dos recursos excedeu o prazo; prosseguindo');
|
||||
|
||||
recoverOrphans(db, log);
|
||||
let httpTimer;
|
||||
const httpTimedOut = await Promise.race([
|
||||
new Promise((resolve) => server.close(resolve)).then(() => false),
|
||||
new Promise((resolve) => {
|
||||
httpTimer = setTimeout(() => resolve(true), 5_000);
|
||||
httpTimer.unref();
|
||||
})
|
||||
]);
|
||||
clearTimeout(httpTimer);
|
||||
if (httpTimedOut) log('encerramento HTTP excedeu o prazo; prosseguindo');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
@@ -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,124 @@
|
||||
import { loadDotEnv } from '../env.mjs';
|
||||
import { openDb } from '../db.mjs';
|
||||
import { runCapture } from './runner.mjs';
|
||||
import { createJobIO } from '../jobs/protocol.mjs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createJobLease } from '../jobs/lease.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');
|
||||
let db;
|
||||
let lease;
|
||||
try {
|
||||
db = openDb();
|
||||
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
|
||||
if (!run) throw new Error(`capture_run #${runId} nao encontrado`);
|
||||
lease = createJobLease({ db, table: 'capture_runs', id: runId, ownerToken: args.get('owner-token') || randomUUID() });
|
||||
|
||||
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
|
||||
});
|
||||
} catch (error) {
|
||||
if (!db) db = openDb();
|
||||
db.prepare(
|
||||
"UPDATE capture_runs SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')"
|
||||
).run(String(error.message || error), runId);
|
||||
throw error;
|
||||
} finally {
|
||||
lease?.close();
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { UA_HEADERS, UF_NAMES } from '../sources/common.mjs';
|
||||
import { fetchJsonWithTimeout } from '../util/fetch.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, data: results } = await fetchJsonWithTimeout(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) throw new Error(`Nominatim geocode HTTP ${response.status} para ${cidade}/${uf}`);
|
||||
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);
|
||||
}
|
||||
+1168
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
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.APP_DB_PATH || path.join(projectRoot, 'data', 'maquina-pessoal.db'),
|
||||
whatsappAuth: process.env.APP_WHATSAPP_AUTH_DIR || path.join(projectRoot, 'data', 'runtime', 'whatsapp-auth'),
|
||||
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 APP_PORT = envNumber('APP_PORT', 3910);
|
||||
|
||||
function refreshEnvDerivedValues() {
|
||||
paths.db = process.env.APP_DB_PATH || path.join(projectRoot, 'data', 'maquina-pessoal.db');
|
||||
paths.whatsappAuth = process.env.APP_WHATSAPP_AUTH_DIR || path.join(projectRoot, 'data', 'runtime', 'whatsapp-auth');
|
||||
paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj');
|
||||
paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw');
|
||||
APP_PORT = envNumber('APP_PORT', 3910);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { csvEscape, excelTextCell, neutralizeSpreadsheetFormula, BOM } from '../util/csv.mjs';
|
||||
import { iterateLeads } from '../treatment/queries.mjs';
|
||||
|
||||
// Exportacao "planilha": CSV + .xls gemeo, ambos a prova de Excel — os campos
|
||||
// numericos longos (whatsapp, cnpj, cnae) sao gravados como TEXTO para nunca
|
||||
// virarem notacao cientifica. Mesmo formato dos arquivos TRATADOS legados:
|
||||
// CSV -> formula ="valor"
|
||||
// XLS -> tabela HTML com mso-number-format:"\@"
|
||||
|
||||
const HEADERS = [
|
||||
'empresa', 'whatsapp', 'ddd', 'cidade', 'uf', 'segmento', 'segmento_legivel', 'interesse',
|
||||
'cnpj', 'cnae', 'telefone_valido', 'motivo_telefone', 'cnpj_valido', 'empresa_valida',
|
||||
'motivo_empresa', 'score', 'classificacao', 'motivo_classificacao', 'chave_duplicidade', 'linha_origem',
|
||||
'score_qualidade', 'score_comercial', 'prioridade', 'produto_recomendado'
|
||||
];
|
||||
|
||||
const TEXT_COLUMNS = new Set(['whatsapp', 'ddd', 'cnpj', 'cnae']);
|
||||
|
||||
function leadToExportRow(lead) {
|
||||
return {
|
||||
empresa: lead.empresa,
|
||||
whatsapp: lead.whatsapp,
|
||||
ddd: lead.ddd,
|
||||
cidade: lead.cidade,
|
||||
uf: lead.uf,
|
||||
segmento: lead.segmento,
|
||||
segmento_legivel: lead.segmento_label,
|
||||
interesse: lead.interesse,
|
||||
cnpj: lead.cnpj,
|
||||
cnae: lead.cnae,
|
||||
telefone_valido: lead.telefone_valido ? 'sim' : 'nao',
|
||||
motivo_telefone: lead.telefone_valido ? 'telefone valido' : 'telefone invalido ou vazio',
|
||||
cnpj_valido: lead.cnpj_valido ? 'sim' : 'nao',
|
||||
empresa_valida: lead.empresa_valida ? 'sim' : 'nao',
|
||||
motivo_empresa: lead.empresa_valida ? 'empresa valida' : (lead.motivo_classificacao || 'empresa invalida'),
|
||||
score: lead.score,
|
||||
classificacao: lead.classificacao,
|
||||
motivo_classificacao: lead.motivo_classificacao,
|
||||
chave_duplicidade: lead.dedup_key,
|
||||
linha_origem: lead.id,
|
||||
score_qualidade: lead.score_qualidade,
|
||||
score_comercial: lead.score_comercial,
|
||||
prioridade: lead.prioridade,
|
||||
produto_recomendado: lead.produto_principal
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
async function writeStream(stream, text) {
|
||||
if (!stream.write(text)) {
|
||||
await new Promise((resolve) => stream.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportExcel({ db, filtro, dir, onProgress = () => {}, collectIds }) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const baseName = 'leads-excel';
|
||||
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(neutralizeSpreadsheetFormula(row[h]))));
|
||||
await writeStream(csv, csvCells.join(';') + '\n');
|
||||
|
||||
const xlsCells = HEADERS.map((h) =>
|
||||
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(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,87 @@
|
||||
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 { randomUUID } from 'node:crypto';
|
||||
import { createJobLease } from '../jobs/lease.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
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const exportId = Number(args.get('export-id'));
|
||||
if (!exportId) throw new Error('export-job: --export-id obrigatorio');
|
||||
|
||||
const db = openDb();
|
||||
let lease;
|
||||
try {
|
||||
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}`);
|
||||
lease = createJobLease({ db, table: 'exports', id: exportId, ownerToken: args.get('owner-token') || randomUUID() });
|
||||
const filtro = JSON.parse(row.filtro_json || '{}');
|
||||
const params = JSON.parse(row.params_json || '{}');
|
||||
io.emit({ phase: row.tipo, message: 'gerando arquivos...' });
|
||||
const dir = path.join(paths.exports, `export-${exportId}`);
|
||||
const leadIds = [];
|
||||
const result = await generator({
|
||||
db,
|
||||
filtro,
|
||||
params,
|
||||
dir,
|
||||
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 = ? AND status IN ('pending','running')")
|
||||
.run(String(error.message || error), exportId);
|
||||
throw error;
|
||||
} finally {
|
||||
lease?.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import path from 'node:path';
|
||||
import { loadDotEnv, paths } from '../env.mjs';
|
||||
import { openDb } from '../db.mjs';
|
||||
import { extractUf } from './rfb-extractor.mjs';
|
||||
import { downloadRfbDataset, validateRfbRawFiles } from './rfb-downloader.mjs';
|
||||
import { createJobIO } from '../jobs/protocol.mjs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createJobLease } from '../jobs/lease.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();
|
||||
let lease;
|
||||
try {
|
||||
const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId);
|
||||
if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`);
|
||||
const uf = extraction.uf;
|
||||
lease = createJobLease({ db, table: 'extractions', id: extractionId, ownerToken: args.get('owner-token') || randomUUID() });
|
||||
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
|
||||
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
|
||||
const download = await downloadRfbDataset({
|
||||
rawDir: paths.rfbRaw,
|
||||
isCancelled: io.isCancelled,
|
||||
onProgress: (progress) => {
|
||||
const mapped = { ...progress, pct: Math.round((progress.pct || 0) * 0.45) };
|
||||
io.emit(mapped);
|
||||
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(mapped), extractionId);
|
||||
}
|
||||
});
|
||||
if (download.cancelled) {
|
||||
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?").run(extractionId);
|
||||
io.done({ cancelled: true, snapshot: download.snapshot, downloaded: download.downloaded.length });
|
||||
return;
|
||||
}
|
||||
await validateRfbRawFiles(paths.rfbRaw);
|
||||
const result = await extractUf({
|
||||
uf,
|
||||
rawDir: paths.rfbRaw,
|
||||
outCsv,
|
||||
checkpointPath,
|
||||
db,
|
||||
log: (m) => io.log(m),
|
||||
isCancelled: io.isCancelled,
|
||||
onProgress: (p) => {
|
||||
const mapped = { ...p, pct: 45 + Math.round((p.pct || 0) * 0.55) };
|
||||
io.emit(mapped);
|
||||
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(mapped), 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({
|
||||
snapshot: download.snapshot,
|
||||
downloaded: download.downloaded.length,
|
||||
skipped: download.skipped.length,
|
||||
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 = ? AND status IN ('pending','running')")
|
||||
.run(String(error.message || error), extractionId);
|
||||
throw error;
|
||||
} finally {
|
||||
lease?.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fetchTextWithTimeout } from '../util/fetch.mjs';
|
||||
|
||||
export const REQUIRED_RFB_FILES = [
|
||||
'Municipios.zip',
|
||||
...Array.from({ length: 10 }, (_, index) => `Empresas${index}.zip`),
|
||||
...Array.from({ length: 10 }, (_, index) => `Estabelecimentos${index}.zip`)
|
||||
];
|
||||
|
||||
export const DEFAULT_RFB_INDEX_URL = 'https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/';
|
||||
const DEFAULT_MIN_ZIP_BYTES = 1024;
|
||||
const MANIFEST_FILE = '.rfb-snapshot.json';
|
||||
const BACKUP_DIR = '.rfb-backup';
|
||||
const INCOMPLETE_DIR = '.rfb-incomplete';
|
||||
const EXTRACTION_MARGIN_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
// Covers the compressed download, extraction checkpoints and generated CSV.
|
||||
export const DEFAULT_MINIMUM_FREE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
|
||||
class CancelledError extends Error {}
|
||||
|
||||
export function parseLatestSnapshot(html) {
|
||||
const dates = [...String(html).matchAll(/href\s*=\s*["'](\d{4}-\d{2}-\d{2})\/["']/gi)]
|
||||
.map((match) => match[1])
|
||||
.filter((date) => !Number.isNaN(Date.parse(`${date}T00:00:00Z`)));
|
||||
if (!dates.length) throw new Error('nenhum snapshot YYYY-MM-DD encontrado no indice da RFB');
|
||||
return dates.sort().at(-1);
|
||||
}
|
||||
|
||||
async function fetchResponse(fetchImpl, url, options = {}, timeoutMs = 25_000) {
|
||||
const controller = new AbortController();
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
||||
const timer = setTimeout(() => controller.abort(new Error(`timeout apos ${timeoutMs}ms`)), timeoutMs);
|
||||
timer.unref?.();
|
||||
try {
|
||||
return await fetchImpl(url, { ...options, signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverLatestSnapshot(indexUrl = DEFAULT_RFB_INDEX_URL, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
||||
const normalizedUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
|
||||
const { response, text } = await fetchTextWithTimeout(normalizedUrl, { fetch: fetchImpl }, 25_000, { maxBytes: 2_000_000 });
|
||||
if (!response.ok) throw new Error(`indice da RFB respondeu HTTP ${response.status}`);
|
||||
return parseLatestSnapshot(text);
|
||||
}
|
||||
|
||||
async function inspectZip(file, minZipBytes, expectedSize) {
|
||||
const stat = await fsp.stat(file);
|
||||
if (stat.size < minZipBytes) throw new Error(`${path.basename(file)} invalido: tamanho ${stat.size}, minimo ${minZipBytes}`);
|
||||
if (expectedSize != null && stat.size !== expectedSize) {
|
||||
throw new Error(`${path.basename(file)} incompleto: ${stat.size} bytes, esperado ${expectedSize}`);
|
||||
}
|
||||
const handle = await fsp.open(file, 'r');
|
||||
try {
|
||||
const signature = Buffer.alloc(2);
|
||||
await handle.read(signature, 0, 2, 0);
|
||||
if (signature.toString('ascii') !== 'PK') throw new Error(`${path.basename(file)} invalido: assinatura ZIP PK ausente`);
|
||||
const tailSize = Math.min(stat.size, 65_557);
|
||||
const tail = Buffer.alloc(tailSize);
|
||||
await handle.read(tail, 0, tailSize, stat.size - tailSize);
|
||||
let eocdOffset = -1;
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) !== 0x06054b50) continue;
|
||||
const commentLength = tail.readUInt16LE(offset + 20);
|
||||
if (offset + 22 + commentLength === tail.length) {
|
||||
eocdOffset = offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocdOffset < 0) throw new Error(`${path.basename(file)} invalido: registro EOCD ausente ou truncado`);
|
||||
|
||||
const disk = tail.readUInt16LE(eocdOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(eocdOffset + 6);
|
||||
const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
|
||||
const entriesTotal = tail.readUInt16LE(eocdOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(eocdOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(eocdOffset + 16);
|
||||
if (disk === 0xffff || centralDisk === 0xffff || entriesOnDisk === 0xffff || entriesTotal === 0xffff
|
||||
|| centralSize === 0xffffffff || centralOffset === 0xffffffff) {
|
||||
throw new Error(`${path.basename(file)} invalido: ZIP64 nao suportado`);
|
||||
}
|
||||
if (disk !== 0 || centralDisk !== 0) throw new Error(`${path.basename(file)} invalido: ZIP multi-disco nao suportado`);
|
||||
if (entriesOnDisk !== entriesTotal) throw new Error(`${path.basename(file)} invalido: contagem do diretorio central divergente`);
|
||||
const absoluteEocd = stat.size - tailSize + eocdOffset;
|
||||
if (centralOffset + centralSize > absoluteEocd) {
|
||||
throw new Error(`${path.basename(file)} invalido: diretorio central ultrapassa o EOCD`);
|
||||
}
|
||||
if (entriesTotal > 0) {
|
||||
const centralSignature = Buffer.alloc(4);
|
||||
const { bytesRead } = await handle.read(centralSignature, 0, 4, centralOffset);
|
||||
if (bytesRead !== 4 || centralSignature.readUInt32LE(0) !== 0x02014b50) {
|
||||
throw new Error(`${path.basename(file)} invalido: assinatura do diretorio central ausente`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function validateRfbRawFiles(rawDir, { minZipBytes = DEFAULT_MIN_ZIP_BYTES, sizes = {} } = {}) {
|
||||
const problems = [];
|
||||
const files = [];
|
||||
for (const name of REQUIRED_RFB_FILES) {
|
||||
try {
|
||||
const expected = typeof sizes[name] === 'number' ? sizes[name] : sizes[name]?.size;
|
||||
const size = await inspectZip(path.join(rawDir, name), minZipBytes, expected);
|
||||
files.push({ name, size });
|
||||
} catch (error) {
|
||||
problems.push(`${name}: ${error.code === 'ENOENT' ? 'ausente' : error.message}`);
|
||||
}
|
||||
}
|
||||
if (problems.length) {
|
||||
throw new Error(`base RFB invalida; exige os 21 arquivos ZIP: ${problems.join('; ')}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readManifest(rawDir) {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(path.join(rawDir, MANIFEST_FILE), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(rawDir, manifest) {
|
||||
const manifestPath = path.join(rawDir, MANIFEST_FILE);
|
||||
const tempPath = `${manifestPath}.tmp`;
|
||||
await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
await fsp.rename(tempPath, manifestPath);
|
||||
}
|
||||
|
||||
function totalFromResponse(response, offset) {
|
||||
const range = response.headers.get('content-range');
|
||||
if (range) {
|
||||
const match = /\/([0-9]+)$/.exec(range);
|
||||
if (match) return Number(match[1]);
|
||||
}
|
||||
const length = Number(response.headers.get('content-length'));
|
||||
return Number.isFinite(length) && length >= 0 ? offset + length : null;
|
||||
}
|
||||
|
||||
function responseValidator(response) {
|
||||
const etag = response.headers.get('etag');
|
||||
const lastModified = response.headers.get('last-modified');
|
||||
return { validator: etag || lastModified || null, etag, lastModified };
|
||||
}
|
||||
|
||||
function numericContentLength(response) {
|
||||
const raw = response.headers.get('content-length');
|
||||
if (!/^\d+$/.test(raw || '')) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
async function downloadOne({ rawDir, snapshotUrl, name, fetchImpl, minZipBytes, isCancelled, onProgress, idleTimeoutMs, manifest }) {
|
||||
const finalPath = path.join(rawDir, name);
|
||||
const partPath = `${finalPath}.part`;
|
||||
let offset = 0;
|
||||
try {
|
||||
offset = (await fsp.stat(partPath)).size;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
manifest.partials ||= {};
|
||||
const saved = manifest.partials[name];
|
||||
const mayResume = offset > 0 && saved?.validator && Number.isSafeInteger(saved?.totalSize)
|
||||
&& saved.totalSize > offset;
|
||||
if (!mayResume) offset = 0;
|
||||
|
||||
let response;
|
||||
let expectedSize;
|
||||
let metadata;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const headers = offset ? { Range: `bytes=${offset}-`, 'If-Range': saved.validator } : {};
|
||||
response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { headers });
|
||||
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
|
||||
if (!response.body) throw new Error(`${name}: resposta sem corpo`);
|
||||
const current = responseValidator(response);
|
||||
let invalid = false;
|
||||
let reason = '';
|
||||
if (offset) {
|
||||
const range = response.headers.get('content-range');
|
||||
const match = /^bytes (\d+)-(\d+)\/(\d+)$/i.exec(range || '');
|
||||
const length = numericContentLength(response);
|
||||
expectedSize = match ? Number(match[3]) : null;
|
||||
invalid = response.status !== 206
|
||||
|| !match
|
||||
|| Number(match[1]) !== offset
|
||||
|| Number(match[2]) < Number(match[1])
|
||||
|| expectedSize !== saved.totalSize
|
||||
|| length !== Number(match[2]) - Number(match[1]) + 1
|
||||
|| current.validator !== saved.validator;
|
||||
reason = `Content-Range/validator invalido para retomada: ${range || 'ausente'}`;
|
||||
} else {
|
||||
const length = numericContentLength(response);
|
||||
invalid = response.status !== 200 || length === null;
|
||||
expectedSize = length;
|
||||
reason = response.status === 206
|
||||
? `${name}: Content-Range inesperado sem retomada`
|
||||
: `${name}: resposta final sem Content-Length numerico confiavel`;
|
||||
}
|
||||
if (!invalid) {
|
||||
metadata = { ...current, totalSize: expectedSize };
|
||||
break;
|
||||
}
|
||||
await response.body.cancel?.();
|
||||
if (!offset || attempt > 0) throw new Error(reason);
|
||||
await fsp.rm(partPath, { force: true });
|
||||
delete manifest.partials[name];
|
||||
await writeManifest(rawDir, manifest);
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
manifest.partials[name] = metadata;
|
||||
await writeManifest(rawDir, manifest);
|
||||
let received = offset;
|
||||
let idleTimer;
|
||||
let lastProgressAt = 0;
|
||||
const emitProgress = (force = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastProgressAt < 500) return;
|
||||
lastProgressAt = now;
|
||||
onProgress({ phase: 'download', file: name, bytes: received, totalBytes: expectedSize, message: `baixando ${name}` });
|
||||
};
|
||||
emitProgress(true);
|
||||
const guard = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
if (isCancelled()) return callback(new CancelledError('download cancelado'));
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(
|
||||
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
|
||||
idleTimeoutMs
|
||||
);
|
||||
idleTimer.unref?.();
|
||||
received += chunk.length;
|
||||
emitProgress();
|
||||
callback(null, chunk);
|
||||
}
|
||||
});
|
||||
try {
|
||||
idleTimer = setTimeout(
|
||||
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
|
||||
idleTimeoutMs
|
||||
);
|
||||
idleTimer.unref?.();
|
||||
await pipeline(Readable.fromWeb(response.body), guard, fs.createWriteStream(partPath, { flags: offset ? 'a' : 'w' }));
|
||||
} catch (error) {
|
||||
if (error instanceof CancelledError || isCancelled()) throw new CancelledError('download cancelado');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
await inspectZip(partPath, minZipBytes, expectedSize);
|
||||
emitProgress(true);
|
||||
await fsp.rename(partPath, finalPath);
|
||||
delete manifest.partials[name];
|
||||
return { name, size: received, etag: metadata.etag, lastModified: metadata.lastModified };
|
||||
}
|
||||
|
||||
async function ensureFreeSpace(rawDir, statfs, minimumFreeBytes) {
|
||||
const stats = await statfs(rawDir);
|
||||
const free = BigInt(stats.bavail) * BigInt(stats.bsize);
|
||||
if (free >= BigInt(minimumFreeBytes)) return;
|
||||
const error = new Error(`espaco livre insuficiente: exige ${minimumFreeBytes} bytes`);
|
||||
error.code = 'ENOSPC';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function readValidDataset(dir, minZipBytes) {
|
||||
const manifest = await readManifest(dir);
|
||||
if (!manifest?.complete) return null;
|
||||
const hasSizes = REQUIRED_RFB_FILES.every((name) => {
|
||||
const metadata = manifest.files?.[name];
|
||||
const size = typeof metadata === 'number' ? metadata : metadata?.size;
|
||||
return Number.isSafeInteger(size) && size >= minZipBytes;
|
||||
});
|
||||
if (!hasSizes) return null;
|
||||
try {
|
||||
await validateRfbRawFiles(dir, { minZipBytes, sizes: manifest.files });
|
||||
return manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveIncompleteDataset(rawDir, { includeParts = false } = {}) {
|
||||
let archivePath = path.join(rawDir, INCOMPLETE_DIR);
|
||||
try {
|
||||
await fsp.access(archivePath);
|
||||
archivePath = path.join(rawDir, `${INCOMPLETE_DIR}-${Date.now()}`);
|
||||
} catch {}
|
||||
await fsp.mkdir(archivePath);
|
||||
let moved = 0;
|
||||
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
|
||||
for (const suffix of ['', ...(includeParts && name !== MANIFEST_FILE ? ['.part'] : [])]) {
|
||||
try {
|
||||
await fsp.rename(path.join(rawDir, `${name}${suffix}`), path.join(archivePath, `${name}${suffix}`));
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved) await fsp.rm(archivePath, { recursive: true, force: true });
|
||||
return moved ? archivePath : null;
|
||||
}
|
||||
|
||||
async function backupPreviousDataset(rawDir, minZipBytes) {
|
||||
const backupPath = path.join(rawDir, BACKUP_DIR);
|
||||
if (await readValidDataset(backupPath, minZipBytes)) return backupPath;
|
||||
await fsp.rm(backupPath, { recursive: true, force: true });
|
||||
if (!await readValidDataset(rawDir, minZipBytes)) return null;
|
||||
const staging = `${backupPath}.tmp`;
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
await fsp.mkdir(staging);
|
||||
const moved = [];
|
||||
try {
|
||||
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
|
||||
const source = path.join(rawDir, name);
|
||||
try {
|
||||
await fsp.rename(source, path.join(staging, name));
|
||||
moved.push(name);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
await fsp.rename(staging, backupPath);
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
for (const name of moved.reverse()) {
|
||||
await fsp.rename(path.join(staging, name), path.join(rawDir, name)).catch(() => {});
|
||||
}
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(rawDir, minZipBytes) {
|
||||
const backupPath = path.join(rawDir, BACKUP_DIR);
|
||||
const manifest = await readValidDataset(backupPath, minZipBytes);
|
||||
if (!manifest) return null;
|
||||
await archiveIncompleteDataset(rawDir);
|
||||
const staging = path.join(rawDir, `${BACKUP_DIR}-restore.tmp`);
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
await fsp.mkdir(staging);
|
||||
try {
|
||||
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
|
||||
await fsp.copyFile(path.join(backupPath, name), path.join(staging, name));
|
||||
}
|
||||
for (const name of REQUIRED_RFB_FILES) {
|
||||
await fsp.rename(path.join(staging, name), path.join(rawDir, name));
|
||||
}
|
||||
await fsp.rename(path.join(staging, MANIFEST_FILE), path.join(rawDir, MANIFEST_FILE));
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }) {
|
||||
for (const name of REQUIRED_RFB_FILES) {
|
||||
const metadata = previous.files[name];
|
||||
if (!metadata || typeof metadata !== 'object') continue;
|
||||
const validator = metadata.etag || metadata.lastModified;
|
||||
if (!validator) continue;
|
||||
const headers = metadata.etag
|
||||
? { 'If-None-Match': metadata.etag }
|
||||
: { 'If-Modified-Since': metadata.lastModified };
|
||||
const response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { method: 'HEAD', headers });
|
||||
if (response.status === 304) continue;
|
||||
if (!response.ok) throw new Error(`${name}: HEAD respondeu HTTP ${response.status}`);
|
||||
const length = numericContentLength(response);
|
||||
const current = responseValidator(response);
|
||||
const changed = length !== metadata.size
|
||||
|| (metadata.etag && current.etag !== metadata.etag)
|
||||
|| (metadata.lastModified && current.lastModified !== metadata.lastModified);
|
||||
if (changed) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function usefulPresentBytes(rawDir, previous, sameSnapshot, minZipBytes) {
|
||||
if (!sameSnapshot || !previous || previous.complete) return 0;
|
||||
let useful = 0;
|
||||
for (const name of REQUIRED_RFB_FILES) {
|
||||
const metadata = previous.files?.[name];
|
||||
const size = typeof metadata === 'number' ? metadata : metadata?.size;
|
||||
if (Number.isSafeInteger(size) && size >= minZipBytes) {
|
||||
try {
|
||||
await inspectZip(path.join(rawDir, name), minZipBytes, size);
|
||||
useful += size;
|
||||
} catch {}
|
||||
}
|
||||
const partial = previous.partials?.[name];
|
||||
if (!partial?.validator || !Number.isSafeInteger(partial.totalSize)) continue;
|
||||
try {
|
||||
const partSize = (await fsp.stat(path.join(rawDir, `${name}.part`))).size;
|
||||
if (partSize > 0 && partSize < partial.totalSize) useful += partSize;
|
||||
} catch {}
|
||||
}
|
||||
return useful;
|
||||
}
|
||||
|
||||
export async function downloadRfbDataset({
|
||||
rawDir,
|
||||
indexUrl = DEFAULT_RFB_INDEX_URL,
|
||||
fetch: fetchImpl = globalThis.fetch,
|
||||
onProgress = () => {},
|
||||
isCancelled = () => false,
|
||||
concurrency = 1,
|
||||
minZipBytes = DEFAULT_MIN_ZIP_BYTES,
|
||||
idleTimeoutMs = 60_000,
|
||||
statfs = fsp.statfs,
|
||||
minimumFreeBytes = DEFAULT_MINIMUM_FREE_BYTES
|
||||
}) {
|
||||
if (!rawDir) throw new Error('rawDir obrigatorio');
|
||||
if (concurrency < 1 || concurrency > 2) throw new Error('concurrency deve ser 1 ou 2');
|
||||
await fsp.mkdir(rawDir, { recursive: true });
|
||||
const previous = await readManifest(rawDir);
|
||||
let cachedFiles = null;
|
||||
const hasCompleteSizes = previous?.complete && REQUIRED_RFB_FILES.every((name) => {
|
||||
const metadata = previous.files?.[name];
|
||||
const size = typeof metadata === 'number' ? metadata : metadata?.size;
|
||||
return Number.isSafeInteger(size) && size >= minZipBytes;
|
||||
});
|
||||
if (hasCompleteSizes) {
|
||||
try {
|
||||
cachedFiles = await validateRfbRawFiles(rawDir, { minZipBytes, sizes: previous.files });
|
||||
} catch {
|
||||
cachedFiles = null;
|
||||
}
|
||||
}
|
||||
onProgress({ phase: 'download', pct: 0, message: 'verificando snapshot da RFB' });
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = await discoverLatestSnapshot(indexUrl, { fetch: fetchImpl });
|
||||
} catch (error) {
|
||||
if (!cachedFiles) {
|
||||
const restored = await restoreBackup(rawDir, minZipBytes);
|
||||
if (!restored) throw error;
|
||||
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; backup ${restored.snapshot} restaurado e validado` });
|
||||
return {
|
||||
cancelled: false,
|
||||
offline: true,
|
||||
snapshot: restored.snapshot,
|
||||
downloaded: [],
|
||||
skipped: REQUIRED_RFB_FILES.slice(),
|
||||
files: restored.files
|
||||
};
|
||||
}
|
||||
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; usando snapshot local validado ${previous.snapshot}` });
|
||||
return {
|
||||
cancelled: false,
|
||||
offline: true,
|
||||
snapshot: previous.snapshot,
|
||||
downloaded: [],
|
||||
skipped: REQUIRED_RFB_FILES.slice(),
|
||||
files: previous.files
|
||||
};
|
||||
}
|
||||
const normalizedIndexUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
|
||||
const snapshotUrl = new URL(`${snapshot}/`, normalizedIndexUrl);
|
||||
const sameSnapshot = previous?.snapshot === snapshot;
|
||||
let forceRefresh = false;
|
||||
if (sameSnapshot && cachedFiles) {
|
||||
try {
|
||||
forceRefresh = Boolean(await revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }));
|
||||
} catch {
|
||||
onProgress({ phase: 'download', pct: 100, message: `revalidacao remota indisponivel; usando snapshot local validado ${snapshot}` });
|
||||
return {
|
||||
cancelled: false,
|
||||
offline: true,
|
||||
snapshot,
|
||||
downloaded: [],
|
||||
skipped: REQUIRED_RFB_FILES.slice(),
|
||||
files: previous.files
|
||||
};
|
||||
}
|
||||
}
|
||||
if (sameSnapshot && cachedFiles && !forceRefresh) {
|
||||
onProgress({ phase: 'download', pct: 100, message: `snapshot ${snapshot} ja esta completo e validado` });
|
||||
return {
|
||||
cancelled: false,
|
||||
offline: false,
|
||||
snapshot,
|
||||
downloaded: [],
|
||||
skipped: REQUIRED_RFB_FILES.slice(),
|
||||
files: previous.files
|
||||
};
|
||||
}
|
||||
const usefulBytes = await usefulPresentBytes(rawDir, previous, sameSnapshot && !forceRefresh, minZipBytes);
|
||||
const requiredFree = sameSnapshot && !forceRefresh
|
||||
? Math.max(EXTRACTION_MARGIN_BYTES, minimumFreeBytes - usefulBytes)
|
||||
: minimumFreeBytes;
|
||||
await ensureFreeSpace(rawDir, statfs, requiredFree);
|
||||
onProgress({ phase: 'download', pct: 0, message: `verificando espaco necessario (${requiredFree} bytes)` });
|
||||
const changed = !sameSnapshot || forceRefresh;
|
||||
if (changed) {
|
||||
await backupPreviousDataset(rawDir, minZipBytes);
|
||||
if (previous && !previous.complete) await archiveIncompleteDataset(rawDir, { includeParts: true });
|
||||
for (const name of REQUIRED_RFB_FILES) {
|
||||
await fsp.rm(path.join(rawDir, name), { force: true });
|
||||
await fsp.rm(path.join(rawDir, `${name}.part`), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
snapshot,
|
||||
complete: false,
|
||||
files: sameSnapshot && !forceRefresh && previous?.files && typeof previous.files === 'object' ? { ...previous.files } : {},
|
||||
partials: sameSnapshot && !forceRefresh && previous?.partials && typeof previous.partials === 'object' ? { ...previous.partials } : {}
|
||||
};
|
||||
await writeManifest(rawDir, manifest);
|
||||
|
||||
const downloaded = [];
|
||||
const skipped = [];
|
||||
try {
|
||||
for (let index = 0; index < REQUIRED_RFB_FILES.length; index += 1) {
|
||||
if (isCancelled()) throw new CancelledError('download cancelado');
|
||||
const name = REQUIRED_RFB_FILES[index];
|
||||
try {
|
||||
if (!sameSnapshot || forceRefresh || !manifest.files[name]) throw new Error('arquivo sem origem de snapshot confirmada');
|
||||
const expected = typeof manifest.files[name] === 'number' ? manifest.files[name] : manifest.files[name]?.size;
|
||||
if (!Number.isSafeInteger(expected) || expected < 0) throw new Error('arquivo sem tamanho esperado no manifesto');
|
||||
await inspectZip(path.join(rawDir, name), minZipBytes, expected);
|
||||
skipped.push(name);
|
||||
} catch {
|
||||
const file = await downloadOne({
|
||||
rawDir,
|
||||
snapshotUrl,
|
||||
name,
|
||||
fetchImpl,
|
||||
minZipBytes,
|
||||
isCancelled,
|
||||
onProgress,
|
||||
idleTimeoutMs,
|
||||
manifest
|
||||
});
|
||||
downloaded.push(file);
|
||||
manifest.files[name] = { size: file.size, etag: file.etag, lastModified: file.lastModified };
|
||||
await writeManifest(rawDir, manifest);
|
||||
}
|
||||
onProgress({
|
||||
phase: 'download',
|
||||
pct: Math.round(((index + 1) / REQUIRED_RFB_FILES.length) * 100),
|
||||
message: `${snapshot}: ${index + 1}/${REQUIRED_RFB_FILES.length} arquivos prontos`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CancelledError || isCancelled()) return { cancelled: true, snapshot, downloaded, skipped };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const files = await validateRfbRawFiles(rawDir, { minZipBytes });
|
||||
const finalFiles = Object.fromEntries(files.map((file) => {
|
||||
const metadata = manifest.files[file.name];
|
||||
return [file.name, typeof metadata === 'object' ? { ...metadata, size: file.size } : { size: file.size, etag: null, lastModified: null }];
|
||||
}));
|
||||
await writeManifest(rawDir, { snapshot, complete: true, files: finalFiles, partials: {} });
|
||||
await fsp.rm(path.join(rawDir, BACKUP_DIR), { recursive: true, force: true });
|
||||
const checkpointDir = path.dirname(rawDir);
|
||||
for (const name of await fsp.readdir(checkpointDir)) {
|
||||
if (/^checkpoint-estabelecimentos-.*\.ndjson(?:\.tmp)?$/i.test(name)) {
|
||||
await fsp.rm(path.join(checkpointDir, name), { force: true });
|
||||
}
|
||||
}
|
||||
return { cancelled: false, snapshot, downloaded: downloaded.map((file) => file.name), skipped, files: finalFiles };
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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';
|
||||
import { validateRfbRawFiles } from './rfb-downloader.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.
|
||||
export function readZipLines(reader, zipPath, { spawn: spawnProcess = spawn } = {}) {
|
||||
const child = spawnProcess(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
let intentionalClose = false;
|
||||
let settled = false;
|
||||
let rl;
|
||||
let resolveDone;
|
||||
let rejectDone;
|
||||
const done = new Promise((resolve, reject) => {
|
||||
resolveDone = resolve;
|
||||
rejectDone = reject;
|
||||
});
|
||||
const settle = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) rejectDone(error);
|
||||
else resolveDone();
|
||||
};
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = (stderr + chunk).slice(-4096);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
const wrapped = new Error(`falha ao iniciar leitor ZIP para ${path.basename(zipPath)}: ${error.message}`);
|
||||
settle(wrapped);
|
||||
rl?.close();
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (intentionalClose) return settle();
|
||||
if (code === 0) return settle();
|
||||
const error = new Error(
|
||||
`leitor ZIP falhou em ${path.basename(zipPath)} (codigo ${code ?? 'n/a'}${signal ? `, sinal ${signal}` : ''}): ${stderr.slice(-1000)}`
|
||||
);
|
||||
settle(error);
|
||||
rl?.close();
|
||||
});
|
||||
child.stdout.setEncoding('latin1');
|
||||
rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
const streamError = (error) => settle(new Error(`erro ao ler ${path.basename(zipPath)}: ${error.message}`));
|
||||
rl.on('error', streamError);
|
||||
child.stdout.on('error', streamError);
|
||||
return {
|
||||
rl,
|
||||
done,
|
||||
close() {
|
||||
intentionalClose = true;
|
||||
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));
|
||||
}
|
||||
await zip.done;
|
||||
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])
|
||||
};
|
||||
}
|
||||
|
||||
await zip.done;
|
||||
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;
|
||||
}
|
||||
|
||||
await zip.done;
|
||||
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();
|
||||
await validateRfbRawFiles(rawDir);
|
||||
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) && fs.statSync(checkpointPath).size > 0;
|
||||
if (checkpointDone) {
|
||||
log('checkpoint encontrado: pulando varredura de Estabelecimentos');
|
||||
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
|
||||
const checkpointLines = readline.createInterface({ input, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of checkpointLines) {
|
||||
if (!line.trim()) continue;
|
||||
const item = JSON.parse(line);
|
||||
if (!item.b || !item.m) throw new Error('checkpoint sem campos obrigatorios');
|
||||
cnpjBasicoSet.add(item.b);
|
||||
tomSeen.add(item.m);
|
||||
totalRows += 1;
|
||||
if (item.t1 || item.t2) withPhone += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
await fsp.rm(checkpointPath, { force: true });
|
||||
throw new Error(`checkpoint invalido: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
const tmpPath = `${checkpointPath}.tmp`;
|
||||
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
|
||||
let outError = null;
|
||||
out.on('error', (error) => { outError = error; });
|
||||
|
||||
onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' });
|
||||
|
||||
try {
|
||||
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')) {
|
||||
if (outError) throw outError;
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('drain', resolve);
|
||||
out.once('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (outError) throw outError;
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('error', reject);
|
||||
out.end(resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
out.destroy();
|
||||
await fsp.rm(tmpPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
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 (!totalRows && !isCancelled()) {
|
||||
await fsp.rm(checkpointPath, { force: true });
|
||||
throw new Error(`nenhum estabelecimento ativo encontrado para ${ufUpper}`);
|
||||
}
|
||||
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 csvTmp = `${outCsv}.tmp`;
|
||||
const csvOut = fs.createWriteStream(csvTmp, { encoding: 'utf8' });
|
||||
let csvError = null;
|
||||
csvOut.on('error', (error) => { csvError = error; });
|
||||
try {
|
||||
csvOut.write(BOM + headers.join(';') + '\n');
|
||||
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
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')) {
|
||||
if (csvError) throw csvError;
|
||||
await new Promise((resolve, reject) => {
|
||||
csvOut.once('drain', resolve);
|
||||
csvOut.once('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (csvError) throw csvError;
|
||||
await new Promise((resolve, reject) => {
|
||||
csvOut.once('error', reject);
|
||||
csvOut.end(resolve);
|
||||
});
|
||||
await fsp.rename(csvTmp, outCsv);
|
||||
} catch (error) {
|
||||
csvOut.destroy();
|
||||
await fsp.rm(csvTmp, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ---- 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 };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const JOB_TABLES = new Set(['capture_runs', 'extractions', 'exports']);
|
||||
|
||||
export function createJobLease({
|
||||
db,
|
||||
table,
|
||||
id,
|
||||
ownerToken,
|
||||
pid = process.pid,
|
||||
intervalMs = 30_000,
|
||||
setInterval: schedule = globalThis.setInterval,
|
||||
clearInterval: unschedule = globalThis.clearInterval
|
||||
}) {
|
||||
if (!JOB_TABLES.has(table)) throw new Error(`tabela de job invalida: ${table}`);
|
||||
if (!id || !ownerToken) throw new Error('id e ownerToken obrigatorios para lease');
|
||||
const startedAt = table === 'exports' ? '' : ", started_at = COALESCE(started_at, datetime('now'))";
|
||||
const start = db.prepare(
|
||||
`UPDATE ${table} SET status = 'running', pid = ?, owner_token = ?, heartbeat_at = datetime('now')${startedAt} WHERE id = ?`
|
||||
);
|
||||
const heartbeat = db.prepare(
|
||||
`UPDATE ${table} SET heartbeat_at = datetime('now') WHERE id = ? AND owner_token = ? AND status = 'running'`
|
||||
);
|
||||
start.run(pid, ownerToken, id);
|
||||
const touch = () => heartbeat.run(id, ownerToken).changes > 0;
|
||||
const timer = schedule(touch, intervalMs);
|
||||
timer?.unref?.();
|
||||
let closed = false;
|
||||
return {
|
||||
ownerToken,
|
||||
touch,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
unschedule(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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({ spawn: spawnProcess = spawn, killGraceMs = KILL_GRACE_MS, db = null } = {}) {
|
||||
const jobs = new Map();
|
||||
|
||||
function persistTerminal(job, status, message = null) {
|
||||
if (!db) return;
|
||||
const target = job.type === 'captura' && job.meta.runId
|
||||
? ['capture_runs', job.meta.runId]
|
||||
: job.type === 'extracao' && job.meta.extractionId
|
||||
? ['extractions', job.meta.extractionId]
|
||||
: job.type === 'exportacao' && job.meta.exportId
|
||||
? ['exports', job.meta.exportId]
|
||||
: null;
|
||||
if (!target) return;
|
||||
const eligibleStatuses = status === 'error' ? "'pending','running','done'" : "'pending','running'";
|
||||
db.prepare(
|
||||
`UPDATE ${target[0]} SET status = ?, error = ?, finished_at = datetime('now')
|
||||
WHERE id = ? AND status IN (${eligibleStatuses})`
|
||||
).run(status, message, target[1]);
|
||||
}
|
||||
|
||||
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 = {}, ownerToken = randomUUID() }) {
|
||||
const jobId = randomUUID();
|
||||
const child = spawnProcess(
|
||||
process.execPath,
|
||||
[path.join(paths.root, script), `--job-id=${jobId}`, `--owner-token=${ownerToken}`, ...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,
|
||||
cancelRequested: false
|
||||
,ownerToken,
|
||||
pendingDone: null,
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
cleanupScheduled: false
|
||||
};
|
||||
jobs.set(jobId, job);
|
||||
|
||||
const failStream = (error) => {
|
||||
if (job.status !== 'running') return;
|
||||
job.status = job.cancelRequested ? 'cancelled' : 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
const message = `stream do processo filho falhou: ${error.message}`;
|
||||
persistTerminal(job, job.status, job.status === 'error' ? message : null);
|
||||
fanout(job, job.status === 'error'
|
||||
? { t: 'error', message }
|
||||
: { t: 'done', summary: { cancelled: true } });
|
||||
try { child.kill(); } catch { /* processo ja encerrou */ }
|
||||
};
|
||||
|
||||
const rl = readline.createInterface({ input: child.stdout });
|
||||
rl.on('error', failStream);
|
||||
rl.on('line', (line) => {
|
||||
let event = parseJobLine(line);
|
||||
if (!event) return;
|
||||
if (event.t === 'done') {
|
||||
job.pendingDone = event;
|
||||
return;
|
||||
} else if (event.t === 'error') {
|
||||
job.status = job.cancelRequested ? 'cancelled' : 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
persistTerminal(job, job.status, job.status === 'error' ? event.message || 'processo filho falhou' : null);
|
||||
if (job.status === 'cancelled') event = { t: 'done', summary: { cancelled: true } };
|
||||
}
|
||||
fanout(job, event);
|
||||
});
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
job.stderrTail = (job.stderrTail + chunk).slice(-4096);
|
||||
});
|
||||
child.stderr.on('error', (error) => {
|
||||
job.stderrTail = (job.stderrTail + error.message).slice(-4096);
|
||||
failStream(error);
|
||||
});
|
||||
child.stdout.on('error', (error) => {
|
||||
job.stderrTail = (job.stderrTail + error.message).slice(-4096);
|
||||
failStream(error);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (job.status !== 'running') return;
|
||||
job.status = job.cancelRequested ? 'cancelled' : 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
persistTerminal(job, job.status, job.status === 'error' ? error.message : null);
|
||||
fanout(job, job.status === 'error' ? { t: 'error', message: `falha ao iniciar processo: ${error.message}` } : { t: 'done', summary: { cancelled: true } });
|
||||
});
|
||||
|
||||
const scheduleCleanup = () => {
|
||||
if (job.cleanupScheduled) return;
|
||||
job.cleanupScheduled = true;
|
||||
setTimeout(() => jobs.delete(jobId), 10 * 60 * 1000).unref();
|
||||
};
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (job.killTimer) clearTimeout(job.killTimer);
|
||||
job.exitCode = code;
|
||||
job.exitSignal = signal;
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (job.killTimer) clearTimeout(job.killTimer);
|
||||
job.exitCode = code ?? job.exitCode;
|
||||
job.exitSignal = signal ?? job.exitSignal;
|
||||
if (job.status === 'running') {
|
||||
const cleanDone = job.exitCode === 0 && job.pendingDone;
|
||||
job.status = cleanDone
|
||||
? (job.pendingDone.summary?.cancelled ? 'cancelled' : 'done')
|
||||
: (job.cancelRequested ? 'cancelled' : 'error');
|
||||
job.finishedAt = new Date().toISOString();
|
||||
if (cleanDone) {
|
||||
persistTerminal(job, job.status);
|
||||
fanout(job, job.pendingDone);
|
||||
} else if (job.status === 'cancelled') {
|
||||
persistTerminal(job, 'cancelled');
|
||||
fanout(job, { t: 'done', summary: { cancelled: true } });
|
||||
} else {
|
||||
const message = `processo encerrou inesperadamente (codigo ${job.exitCode}${job.exitSignal ? `, sinal ${job.exitSignal}` : ''}). ${job.stderrTail.slice(-500)}`;
|
||||
persistTerminal(job, 'error', message);
|
||||
fanout(job, { t: 'error', message });
|
||||
}
|
||||
}
|
||||
scheduleCleanup();
|
||||
});
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
function cancelJob(jobId) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job || job.status !== 'running') return false;
|
||||
job.cancelRequested = true;
|
||||
|
||||
try {
|
||||
job.child.stdin.write('cancel\n');
|
||||
} catch {
|
||||
// stdin ja fechado
|
||||
}
|
||||
job.killTimer = setTimeout(() => {
|
||||
if (job.status === 'running') job.child.kill();
|
||||
}, killGraceMs);
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
async function close() {
|
||||
const running = [...jobs.values()].filter((job) => job.status === 'running');
|
||||
if (!running.length) return;
|
||||
const exits = running.map((job) => new Promise((resolve) => job.child.once('close', resolve)));
|
||||
const allExited = Promise.all(exits);
|
||||
const waitDeadline = () => new Promise((resolve) => setTimeout(resolve, killGraceMs));
|
||||
for (const job of running) cancelJob(job.id);
|
||||
await Promise.race([allExited, waitDeadline()]);
|
||||
for (const job of running) {
|
||||
if (job.status === 'running') job.child.kill();
|
||||
}
|
||||
await Promise.race([allExited, waitDeadline()]);
|
||||
for (const job of running) {
|
||||
if (job.status !== 'running') continue;
|
||||
if (job.killTimer) clearTimeout(job.killTimer);
|
||||
job.status = 'cancelled';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
persistTerminal(job, 'cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
return { startJob, cancelJob, subscribe, getJob, activeJobs, close };
|
||||
}
|
||||
|
||||
// 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 = () => {}, {
|
||||
processAlive = (pid) => {
|
||||
if (!pid) return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
},
|
||||
now = Date.now(),
|
||||
leaseTimeoutMs = 120_000
|
||||
} = {}) {
|
||||
for (const table of ['capture_runs', 'extractions', 'exports']) {
|
||||
const running = db.prepare(`SELECT id, pid, heartbeat_at FROM ${table} WHERE status IN ('running', 'pending')`).all();
|
||||
for (const row of running) {
|
||||
const heartbeatMs = row.heartbeat_at ? Date.parse(`${row.heartbeat_at.replace(' ', 'T')}Z`) : Number.NaN;
|
||||
const stale = !Number.isFinite(heartbeatMs) || now - heartbeatMs > leaseTimeoutMs;
|
||||
if (!stale) continue;
|
||||
db.prepare(
|
||||
`UPDATE ${table} SET status = 'error', error = 'interrompido por reinicio do servidor', 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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? `MCP-${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.',
|
||||
'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: fazer o tratamento manual.'
|
||||
].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.PERSONAL_COMMERCIAL_WHATSAPP || '{{PERSONAL_COMMERCIAL_WHATSAPP}}';
|
||||
|
||||
return {
|
||||
lead,
|
||||
priority,
|
||||
crmRecord: buildCrmRecord(lead, priority),
|
||||
whatsapp: {
|
||||
internal: {
|
||||
to: commercialWhatsApp,
|
||||
message: buildInternalWhatsAppMessage(lead, priority)
|
||||
}
|
||||
},
|
||||
nextAction: 'tratamento_manual'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export const CAMPAIGN_PRODUCTS = ['GTI Vendas', 'G-Delivery'];
|
||||
|
||||
export const CAMPAIGN_STAGES = [
|
||||
'fila_preparada',
|
||||
'aprovado_para_envio',
|
||||
'enviado',
|
||||
'respondeu',
|
||||
'interessado_gti_vendas',
|
||||
'interessado_g_delivery',
|
||||
'demonstracao_agendada',
|
||||
'teste_g_delivery_iniciado',
|
||||
'sem_interesse',
|
||||
'opt_out',
|
||||
'venda_fechada'
|
||||
];
|
||||
|
||||
const GTI_VENDAS_VARIANTS = [
|
||||
{
|
||||
code: 'A',
|
||||
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
},
|
||||
{
|
||||
code: 'B',
|
||||
mensagem: 'Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
},
|
||||
{
|
||||
code: 'C',
|
||||
mensagem: 'Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
}
|
||||
];
|
||||
|
||||
const G_DELIVERY_VARIANTS = [
|
||||
{
|
||||
code: 'A',
|
||||
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
},
|
||||
{
|
||||
code: 'B',
|
||||
mensagem: 'Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
},
|
||||
{
|
||||
code: 'C',
|
||||
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
|
||||
}
|
||||
];
|
||||
|
||||
export const CAMPAIGN_DEFINITIONS = [
|
||||
{
|
||||
code: 'gdelivery-food',
|
||||
name: 'G-Delivery - Alimentacao',
|
||||
produto: 'G-Delivery',
|
||||
segments: ['hamburguerias', 'pizzarias', 'acaiterias', 'marmitarias', 'lanchonetes'],
|
||||
variants: G_DELIVERY_VARIANTS
|
||||
},
|
||||
{
|
||||
code: 'gti-food',
|
||||
name: 'GTI Vendas - Alimentacao e Mercados',
|
||||
produto: 'GTI Vendas',
|
||||
segments: ['restaurantes', 'mercados_de_bairro', 'mercearias', 'minimercados', 'padarias', 'conveniencias'],
|
||||
variants: GTI_VENDAS_VARIANTS
|
||||
},
|
||||
{
|
||||
code: 'gti-retail',
|
||||
name: 'GTI Vendas - Varejo e Servicos',
|
||||
produto: 'GTI Vendas',
|
||||
segments: ['lojas_de_roupa', 'material_de_construcao', 'autopecas', 'oficinas_mecanicas'],
|
||||
variants: GTI_VENDAS_VARIANTS
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const MARKET_SEGMENTS = new Set([
|
||||
'mercados_de_bairro',
|
||||
'mercearias',
|
||||
'minimercados',
|
||||
'padarias',
|
||||
'conveniencias'
|
||||
]);
|
||||
|
||||
const CNPJ_NUMBER = /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/;
|
||||
const URL = /\b(?:https?:\/\/|www\.)\S+|\b(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z]{2,}(?:\/\S*)?/i;
|
||||
const LOCALHOST = /\blocalhost(?::\d{1,5})?(?:\/\S*)?/i;
|
||||
const ABSOLUTE_PATH = /(?:^|\s)\/[\p{L}\d][^\s]*/u;
|
||||
const PLACEHOLDER = /\{[^{}]*\}/;
|
||||
const GENERIC_NAMES = new Set([
|
||||
'comercio',
|
||||
'empresa',
|
||||
'estabelecimento',
|
||||
'loja',
|
||||
'nao informado',
|
||||
'nome fantasia',
|
||||
'nome empresarial',
|
||||
'razao social',
|
||||
'sem nome'
|
||||
]);
|
||||
|
||||
function normalizedText(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function containsIpAddress(value) {
|
||||
const text = String(value);
|
||||
const ipv4Candidates = text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
|
||||
if (ipv4Candidates.some((candidate) => isIP(candidate) === 4)) return true;
|
||||
|
||||
const ipv6Candidates = text.match(/\[?[\da-f]*:[\da-f:]+\]?/gi) ?? [];
|
||||
return ipv6Candidates.some((candidate) => isIP(candidate.replace(/^\[|\]$/g, '')) === 6);
|
||||
}
|
||||
|
||||
export function naturalBusinessName(lead) {
|
||||
const name = String(lead?.empresa ?? '').trim();
|
||||
if (!name || name.length > 60 || /^\d/.test(name) || CNPJ_NUMBER.test(name)) return '';
|
||||
if (/[{}]|[\p{Cc}\p{Cf}]/u.test(name)) return '';
|
||||
if (/\b(?:ltda|eireli|mei|epp|me|s\/?a)\.?$/i.test(name)) return '';
|
||||
if (GENERIC_NAMES.has(normalizedText(name))) return '';
|
||||
|
||||
const letters = name.match(/\p{L}/gu)?.length ?? 0;
|
||||
const digits = name.match(/\d/g)?.length ?? 0;
|
||||
if (letters < 2 || digits >= letters) return '';
|
||||
return name;
|
||||
}
|
||||
|
||||
export function managementFocus(segment) {
|
||||
if (segment === 'restaurantes') return 'vendas, estoque, comandas e emissão fiscal';
|
||||
if (MARKET_SEGMENTS.has(segment)) return 'vendas, estoque e emissão fiscal';
|
||||
if (segment === 'oficinas_mecanicas') return 'ordens de serviço, estoque e financeiro';
|
||||
return 'vendas, estoque, financeiro e notas fiscais';
|
||||
}
|
||||
|
||||
export function variantForLead(variants, leadId) {
|
||||
const activeVariants = variants.filter((variant) => variant.ativo !== 0 && variant.ativo !== false);
|
||||
if (activeVariants.length === 0) throw new Error('nenhuma variante ativa');
|
||||
|
||||
const numericLeadId = Number(leadId);
|
||||
if (!Number.isSafeInteger(numericLeadId) || numericLeadId < 0) throw new Error('leadId invalido');
|
||||
return activeVariants[numericLeadId % activeVariants.length];
|
||||
}
|
||||
|
||||
export function renderCampaignMessage(template, lead) {
|
||||
const source = typeof template === 'string' ? template : template?.mensagem;
|
||||
if (typeof source !== 'string') throw new Error('template de campanha invalido');
|
||||
|
||||
let rendered = source.replaceAll('{foco_gestao}', managementFocus(lead?.segmento ?? lead?.segment));
|
||||
const businessName = naturalBusinessName(lead);
|
||||
if (businessName) {
|
||||
rendered = rendered.replace(/^(?:Ola|Olá)(?:, tudo bem\?|!)/, `Olá, pessoal da ${businessName}, tudo bem?`);
|
||||
}
|
||||
|
||||
const normalized = normalizedText(rendered);
|
||||
const unsafe = URL.test(rendered)
|
||||
|| LOCALHOST.test(rendered)
|
||||
|| containsIpAddress(rendered)
|
||||
|| ABSOLUTE_PATH.test(rendered)
|
||||
|| PLACEHOLDER.test(rendered)
|
||||
|| CNPJ_NUMBER.test(rendered)
|
||||
|| /R\$/i.test(rendered)
|
||||
|| !rendered.includes('Gustavo')
|
||||
|| !rendered.includes('Grupo GTI')
|
||||
|| !normalized.includes('nao volto a entrar em contato');
|
||||
if (unsafe) throw new Error('mensagem inicial insegura');
|
||||
|
||||
return rendered;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
export function campaignMetrics(db, campaignId, filters = {}) {
|
||||
const { segmento, variant } = filters;
|
||||
|
||||
let queueWhere = 'WHERE q.campaign_id = ?';
|
||||
const queueParams = [campaignId];
|
||||
if (segmento) {
|
||||
queueWhere += ' AND q.segmento = ?';
|
||||
queueParams.push(segmento);
|
||||
}
|
||||
if (variant) {
|
||||
queueWhere += ' AND v.code = ?';
|
||||
queueParams.push(variant);
|
||||
}
|
||||
|
||||
const queueJoin = variant
|
||||
? 'JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.campaign_id = q.campaign_id'
|
||||
: '';
|
||||
|
||||
const queue = db.prepare(
|
||||
`SELECT q.status, COUNT(*) AS total
|
||||
FROM outreach_campaign_queue q ${queueJoin} ${queueWhere}
|
||||
GROUP BY q.status`
|
||||
).all(...queueParams);
|
||||
const queueMap = {};
|
||||
for (const row of queue) queueMap[row.status] = row.total;
|
||||
|
||||
const sends = db.prepare(
|
||||
`SELECT s.status, COUNT(*) AS total
|
||||
FROM outreach_sends s
|
||||
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
|
||||
${queueJoin}
|
||||
${queueWhere}
|
||||
GROUP BY s.status`
|
||||
).all(...queueParams);
|
||||
|
||||
const leadIdsResult = db.prepare(
|
||||
`SELECT DISTINCT q.lead_id
|
||||
FROM outreach_campaign_queue q ${queueJoin} ${queueWhere}`
|
||||
).all(...queueParams);
|
||||
const leadIds = leadIdsResult.map((r) => r.lead_id);
|
||||
|
||||
const replies = db.prepare(
|
||||
`SELECT COUNT(DISTINCT r.lead_id) AS total
|
||||
FROM outreach_inbound_replies r
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = r.lead_id
|
||||
${queueJoin}
|
||||
${queueWhere}`
|
||||
).get(...queueParams).total;
|
||||
|
||||
let firstResponseTimeAvg = null;
|
||||
if (replies > 0) {
|
||||
const times = db.prepare(
|
||||
`SELECT MIN(r.received_at) AS first_reply,
|
||||
MIN(s.sent_at) AS first_send
|
||||
FROM outreach_inbound_replies r
|
||||
JOIN outreach_sends s ON s.lead_id = r.lead_id AND s.origem = 'automatico'
|
||||
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
|
||||
${queueJoin}
|
||||
${queueWhere}
|
||||
AND s.sent_at IS NOT NULL
|
||||
GROUP BY r.lead_id
|
||||
HAVING first_reply IS NOT NULL AND first_send IS NOT NULL`
|
||||
).all(...queueParams);
|
||||
if (times.length > 0) {
|
||||
let total = 0;
|
||||
for (const t of times) {
|
||||
total += (new Date(t.first_reply).getTime() - new Date(t.first_send).getTime()) / 3600000;
|
||||
}
|
||||
firstResponseTimeAvg = total / times.length;
|
||||
}
|
||||
}
|
||||
|
||||
const stageRows = db.prepare(
|
||||
`SELECT ls.stage, COUNT(DISTINCT ls.lead_id) AS total
|
||||
FROM outreach_lead_states ls
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id
|
||||
${queueJoin}
|
||||
${queueWhere}
|
||||
GROUP BY ls.stage`
|
||||
).all(...queueParams);
|
||||
|
||||
let positiveStages = 0;
|
||||
let negativeStages = 0;
|
||||
let optOutStages = 0;
|
||||
for (const row of stageRows) {
|
||||
if (['interessado_gti_vendas', 'interessado_g_delivery', 'venda_fechada'].includes(row.stage)) {
|
||||
positiveStages += row.total;
|
||||
} else if (row.stage === 'sem_interesse') {
|
||||
negativeStages += row.total;
|
||||
} else if (row.stage === 'opt_out') {
|
||||
optOutStages += row.total;
|
||||
}
|
||||
}
|
||||
|
||||
const appRows = db.prepare(
|
||||
`SELECT a.kind, COUNT(DISTINCT a.rowid) AS total
|
||||
FROM outreach_appointments a
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = a.lead_id
|
||||
${queueJoin}
|
||||
${queueWhere}
|
||||
GROUP BY a.kind`
|
||||
).all(...queueParams);
|
||||
let demos = 0;
|
||||
let tests = 0;
|
||||
for (const row of appRows) {
|
||||
if (row.kind === 'demonstracao') demos += row.total;
|
||||
else if (row.kind === 'teste_g_delivery') tests += row.total;
|
||||
}
|
||||
|
||||
const sales = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT ls.lead_id) AS total
|
||||
FROM outreach_lead_states ls
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id
|
||||
${queueJoin}
|
||||
${queueWhere}
|
||||
AND ls.stage = 'venda_fechada'`
|
||||
).get(...queueParams).total);
|
||||
|
||||
const sendMap = { aceito: 0, erro: 0, desconhecido: 0 };
|
||||
for (const row of sends) {
|
||||
if (row.status in sendMap) sendMap[row.status] = row.total;
|
||||
}
|
||||
|
||||
const groups = {
|
||||
byProduct: {},
|
||||
bySegment: {},
|
||||
byVariant: {}
|
||||
};
|
||||
|
||||
const groupRows = db.prepare(
|
||||
`SELECT q.produto, q.segmento, v.code AS variant_code,
|
||||
COUNT(*) AS total
|
||||
FROM outreach_campaign_queue q
|
||||
JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.campaign_id = q.campaign_id
|
||||
${queueWhere}
|
||||
GROUP BY q.produto, q.segmento, v.code`
|
||||
).all(...queueParams);
|
||||
|
||||
for (const row of groupRows) {
|
||||
const total = row.total;
|
||||
if (!groups.byProduct[row.produto]) {
|
||||
groups.byProduct[row.produto] = { sends: 0 };
|
||||
}
|
||||
groups.byProduct[row.produto].sends += total;
|
||||
|
||||
if (!groups.bySegment[row.segmento]) {
|
||||
groups.bySegment[row.segmento] = { sends: 0 };
|
||||
}
|
||||
groups.bySegment[row.segmento].sends += total;
|
||||
|
||||
if (!groups.byVariant[row.variant_code]) {
|
||||
groups.byVariant[row.variant_code] = { sends: 0, eligibleForComparison: false };
|
||||
}
|
||||
groups.byVariant[row.variant_code].sends += total;
|
||||
}
|
||||
|
||||
for (const variantCode of Object.keys(groups.byVariant)) {
|
||||
const v = groups.byVariant[variantCode];
|
||||
const sendCount = db.prepare(
|
||||
`SELECT COUNT(DISTINCT s.id) AS total
|
||||
FROM outreach_sends s
|
||||
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
|
||||
JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.code = ? AND v.campaign_id = q.campaign_id
|
||||
${queueWhere}
|
||||
AND s.origem = 'automatico' AND s.status IN ('aceito','erro','desconhecido')`
|
||||
).get(variantCode, ...queueParams).total;
|
||||
v.eligibleForComparison = sendCount >= 40;
|
||||
v.sends = sendCount;
|
||||
}
|
||||
|
||||
return {
|
||||
previewed: queueMap.preview || 0,
|
||||
approved: queueMap.approved || 0,
|
||||
removed: queueMap.cancelled || 0,
|
||||
sent: queueMap.sent || 0,
|
||||
attempted: (queueMap.scheduled || 0) + (queueMap.reserved || 0) + (queueMap.sent || 0) + (queueMap.unknown || 0) + (queueMap.error || 0),
|
||||
provider: sendMap,
|
||||
replies,
|
||||
firstResponseTimeAvg,
|
||||
stages: { positive: positiveStages, negative: negativeStages, optOut: optOutStages },
|
||||
appointments: { demos, tests },
|
||||
sales,
|
||||
groups
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||
const row = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||
if (!row) return { pause: false, reason: null };
|
||||
|
||||
const unknownCount = Number(db.prepare(
|
||||
`SELECT COUNT(*) AS total FROM outreach_campaign_queue
|
||||
WHERE campaign_id = ? AND status = 'unknown'`
|
||||
).get(campaignId).total);
|
||||
|
||||
if (unknownCount > 0) {
|
||||
applyPause(db, campaignId, 'unknown_outcome');
|
||||
return { pause: true, reason: 'unknown_outcome' };
|
||||
}
|
||||
|
||||
const todayKey = dateKey(now);
|
||||
|
||||
const attemptsToday = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT s.id) AS total
|
||||
FROM outreach_sends s
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
|
||||
WHERE s.origem = 'automatico'
|
||||
AND s.status IN ('aceito', 'erro')
|
||||
AND date(s.sent_at, 'localtime') = ?`
|
||||
).get(campaignId, todayKey).total);
|
||||
|
||||
if (attemptsToday >= 10) {
|
||||
const failureCount = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT s.id) AS total
|
||||
FROM outreach_sends s
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
|
||||
WHERE s.origem = 'automatico'
|
||||
AND s.status = 'erro'
|
||||
AND date(s.sent_at, 'localtime') = ?`
|
||||
).get(campaignId, todayKey).total);
|
||||
const successCount = attemptsToday - failureCount;
|
||||
const failureRate = successCount + failureCount > 0 ? failureCount / (successCount + failureCount) : 0;
|
||||
if (failureRate > 0.10) {
|
||||
applyPause(db, campaignId, 'high_failure_rate');
|
||||
return { pause: true, reason: 'high_failure_rate' };
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT q.lead_id) AS total
|
||||
FROM outreach_campaign_queue q
|
||||
WHERE q.campaign_id = ?
|
||||
AND q.status IN ('sent', 'unknown', 'error')`
|
||||
).get(campaignId).total);
|
||||
|
||||
if (contacts >= 20) {
|
||||
const negativeOrOptOutCount = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT ls.lead_id) AS total
|
||||
FROM outreach_lead_states ls
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id AND q.campaign_id = ?
|
||||
WHERE ls.stage IN ('sem_interesse', 'opt_out')
|
||||
AND q.status IN ('sent', 'unknown', 'error')`
|
||||
).get(campaignId).total);
|
||||
const rate = contacts > 0 ? negativeOrOptOutCount / contacts : 0;
|
||||
if (rate > 0.10) {
|
||||
applyPause(db, campaignId, 'high_negative_rate');
|
||||
return { pause: true, reason: 'high_negative_rate' };
|
||||
}
|
||||
}
|
||||
|
||||
if (contacts >= 50) {
|
||||
const replyCount = Number(db.prepare(
|
||||
`SELECT COUNT(DISTINCT r.lead_id) AS total
|
||||
FROM outreach_inbound_replies r
|
||||
JOIN outreach_campaign_queue q ON q.lead_id = r.lead_id AND q.campaign_id = ?
|
||||
WHERE q.status IN ('sent', 'unknown', 'error')`
|
||||
).get(campaignId).total);
|
||||
const replyRate = contacts > 0 ? replyCount / contacts : 0;
|
||||
if (replyRate < 0.05) {
|
||||
applyPause(db, campaignId, 'low_reply_rate');
|
||||
return { pause: true, reason: 'low_reply_rate' };
|
||||
}
|
||||
}
|
||||
|
||||
return { pause: false, reason: null };
|
||||
}
|
||||
|
||||
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) {
|
||||
const row = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||
if (!row || row.status !== 'running') return;
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaigns SET status = 'paused', pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(reason, campaignId);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
isOptedOut,
|
||||
saveWhatsAppVerification
|
||||
} from '../db.mjs';
|
||||
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
|
||||
import { getCampaign } from './campaign-store.mjs';
|
||||
import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs';
|
||||
|
||||
const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'";
|
||||
const PENDING_QUEUE_STATUSES = "'preview','approved','scheduled','reserved'";
|
||||
|
||||
function normalizedSql(column) {
|
||||
return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`;
|
||||
}
|
||||
|
||||
function equivalentSql(column) {
|
||||
const number = normalizedSql(column);
|
||||
return `CASE
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
|
||||
THEN substr(${number}, 1, 4) || substr(${number}, 6)
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9'
|
||||
THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5)
|
||||
ELSE ${number}
|
||||
END`;
|
||||
}
|
||||
|
||||
function candidateQuery(confirmed) {
|
||||
const leadNumber = normalizedSql('leads.whatsapp');
|
||||
const leadEquivalent = equivalentSql('leads.whatsapp');
|
||||
const verificationFilter = confirmed
|
||||
? "verification.status = 'confirmado'"
|
||||
: "(verification.status IS NULL OR verification.status = 'erro_temporario')";
|
||||
return `/* campaign-candidates */
|
||||
SELECT leads.id, leads.empresa, leads.legal_name, leads.whatsapp, leads.segmento,
|
||||
leads.prioridade, leads.score,
|
||||
verification.status AS verification_status,
|
||||
verification.numero AS verification_number,
|
||||
verification.numero_resolvido AS verification_resolved_number
|
||||
FROM leads
|
||||
LEFT JOIN whatsapp_number_verifications verification
|
||||
ON verification.rowid = (
|
||||
SELECT latest.rowid
|
||||
FROM whatsapp_number_verifications latest
|
||||
WHERE latest.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
ORDER BY latest.checked_at DESC, latest.rowid DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE leads.segmento = ?
|
||||
AND ${leadNumber} <> ''
|
||||
AND ${verificationFilter}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_sends sends
|
||||
WHERE sends.lead_id = leads.id AND sends.produto = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM outreach_campaign_queue queued
|
||||
WHERE queued.lead_id = leads.id
|
||||
AND queued.produto = ?
|
||||
AND queued.status IN (${ACTIVE_QUEUE_STATUSES})
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_opt_outs opt_out
|
||||
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_inbound_replies replies
|
||||
WHERE replies.lead_id = leads.id
|
||||
OR replies.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_lead_states states
|
||||
WHERE states.lead_id = leads.id AND states.first_reply_at IS NOT NULL
|
||||
)
|
||||
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC
|
||||
LIMIT ?`;
|
||||
}
|
||||
|
||||
function orderedCandidates(db, campaign, { confirmed, limit }) {
|
||||
if (limit <= 0) return [];
|
||||
const statement = db.prepare(candidateQuery(confirmed));
|
||||
const candidates = campaign.segments.flatMap((segment) => statement.all(
|
||||
segment,
|
||||
campaign.produto,
|
||||
campaign.produto,
|
||||
limit
|
||||
));
|
||||
candidates.sort((left, right) => left.prioridade - right.prioridade
|
||||
|| right.score - left.score
|
||||
|| left.id - right.id);
|
||||
return candidates.slice(0, limit);
|
||||
}
|
||||
|
||||
function verificationGroup(number) {
|
||||
return equivalentPhoneNumbers(number).slice().sort().join(':');
|
||||
}
|
||||
|
||||
function persistLookup(db, requestedNumber, result, error = null) {
|
||||
const registered = result?.registered === true || result?.status === 'confirmado';
|
||||
const status = error || result?.status === 'erro_temporario'
|
||||
? 'erro_temporario'
|
||||
: registered ? 'confirmado' : 'nao_cadastrado';
|
||||
return saveWhatsAppVerification(db, {
|
||||
...result,
|
||||
numero: requestedNumber,
|
||||
status,
|
||||
erro: status === 'erro_temporario' ? (error || result?.erro || 'falha temporaria') : null
|
||||
});
|
||||
}
|
||||
|
||||
function insertPreview(db, campaign, lead, verification) {
|
||||
const number = normalizePhone(verification.numero_resolvido || verification.numero || lead.whatsapp);
|
||||
if (!number || isOptedOut(db, number)) return false;
|
||||
const variant = variantForLead(campaign.variants, lead.id);
|
||||
const renderedMessage = renderCampaignMessage(variant, lead);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const pending = db.prepare(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM outreach_campaign_queue
|
||||
WHERE campaign_id = ? AND status IN (${PENDING_QUEUE_STATUSES})`
|
||||
).get(campaign.id).total;
|
||||
if (pending >= campaign.approved_daily_limit) {
|
||||
db.exec('COMMIT');
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = db.prepare(
|
||||
`INSERT OR IGNORE INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message,
|
||||
selection_reason, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'preview')`
|
||||
).run(
|
||||
campaign.id,
|
||||
lead.id,
|
||||
variant.id,
|
||||
campaign.produto,
|
||||
number,
|
||||
lead.segmento,
|
||||
renderedMessage,
|
||||
`${campaign.code}:prioridade-${lead.prioridade}`
|
||||
);
|
||||
db.exec('COMMIT');
|
||||
return result.changes === 1;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareDailyQueue({ db, whatsapp, campaignId, now }) {
|
||||
void now;
|
||||
const campaign = getCampaign(db, campaignId);
|
||||
if (!campaign) throw new Error('campanha nao encontrada');
|
||||
if (!Array.isArray(campaign.segments) || campaign.segments.length === 0) {
|
||||
return { checked: 0, previewed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const pending = db.prepare(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM outreach_campaign_queue
|
||||
WHERE campaign_id = ? AND status IN (${PENDING_QUEUE_STATUSES})`
|
||||
).get(campaign.id).total;
|
||||
const available = Math.max(0, campaign.approved_daily_limit - pending);
|
||||
if (available === 0) return { checked: 0, previewed: 0, skipped: 0 };
|
||||
|
||||
const checkLimit = Math.ceil(campaign.approved_daily_limit * 1.5);
|
||||
const confirmedCandidates = orderedCandidates(db, campaign, { confirmed: true, limit: available });
|
||||
const uncheckedCandidates = confirmedCandidates.length < available
|
||||
? orderedCandidates(db, campaign, { confirmed: false, limit: checkLimit })
|
||||
: [];
|
||||
const candidates = [...confirmedCandidates, ...uncheckedCandidates];
|
||||
const attemptedGroups = new Set();
|
||||
let checked = 0;
|
||||
let previewed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const lead of candidates) {
|
||||
if (previewed >= available) break;
|
||||
let verification = lead.verification_status === 'confirmado'
|
||||
? {
|
||||
numero: lead.verification_number,
|
||||
status: lead.verification_status,
|
||||
numero_resolvido: lead.verification_resolved_number
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!verification) {
|
||||
if (checked >= checkLimit) break;
|
||||
const group = verificationGroup(lead.whatsapp);
|
||||
if (group && attemptedGroups.has(group)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (group) attemptedGroups.add(group);
|
||||
checked += 1;
|
||||
try {
|
||||
const result = await whatsapp.checkNumber(lead.whatsapp);
|
||||
verification = persistLookup(db, lead.whatsapp, result);
|
||||
} catch (error) {
|
||||
persistLookup(db, lead.whatsapp, error, error);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (verification.status !== 'confirmado') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertPreview(db, campaign, lead, verification)) previewed += 1;
|
||||
else skipped += 1;
|
||||
}
|
||||
|
||||
return { checked, previewed, skipped };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
const MAX_DAILY_SLOTS = 18;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export class CampaignScheduleValidationError extends Error {}
|
||||
|
||||
function invalidSchedule(message) {
|
||||
throw new CampaignScheduleValidationError(message);
|
||||
}
|
||||
|
||||
function localDateParts(date) {
|
||||
if (date instanceof Date && !Number.isNaN(date.getTime())) {
|
||||
return [date.getFullYear(), date.getMonth(), date.getDate()];
|
||||
}
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date || ''));
|
||||
if (!match) invalidSchedule('data local invalida; use YYYY-MM-DD');
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
const day = Number(match[3]);
|
||||
const check = new Date(year, month, day, 12);
|
||||
if (check.getFullYear() !== year || check.getMonth() !== month || check.getDate() !== day) {
|
||||
invalidSchedule('data local invalida');
|
||||
}
|
||||
return [year, month, day];
|
||||
}
|
||||
|
||||
function localTime(year, month, day, value) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(String(value || ''));
|
||||
if (!match) invalidSchedule('janela de horario invalida');
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour > 23 || minute > 59) invalidSchedule('janela de horario invalida');
|
||||
const result = new Date(year, month, day, hour, minute, 0, 0);
|
||||
if (result.getFullYear() !== year || result.getMonth() !== month || result.getDate() !== day
|
||||
|| result.getHours() !== hour || result.getMinutes() !== minute) {
|
||||
invalidSchedule('janela de horario invalida para a data local');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseWindows(parts, windows) {
|
||||
if (!Array.isArray(windows) || windows.length === 0) invalidSchedule('janela de horario obrigatoria');
|
||||
const parsed = windows.map((window) => {
|
||||
const start = localTime(...parts, window?.start);
|
||||
const end = localTime(...parts, window?.end);
|
||||
if (end < start) invalidSchedule('janela de horario invalida');
|
||||
return { start, end };
|
||||
}).sort((left, right) => left.start - right.start);
|
||||
for (let index = 1; index < parsed.length; index += 1) {
|
||||
if (parsed[index].start <= parsed[index - 1].end) invalidSchedule('janelas de horario sobrepostas');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function generateDailySchedule({
|
||||
date,
|
||||
count,
|
||||
windows,
|
||||
minMs,
|
||||
maxMs,
|
||||
hourlyLimit,
|
||||
random = Math.random
|
||||
}) {
|
||||
const parts = localDateParts(date);
|
||||
const day = new Date(...parts, 12).getDay();
|
||||
if (day === 0 || day === 6) return [];
|
||||
if (!Number.isSafeInteger(count) || count < 0) invalidSchedule('quantidade invalida');
|
||||
if (!Number.isFinite(minMs) || minMs <= 0 || !Number.isFinite(maxMs) || maxMs < minMs) {
|
||||
invalidSchedule('intervalo invalido');
|
||||
}
|
||||
if (!Number.isSafeInteger(hourlyLimit) || hourlyLimit < 1) invalidSchedule('limite por hora invalido');
|
||||
if (typeof random !== 'function') invalidSchedule('random invalido');
|
||||
|
||||
const parsedWindows = parseWindows(parts, windows);
|
||||
const requested = Math.min(count, MAX_DAILY_SLOTS);
|
||||
const slots = [];
|
||||
let previous = null;
|
||||
|
||||
for (const window of parsedWindows) {
|
||||
let candidate = previous === null || previous < window.start ? window.start.getTime() : previous;
|
||||
while (slots.length < requested) {
|
||||
if (previous !== null && candidate <= previous) {
|
||||
const value = random();
|
||||
if (!Number.isFinite(value) || value < 0 || value > 1) invalidSchedule('random deve retornar valor entre 0 e 1');
|
||||
candidate = previous + minMs + Math.floor((maxMs - minMs) * value);
|
||||
}
|
||||
if (slots.length >= hourlyLimit) {
|
||||
candidate = Math.max(candidate, slots[slots.length - hourlyLimit].getTime() + HOUR_MS);
|
||||
}
|
||||
if (candidate > window.end.getTime()) break;
|
||||
slots.push(new Date(candidate));
|
||||
previous = candidate;
|
||||
candidate = previous;
|
||||
}
|
||||
if (slots.length >= requested) break;
|
||||
previous = slots.length ? slots.at(-1).getTime() : null;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { transaction } from '../db.mjs';
|
||||
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
|
||||
import { CAMPAIGN_DEFINITIONS, CAMPAIGN_STAGES } from './campaign-catalog.mjs';
|
||||
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
||||
|
||||
function hydrateCampaign(db, row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
segments: JSON.parse(row.segments_json),
|
||||
windows: JSON.parse(row.windows_json),
|
||||
variants: db.prepare(
|
||||
`SELECT id, campaign_id, code, mensagem, ativo, created_at
|
||||
FROM outreach_campaign_variants
|
||||
WHERE campaign_id = ?
|
||||
ORDER BY code`
|
||||
).all(row.id)
|
||||
};
|
||||
}
|
||||
|
||||
export function seedCampaigns(db) {
|
||||
transaction(db, () => {
|
||||
const insertCampaign = db.prepare(
|
||||
`INSERT OR IGNORE INTO outreach_campaigns (code, name, produto, segments_json)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const findCampaign = db.prepare('SELECT id FROM outreach_campaigns WHERE code = ?');
|
||||
const insertVariant = db.prepare(
|
||||
`INSERT OR IGNORE INTO outreach_campaign_variants (campaign_id, code, mensagem)
|
||||
VALUES (?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const definition of CAMPAIGN_DEFINITIONS) {
|
||||
insertCampaign.run(
|
||||
definition.code,
|
||||
definition.name,
|
||||
definition.produto,
|
||||
JSON.stringify(definition.segments)
|
||||
);
|
||||
const campaignId = findCampaign.get(definition.code).id;
|
||||
for (const variant of definition.variants) {
|
||||
insertVariant.run(campaignId, variant.code, variant.mensagem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function listCampaigns(db) {
|
||||
return db.prepare(
|
||||
`SELECT * FROM outreach_campaigns
|
||||
ORDER BY CASE code
|
||||
WHEN 'gdelivery-food' THEN 1
|
||||
WHEN 'gti-food' THEN 2
|
||||
WHEN 'gti-retail' THEN 3
|
||||
ELSE 4
|
||||
END, id`
|
||||
).all().map((row) => hydrateCampaign(db, row));
|
||||
}
|
||||
|
||||
export function getCampaign(db, id) {
|
||||
return hydrateCampaign(db, db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
export function listCampaignQueue(db, campaignId, status) {
|
||||
const params = [campaignId];
|
||||
let statusFilter = '';
|
||||
if (status !== undefined && status !== null && status !== '') {
|
||||
statusFilter = ' AND queue.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
return db.prepare(
|
||||
`SELECT queue.*, variants.code AS variant_code,
|
||||
leads.empresa, leads.legal_name, leads.score, leads.prioridade
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_campaign_variants variants ON variants.id = queue.variant_id
|
||||
JOIN leads ON leads.id = queue.lead_id
|
||||
WHERE queue.campaign_id = ?${statusFilter}
|
||||
ORDER BY queue.id`
|
||||
).all(...params);
|
||||
}
|
||||
|
||||
export function getLeadCommercialState(db, leadId) {
|
||||
return db.prepare('SELECT lead_id, stage, first_reply_at, updated_at FROM outreach_lead_states WHERE lead_id = ?')
|
||||
.get(leadId) || null;
|
||||
}
|
||||
|
||||
export function setLeadCommercialStage(db, { leadId, campaignId = null, stage, note = null }) {
|
||||
if (!CAMPAIGN_STAGES.includes(stage)) throw new Error('etapa comercial invalida');
|
||||
return transaction(db, () => {
|
||||
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId);
|
||||
if (!lead) throw new Error('lead nao encontrado');
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = 'lead_stage_moved', updated_at = datetime('now')
|
||||
WHERE lead_id = ?
|
||||
AND status IN ('preview', 'approved', 'scheduled')`
|
||||
).run(leadId);
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
|
||||
VALUES (?, ?, CASE WHEN ? = 'respondeu' THEN datetime('now') END)
|
||||
ON CONFLICT(lead_id) DO UPDATE SET
|
||||
stage = excluded.stage,
|
||||
first_reply_at = CASE
|
||||
WHEN excluded.stage = 'respondeu' THEN COALESCE(outreach_lead_states.first_reply_at, datetime('now'))
|
||||
ELSE outreach_lead_states.first_reply_at
|
||||
END,
|
||||
updated_at = datetime('now')`
|
||||
).run(leadId, stage, stage);
|
||||
db.prepare(
|
||||
'INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)'
|
||||
).run(leadId, campaignId, stage, note);
|
||||
if (campaignId !== null) {
|
||||
evaluateCampaignHealth(db, campaignId);
|
||||
}
|
||||
if (stage === 'opt_out' && lead.whatsapp) {
|
||||
const numbers = equivalentPhoneNumbers(lead.whatsapp);
|
||||
if (numbers.length) {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers);
|
||||
db.prepare('INSERT INTO outreach_opt_outs (numero, motivo) VALUES (?, ?)')
|
||||
.run(normalizePhone(lead.whatsapp), String(note || '').trim());
|
||||
}
|
||||
}
|
||||
return getLeadCommercialState(db, leadId);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveAppointment(db, input) {
|
||||
const { leadId, campaignId = null, kind, participantName = null, companyName = null, preferredDay = null, preferredTime = null } = input;
|
||||
if (!leadId) throw new Error('lead_id obrigatorio');
|
||||
if (!kind || !['demonstracao', 'teste_g_delivery'].includes(kind)) throw new Error('tipo de agendamento invalido');
|
||||
if (kind === 'demonstracao' && (!participantName || !companyName || !preferredDay || !preferredTime)) {
|
||||
throw new Error('demonstracao_agendada requer participante, empresa, dia e horario');
|
||||
}
|
||||
if (preferredDay !== null && !/^\d{4}-\d{2}-\d{2}$/.test(preferredDay)) {
|
||||
throw new Error('formato de data invalido');
|
||||
}
|
||||
if (preferredTime !== null && !/^\d{2}:\d{2}$/.test(preferredTime)) {
|
||||
throw new Error('formato de hora invalido');
|
||||
}
|
||||
return transaction(db, () => {
|
||||
const inserted = db.prepare(
|
||||
`INSERT INTO outreach_appointments (lead_id, campaign_id, kind, participant_name, company_name, preferred_day, preferred_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(leadId, campaignId, kind, participantName, companyName, preferredDay, preferredTime);
|
||||
return db.prepare('SELECT * FROM outreach_appointments WHERE id = ?').get(Number(inserted.lastInsertRowid));
|
||||
});
|
||||
}
|
||||
|
||||
export function listPendingReplies(db) {
|
||||
return db.prepare(
|
||||
`SELECT state.lead_id, state.stage, state.first_reply_at, state.updated_at,
|
||||
leads.empresa, leads.whatsapp, campaigns.produto,
|
||||
events.note, events.campaign_id, events.created_at AS event_created_at
|
||||
FROM outreach_lead_states state
|
||||
JOIN leads ON leads.id = state.lead_id
|
||||
LEFT JOIN outreach_stage_events events ON events.lead_id = state.lead_id
|
||||
AND events.id = (SELECT MAX(e2.id) FROM outreach_stage_events e2 WHERE e2.lead_id = state.lead_id)
|
||||
LEFT JOIN outreach_campaigns campaigns ON campaigns.id = events.campaign_id
|
||||
WHERE state.stage = 'respondeu'
|
||||
ORDER BY state.updated_at DESC`
|
||||
).all();
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
equivalentPhoneNumbers,
|
||||
normalizePhone
|
||||
} from '../phone.mjs';
|
||||
import { setSetting } from '../db.mjs';
|
||||
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
||||
|
||||
const ACTIVE_SEND_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido'";
|
||||
const CAMPAIGN_ATTEMPT_STATUSES = `${ACTIVE_SEND_STATUSES},'erro'`;
|
||||
|
||||
function conflict(message, statusCode = 409) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
function permanentPolicyConflict(message, cancelReason) {
|
||||
const error = conflict(message);
|
||||
error.permanentCampaignPolicy = true;
|
||||
error.cancelReason = cancelReason;
|
||||
return error;
|
||||
}
|
||||
|
||||
function immediateTransaction(db, operation) {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = operation();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function placeholders(values) {
|
||||
return values.map(() => '?').join(', ');
|
||||
}
|
||||
|
||||
function cancelScheduledQueue(db, { queueId, reason, nowValue }) {
|
||||
return immediateTransaction(db, () => db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'scheduled'`
|
||||
).run(reason, nowValue, queueId).changes === 1);
|
||||
}
|
||||
|
||||
function requireConfirmedNumber(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const row = numbers.length
|
||||
? db.prepare(
|
||||
`SELECT status
|
||||
FROM whatsapp_number_verifications
|
||||
WHERE numero IN (${placeholders(numbers)})
|
||||
ORDER BY checked_at DESC, rowid DESC
|
||||
LIMIT 1`
|
||||
).get(...numbers)
|
||||
: null;
|
||||
if (row?.status !== 'confirmado') {
|
||||
throw permanentPolicyConflict('numero sem confirmacao vigente no WhatsApp', 'whatsapp_not_confirmed');
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) {
|
||||
const nowValue = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
||||
const result = immediateTransaction(db, () => {
|
||||
const row = db.prepare(
|
||||
`SELECT queue.*, campaigns.status AS campaign_status,
|
||||
campaigns.approved_daily_limit, campaigns.hourly_limit
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_campaigns campaigns ON campaigns.id = queue.campaign_id
|
||||
WHERE queue.id = ?`
|
||||
).get(queueId);
|
||||
if (!row) throw conflict('item da fila nao encontrado', 404);
|
||||
if (row.campaign_status !== 'running') throw conflict('campanha nao esta em execucao');
|
||||
if (row.status !== 'scheduled') throw conflict('item da fila nao esta agendado');
|
||||
if (!row.scheduled_at || new Date(row.scheduled_at).getTime() > new Date(nowValue).getTime()) {
|
||||
throw conflict('item da fila ainda nao esta vencido');
|
||||
}
|
||||
|
||||
let numbers;
|
||||
try {
|
||||
numbers = requireConfirmedNumber(db, row.numero);
|
||||
if (db.prepare(
|
||||
`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders(numbers)}) LIMIT 1`
|
||||
).get(...numbers)) {
|
||||
throw permanentPolicyConflict('numero bloqueado por opt-out', 'opt_out');
|
||||
}
|
||||
if (db.prepare(
|
||||
`SELECT 1
|
||||
FROM outreach_inbound_replies
|
||||
WHERE lead_id = ? OR numero IN (${placeholders(numbers)})
|
||||
LIMIT 1`
|
||||
).get(row.lead_id, ...numbers)) {
|
||||
throw permanentPolicyConflict('lead possui resposta recebida', 'inbound_reply');
|
||||
}
|
||||
if (db.prepare(
|
||||
`SELECT 1 FROM outreach_sends
|
||||
WHERE lead_id = ? AND produto = ?
|
||||
AND status IN (${ACTIVE_SEND_STATUSES})
|
||||
LIMIT 1`
|
||||
).get(row.lead_id, row.produto)) {
|
||||
throw permanentPolicyConflict('envio ativo para o mesmo lead e produto', 'duplicate_product_send');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error.permanentCampaignPolicy) throw error;
|
||||
const cancelled = db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'scheduled'`
|
||||
).run(error.cancelReason, nowValue, row.id).changes;
|
||||
if (cancelled !== 1) throw conflict('item da fila deixou de estar agendado');
|
||||
error.queueCancelled = true;
|
||||
return { policyError: error };
|
||||
}
|
||||
|
||||
const dailyAttempts = db.prepare(
|
||||
`SELECT COUNT(DISTINCT sends.id) AS total
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
|
||||
WHERE queue.campaign_id = ?
|
||||
AND sends.origem = 'automatico'
|
||||
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
|
||||
AND date(sends.sent_at, 'localtime') = date(?, 'localtime')`
|
||||
).get(row.campaign_id, nowValue).total;
|
||||
if (dailyAttempts >= row.approved_daily_limit) {
|
||||
throw conflict(`limite diario da campanha atingido (${dailyAttempts}/${row.approved_daily_limit})`, 429);
|
||||
}
|
||||
|
||||
const hourlyAttempts = db.prepare(
|
||||
`SELECT COUNT(DISTINCT sends.id) AS total
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
|
||||
WHERE queue.campaign_id = ?
|
||||
AND sends.origem = 'automatico'
|
||||
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
|
||||
AND julianday(sends.sent_at) > julianday(?) - (1.0 / 24.0)
|
||||
AND julianday(sends.sent_at) <= julianday(?)`
|
||||
).get(row.campaign_id, nowValue, nowValue).total;
|
||||
if (hourlyAttempts >= row.hourly_limit) {
|
||||
throw conflict(`limite da hora rolante atingido (${hourlyAttempts}/${row.hourly_limit})`, 429);
|
||||
}
|
||||
|
||||
const inserted = db.prepare(
|
||||
`INSERT INTO outreach_sends
|
||||
(lead_id, produto, numero, mensagem_enviada, status, canal, origem, sent_at)
|
||||
VALUES (?, ?, ?, ?, 'pendente', 'local', 'automatico', ?)`
|
||||
).run(row.lead_id, row.produto, normalizePhone(row.numero), row.rendered_message, nowValue);
|
||||
const sendId = Number(inserted.lastInsertRowid);
|
||||
const updated = db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'reserved', outreach_send_id = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'scheduled'`
|
||||
).run(sendId, nowValue, row.id);
|
||||
if (updated.changes !== 1) throw conflict('item da fila foi reservado por outro processo');
|
||||
return { queueId: row.id, sendId };
|
||||
});
|
||||
if (result.policyError) throw result.policyError;
|
||||
return result;
|
||||
}
|
||||
|
||||
function finishCampaignQueueSend(db, context, { messageId = null, error = null } = {}) {
|
||||
if (!context?.queueId || !context?.sendId) return false;
|
||||
return immediateTransaction(db, () => {
|
||||
const row = db.prepare(
|
||||
`SELECT queue.status AS queue_status, sends.status AS send_status
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
|
||||
WHERE queue.id = ? AND sends.id = ?`
|
||||
).get(context.queueId, context.sendId);
|
||||
if (!row || row.queue_status !== 'reserved' || row.send_status !== 'pendente') return false;
|
||||
const unknown = error?.deliveryUnknown === true;
|
||||
const sendStatus = unknown ? 'desconhecido' : error ? 'erro' : 'aceito';
|
||||
const queueStatus = unknown ? 'unknown' : error ? 'error' : 'sent';
|
||||
db.prepare(
|
||||
`UPDATE outreach_sends
|
||||
SET status = ?, erro = ?, provider_message_id = ?,
|
||||
accepted_at = CASE WHEN ? = 'aceito' THEN ? ELSE accepted_at END
|
||||
WHERE id = ? AND status = 'pendente'`
|
||||
).run(sendStatus, error ? String(error.message || error) : null, messageId, sendStatus, new Date().toISOString(), context.sendId);
|
||||
return db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'reserved' AND outreach_send_id = ?`
|
||||
).run(queueStatus, new Date().toISOString(), context.queueId, context.sendId).changes === 1;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now = new Date(),
|
||||
signal,
|
||||
log = () => {}
|
||||
}) {
|
||||
const summary = { tentados: 0, enviados: 0, erros: 0, pulados: 0 };
|
||||
if (signal?.aborted) return summary;
|
||||
if (!whatsapp || whatsapp.getStatus().state !== 'READY') {
|
||||
log('outreach: WhatsApp local nao esta conectado; fila adiada');
|
||||
return summary;
|
||||
}
|
||||
const nowValue = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
||||
while (!signal?.aborted) {
|
||||
const item = db.prepare(
|
||||
`SELECT queue.id, queue.campaign_id, queue.numero, queue.rendered_message
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_campaigns campaigns ON campaigns.id = queue.campaign_id
|
||||
WHERE queue.status = 'scheduled' AND queue.scheduled_at <= ?
|
||||
AND campaigns.status = 'running'
|
||||
ORDER BY queue.scheduled_at, queue.id
|
||||
LIMIT 1`
|
||||
).get(nowValue);
|
||||
if (!item) return summary;
|
||||
|
||||
summary.tentados += 1;
|
||||
try {
|
||||
await whatsapp.sendText({
|
||||
number: item.numero,
|
||||
message: item.rendered_message,
|
||||
externalKey: `campaign-queue-${item.id}`,
|
||||
beforeSend: () => {
|
||||
if (signal?.aborted) {
|
||||
const error = new Error('fila de campanha cancelada');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
if (whatsapp.getStatus().state !== 'READY') throw conflict('WhatsApp local nao esta conectado', 503);
|
||||
return reserveCampaignQueueSend(db, { queueId: item.id, now });
|
||||
},
|
||||
afterSend: (result, error, context) => finishCampaignQueueSend(db, context, {
|
||||
messageId: result?.messageId,
|
||||
error
|
||||
})
|
||||
});
|
||||
summary.enviados += 1;
|
||||
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
|
||||
return summary;
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error.name === 'AbortError') return summary;
|
||||
if (error.permanentCampaignPolicy && error.queueCancelled) {
|
||||
summary.pulados += 1;
|
||||
log(`outreach: fila #${item.id} cancelada: ${error.message}`);
|
||||
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
|
||||
continue;
|
||||
}
|
||||
if (error.status === 'nao_cadastrado' && cancelScheduledQueue(db, {
|
||||
queueId: item.id,
|
||||
reason: 'whatsapp_not_confirmed',
|
||||
nowValue
|
||||
})) {
|
||||
summary.pulados += 1;
|
||||
log(`outreach: fila #${item.id} cancelada: ${error.message}`);
|
||||
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
|
||||
continue;
|
||||
}
|
||||
summary.erros += 1;
|
||||
log(`outreach: fila #${item.id} nao enviada: ${error.message}`);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function pauseCampaignsAfterRestart(db) {
|
||||
return immediateTransaction(db, () => {
|
||||
const paused = db.prepare(
|
||||
`UPDATE outreach_campaigns
|
||||
SET status = 'paused', pause_reason = 'server_restart', updated_at = datetime('now')
|
||||
WHERE status = 'running'`
|
||||
).run().changes;
|
||||
setSetting(db, 'outreach.ativo', 'false');
|
||||
return paused;
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverReservedCampaignQueue(db) {
|
||||
return immediateTransaction(db, () => {
|
||||
const rows = db.prepare(
|
||||
`SELECT queue.id, queue.outreach_send_id, sends.status AS send_status
|
||||
FROM outreach_campaign_queue queue
|
||||
LEFT JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
|
||||
WHERE queue.outreach_send_id IS NOT NULL
|
||||
AND queue.status IN ('reserved','sent','unknown','error')`
|
||||
).all();
|
||||
let recovered = 0;
|
||||
for (const row of rows) {
|
||||
let queueStatus;
|
||||
if (['aceito', 'enviado', 'entregue', 'lido'].includes(row.send_status)) queueStatus = 'sent';
|
||||
else if (row.send_status === 'erro') queueStatus = 'error';
|
||||
else queueStatus = 'unknown';
|
||||
if (['pendente', 'aceito'].includes(row.send_status)) {
|
||||
db.prepare(
|
||||
`UPDATE outreach_sends
|
||||
SET status = 'desconhecido',
|
||||
erro = COALESCE(NULLIF(erro, ''), 'processo encerrou sem confirmacao de entrega')
|
||||
WHERE id = ?`
|
||||
).run(row.outreach_send_id);
|
||||
queueStatus = 'unknown';
|
||||
}
|
||||
recovered += db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND status <> ?`
|
||||
).run(queueStatus, row.id, queueStatus).changes;
|
||||
}
|
||||
return recovered;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
|
||||
|
||||
function placeholders(values) {
|
||||
return values.map(() => '?').join(', ');
|
||||
}
|
||||
|
||||
export function recordInboundReply(db, event) {
|
||||
const messageId = String(event?.messageId || '').trim();
|
||||
const number = normalizePhone(event?.number);
|
||||
const receivedAt = new Date(event?.receivedAt);
|
||||
if (!messageId) throw new Error('messageId inbound obrigatorio');
|
||||
if (!number) throw new Error('numero inbound obrigatorio');
|
||||
if (!Number.isFinite(receivedAt.getTime())) throw new Error('receivedAt inbound invalido');
|
||||
const receivedAtValue = receivedAt.toISOString();
|
||||
const numbers = equivalentPhoneNumbers(number);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const existing = db.prepare(
|
||||
'SELECT lead_id FROM outreach_inbound_replies WHERE provider_message_id = ?'
|
||||
).get(messageId);
|
||||
if (existing) {
|
||||
db.exec('COMMIT');
|
||||
return { inserted: false, leadId: existing.lead_id ?? null, cancelled: 0 };
|
||||
}
|
||||
|
||||
const latest = db.prepare(
|
||||
`SELECT sends.lead_id, queue.campaign_id
|
||||
FROM outreach_sends sends
|
||||
JOIN outreach_campaign_queue queue ON queue.outreach_send_id = sends.id
|
||||
WHERE sends.numero IN (${placeholders(numbers)})
|
||||
ORDER BY julianday(sends.sent_at) DESC, sends.id DESC
|
||||
LIMIT 1`
|
||||
).get(...numbers);
|
||||
const leadId = latest?.lead_id ?? null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_inbound_replies
|
||||
(provider_message_id, lead_id, numero, received_at)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(messageId, leadId, number, receivedAtValue);
|
||||
|
||||
if (leadId !== null) {
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
|
||||
VALUES (?, 'respondeu', ?, ?)
|
||||
ON CONFLICT(lead_id) DO UPDATE SET
|
||||
stage = 'respondeu',
|
||||
first_reply_at = COALESCE(outreach_lead_states.first_reply_at, excluded.first_reply_at),
|
||||
updated_at = excluded.updated_at`
|
||||
).run(leadId, receivedAtValue, receivedAtValue);
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, created_at)
|
||||
VALUES (?, ?, 'respondeu', ?)`
|
||||
).run(leadId, latest.campaign_id, receivedAtValue);
|
||||
}
|
||||
|
||||
const cancelled = db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = 'inbound_reply', updated_at = ?
|
||||
WHERE numero IN (${placeholders(numbers)})
|
||||
AND status IN ('preview', 'approved', 'scheduled')`
|
||||
).run(receivedAtValue, ...numbers).changes;
|
||||
db.exec('COMMIT');
|
||||
return { inserted: true, leadId, cancelled };
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { runDueCampaignQueue } from './campaign-worker.mjs';
|
||||
|
||||
export async function runOutreachBatch({
|
||||
db,
|
||||
whatsapp,
|
||||
log = () => {},
|
||||
now = new Date(),
|
||||
signal
|
||||
}) {
|
||||
return runDueCampaignQueue({ db, whatsapp, log, now, signal });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { runOutreachBatch } from './run.mjs';
|
||||
|
||||
const CICLO_MS = 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
|
||||
// minuto e seguro; o running flag abaixo impede sobreposicao de ciclos.
|
||||
export function startOutreachScheduler({
|
||||
db,
|
||||
whatsapp,
|
||||
log = () => {},
|
||||
runBatch = runOutreachBatch,
|
||||
setTimeoutFn = setTimeout,
|
||||
setIntervalFn = setInterval,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
clearIntervalFn = clearInterval
|
||||
}) {
|
||||
let activePromise = null;
|
||||
let stopped = false;
|
||||
const controller = new AbortController();
|
||||
const ciclo = async () => {
|
||||
if (stopped || activePromise) return activePromise;
|
||||
activePromise = (async () => {
|
||||
try {
|
||||
await runBatch({ db, whatsapp, log, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) log(`outreach: erro inesperado no ciclo automatico: ${error.message}`);
|
||||
} finally {
|
||||
activePromise = null;
|
||||
}
|
||||
})();
|
||||
return activePromise;
|
||||
};
|
||||
|
||||
// roda um primeiro ciclo pouco depois do boot (idempotente/seguro), sem
|
||||
// esperar os 20 min inteiros para o primeiro lote do dia.
|
||||
const primeiro = setTimeoutFn(ciclo, 30 * 1000);
|
||||
primeiro.unref();
|
||||
|
||||
const timer = setIntervalFn(ciclo, CICLO_MS);
|
||||
timer.unref();
|
||||
return async () => {
|
||||
stopped = true;
|
||||
clearTimeoutFn(primeiro);
|
||||
clearIntervalFn(timer);
|
||||
controller.abort();
|
||||
await activePromise;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const COMMERCIAL_VARIABLES = [
|
||||
{ key: 'empresa', label: 'Nome da empresa' },
|
||||
{ key: 'produto', label: 'Produto recomendado' },
|
||||
{ key: 'cidade', label: 'Cidade' },
|
||||
{ key: 'uf', label: 'UF' },
|
||||
{ key: 'segmento', label: 'Nicho/segmento' },
|
||||
{ key: 'cnpj', label: 'CNPJ' },
|
||||
{ key: 'email', label: 'E-mail' },
|
||||
{ key: 'whatsapp', label: 'WhatsApp/telefone' }
|
||||
];
|
||||
|
||||
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 || 'Atendimento',
|
||||
userEmail: lead.userEmail || '',
|
||||
greeting: 'Ola'
|
||||
};
|
||||
}
|
||||
|
||||
// Substituicao simples de variaveis no texto do template. Cada mensagem ja e
|
||||
// resolvida por completo antes de chamar o WhatsApp local.
|
||||
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,14 @@
|
||||
export function normalizePhone(value = '') {
|
||||
return String(value).replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function equivalentPhoneNumbers(value = '') {
|
||||
const digits = normalizePhone(value);
|
||||
const numbers = digits ? [digits] : [];
|
||||
if (!digits.startsWith('55')) return numbers;
|
||||
const prefix = digits.slice(0, 4);
|
||||
const local = digits.slice(4);
|
||||
if (local.length === 9 && local.startsWith('9')) numbers.push(`${prefix}${local.slice(1)}`);
|
||||
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
|
||||
return [...new Set(numbers)];
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { cleanText } from './lead-engine.mjs';
|
||||
import { normalizePhone } from './phone.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 normalizePhone(value);
|
||||
}
|
||||
|
||||
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 o servico oferecido?"
|
||||
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(' ');
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { registerWhatsAppRoutes } from './routes/whatsapp.mjs';
|
||||
import { registerCampaignRoutes } from './routes/campaigns.mjs';
|
||||
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
|
||||
import { seedCampaigns } from '../outreach/campaign-store.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, whatsapp, log = () => {} }) {
|
||||
seedCampaigns(db);
|
||||
const router = createRouter();
|
||||
const deps = { db, jobs, whatsapp };
|
||||
|
||||
registerAuthRoutes(router, deps);
|
||||
registerMetaRoutes(router, deps);
|
||||
registerCaptureRoutes(router, deps);
|
||||
registerExtractionRoutes(router, deps);
|
||||
registerLeadRoutes(router, deps);
|
||||
registerExportRoutes(router, deps);
|
||||
registerOutreachRoutes(router, deps);
|
||||
registerWhatsAppRoutes(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) => {
|
||||
const headers = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store'
|
||||
};
|
||||
if (pathname === '/api/whatsapp/qr') {
|
||||
headers.pragma = 'no-cache';
|
||||
headers.expires = '0';
|
||||
}
|
||||
res.writeHead(status, headers);
|
||||
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: 'erro interno do servidor' });
|
||||
} 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,92 @@
|
||||
import { findUserByUsername, createUser, listUsers, updateUserPassword } from '../../db.mjs';
|
||||
import { hashPassword, verifyPassword } from '../../auth.mjs';
|
||||
import { createSession, destroySession, destroyUserSessions, 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('PUT', '/api/auth/password', async (req, res, { json, user, body }) => {
|
||||
const current = user ? findUserByUsername(db, user.username) : null;
|
||||
const currentPassword = String(body?.senhaAtual || '');
|
||||
const newPassword = String(body?.novaSenha || '');
|
||||
if (!current || !verifyPassword(currentPassword, current.password_hash)) {
|
||||
json(403, { erro: 'senha atual incorreta' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
json(400, { erro: 'nova senha deve ter ao menos 8 caracteres' });
|
||||
return;
|
||||
}
|
||||
updateUserPassword(db, current.id, hashPassword(newPassword));
|
||||
destroyUserSessions(current);
|
||||
res?.setHeader?.('Set-Cookie', clearSessionCookieHeader());
|
||||
json(200, { ok: true, relogin: true });
|
||||
});
|
||||
|
||||
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 < 8) {
|
||||
json(400, { erro: 'senha deve ter ao menos 8 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,389 @@
|
||||
import { countDailyOutreachUsage, getSetting, transaction } from '../../db.mjs';
|
||||
import { prepareDailyQueue } from '../../outreach/campaign-queue.mjs';
|
||||
import {
|
||||
CampaignScheduleValidationError,
|
||||
generateDailySchedule
|
||||
} from '../../outreach/campaign-schedule.mjs';
|
||||
import { campaignMetrics } from '../../outreach/campaign-metrics.mjs';
|
||||
import { getCampaign, listCampaignQueue, listCampaigns, listPendingReplies, saveAppointment, setLeadCommercialStage } from '../../outreach/campaign-store.mjs';
|
||||
|
||||
const CAMPAIGN_ATTEMPT_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido','erro'";
|
||||
const CAMPAIGN_DAILY_QUEUE_STATUSES = "'approved','scheduled','reserved','sent','unknown','error'";
|
||||
const WEEK_LIMITS = new Map([[1, 10], [2, 12], [3, 15], [4, 18]]);
|
||||
|
||||
class CampaignTransitionError extends Error {}
|
||||
|
||||
function transitionConflict(message) {
|
||||
throw new CampaignTransitionError(message);
|
||||
}
|
||||
|
||||
function respondTransitionError(json, error) {
|
||||
if (error instanceof CampaignTransitionError) {
|
||||
json(409, { erro: error.message });
|
||||
return;
|
||||
}
|
||||
json(500, { erro: 'falha interna ao atualizar campanha' });
|
||||
}
|
||||
|
||||
function requireAdmin(user, json) {
|
||||
if (user?.role === 'admin') return true;
|
||||
json(403, { erro: 'apenas administradores podem alterar campanhas' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseId(value) {
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function campaignUsage(db, campaign) {
|
||||
const manualLimit = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
|
||||
const manualUsed = countDailyOutreachUsage(db, { origem: 'manual' });
|
||||
const automaticUsed = db.prepare(
|
||||
`SELECT COUNT(DISTINCT sends.id) AS total
|
||||
FROM outreach_campaign_queue queue
|
||||
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
|
||||
WHERE queue.campaign_id = ? AND sends.origem = 'automatico'
|
||||
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
|
||||
AND date(sends.sent_at, 'localtime') = date('now', 'localtime')`
|
||||
).get(campaign.id).total;
|
||||
return {
|
||||
manual: {
|
||||
limit: manualLimit,
|
||||
used: manualUsed,
|
||||
remaining: Math.max(0, manualLimit - manualUsed)
|
||||
},
|
||||
campaign: {
|
||||
limit: campaign.approved_daily_limit,
|
||||
used: automaticUsed,
|
||||
remaining: Math.max(0, campaign.approved_daily_limit - automaticUsed)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function localDateKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function campaignDayUsage(db, campaignId, dateKey) {
|
||||
return db.prepare(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM outreach_campaign_queue
|
||||
WHERE campaign_id = ? AND status IN (${CAMPAIGN_DAILY_QUEUE_STATUSES})
|
||||
AND strftime('%Y-%m-%d', scheduled_at, 'localtime') = ?`
|
||||
).get(campaignId, dateKey).total;
|
||||
}
|
||||
|
||||
function campaignWithUsage(db, campaign) {
|
||||
return { ...campaign, usage: campaignUsage(db, campaign) };
|
||||
}
|
||||
|
||||
function findCampaign(db, value, json) {
|
||||
const id = parseId(value);
|
||||
const campaign = id === null ? null : getCampaign(db, id);
|
||||
if (!campaign) json(404, { erro: 'campanha nao encontrada' });
|
||||
return campaign;
|
||||
}
|
||||
|
||||
export function registerCampaignRoutes(router, {
|
||||
db,
|
||||
whatsapp,
|
||||
random = Math.random,
|
||||
now = () => new Date()
|
||||
}) {
|
||||
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
|
||||
json(200, { campaigns: listCampaigns(db).map((campaign) => campaignWithUsage(db, campaign)) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/campaigns/:id', async (req, res, { params, json }) => {
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
json(200, { campaign, usage: campaignUsage(db, campaign) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/campaigns/:id/metrics', async (req, res, { params, query, json }) => {
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const segmento = query?.get('segmento') || undefined;
|
||||
const variant = query?.get('variant') || undefined;
|
||||
const metrics = campaignMetrics(db, campaign.id, { segmento, variant });
|
||||
json(200, { metrics });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/campaigns/:id/prepare', async (req, res, { params, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const summary = await prepareDailyQueue({ db, whatsapp, campaignId: campaign.id, now: now() });
|
||||
json(200, { summary, queue: listCampaignQueue(db, campaign.id, 'preview') });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/campaigns/:id/queue', async (req, res, { params, query, json }) => {
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
json(200, { queue: listCampaignQueue(db, campaign.id, query?.get('status') || undefined) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/campaigns/:id/approve', async (req, res, { params, body, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const queueIds = body?.queueIds;
|
||||
if (!Array.isArray(queueIds) || queueIds.length === 0
|
||||
|| queueIds.some((id) => !Number.isSafeInteger(id) || id <= 0)
|
||||
|| new Set(queueIds).size !== queueIds.length) {
|
||||
json(400, { erro: 'queueIds explicitos, unicos e validos sao obrigatorios' });
|
||||
return;
|
||||
}
|
||||
if (queueIds.length > campaign.approved_daily_limit) {
|
||||
json(409, { erro: 'selecao excede o limite diario aprovado' });
|
||||
return;
|
||||
}
|
||||
|
||||
let schedule;
|
||||
try {
|
||||
schedule = generateDailySchedule({
|
||||
date: body?.date,
|
||||
count: queueIds.length,
|
||||
windows: campaign.windows,
|
||||
minMs: campaign.interval_min_ms,
|
||||
maxMs: campaign.interval_max_ms,
|
||||
hourlyLimit: campaign.hourly_limit,
|
||||
random
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CampaignScheduleValidationError) {
|
||||
json(400, { erro: error.message });
|
||||
} else {
|
||||
respondTransitionError(json, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (schedule.length !== queueIds.length) {
|
||||
json(409, { erro: 'as janelas nao comportam todos os itens selecionados' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
transaction(db, () => {
|
||||
const current = db.prepare('SELECT status, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaign.id);
|
||||
if (!current || current.status === 'running' || current.status === 'completed') {
|
||||
transitionConflict('estado da campanha nao permite aprovacao');
|
||||
}
|
||||
const placeholders = queueIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT id FROM outreach_campaign_queue
|
||||
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
|
||||
).all(campaign.id, ...queueIds);
|
||||
if (rows.length !== queueIds.length) transitionConflict('fila selecionada nao pertence integralmente a campanha');
|
||||
const activeToday = campaignDayUsage(db, campaign.id, body.date);
|
||||
if (activeToday + queueIds.length > current.approved_daily_limit) {
|
||||
transitionConflict('fila ativa do dia excede o limite diario aprovado');
|
||||
}
|
||||
|
||||
const scheduleRow = db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'scheduled', scheduled_at = ?, cancel_reason = NULL, updated_at = datetime('now')
|
||||
WHERE id = ? AND campaign_id = ? AND status = 'preview'`
|
||||
);
|
||||
queueIds.forEach((queueId, index) => {
|
||||
const result = scheduleRow.run(schedule[index].toISOString(), queueId, campaign.id);
|
||||
if (result.changes !== 1) transitionConflict('fila mudou durante a aprovacao');
|
||||
});
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = 'removed_by_operator', updated_at = datetime('now')
|
||||
WHERE campaign_id = ? AND status = 'preview'`
|
||||
).run(campaign.id);
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaigns
|
||||
SET status = 'ready', pause_reason = NULL, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(campaign.id);
|
||||
});
|
||||
} catch (error) {
|
||||
respondTransitionError(json, error);
|
||||
return;
|
||||
}
|
||||
json(200, { scheduled: queueIds.length, campaign: getCampaign(db, campaign.id) });
|
||||
});
|
||||
|
||||
router.add('DELETE', '/api/campaigns/:id/queue/:queueId', async (req, res, { params, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const queueId = parseId(params.queueId);
|
||||
if (queueId === null) {
|
||||
json(404, { erro: 'item da fila nao encontrado' });
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = transaction(db, () => {
|
||||
const row = db.prepare(
|
||||
'SELECT status FROM outreach_campaign_queue WHERE id = ? AND campaign_id = ?'
|
||||
).get(queueId, campaign.id);
|
||||
if (!row) return null;
|
||||
if (!['preview', 'approved', 'scheduled'].includes(row.status)) {
|
||||
transitionConflict('item reservado, enviado ou terminal nao pode ser removido');
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaign_queue
|
||||
SET status = 'cancelled', cancel_reason = 'removed_by_operator',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND campaign_id = ?`
|
||||
).run(queueId, campaign.id);
|
||||
return { id: queueId, status: 'cancelled' };
|
||||
});
|
||||
} catch (error) {
|
||||
respondTransitionError(json, error);
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
json(404, { erro: 'item da fila nao encontrado' });
|
||||
return;
|
||||
}
|
||||
json(200, { queue: result });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/campaigns/:id/start', async (req, res, { params, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
if (whatsapp.getStatus().state !== 'READY') {
|
||||
json(409, { erro: 'WhatsApp local nao esta READY' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transaction(db, () => {
|
||||
const current = db.prepare('SELECT status, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaign.id);
|
||||
if (!current || !['ready', 'paused'].includes(current.status)) {
|
||||
transitionConflict('campanha precisa estar ready ou paused');
|
||||
}
|
||||
const scheduled = db.prepare(
|
||||
"SELECT 1 FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled' LIMIT 1"
|
||||
).get(campaign.id);
|
||||
if (!scheduled) transitionConflict('campanha precisa de ao menos um item agendado');
|
||||
if (campaignDayUsage(db, campaign.id, localDateKey(now())) > current.approved_daily_limit) {
|
||||
transitionConflict('fila ativa do dia excede o limite diario aprovado');
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE outreach_campaigns SET status = 'running', pause_reason = NULL, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(campaign.id);
|
||||
});
|
||||
} catch (error) {
|
||||
respondTransitionError(json, error);
|
||||
return;
|
||||
}
|
||||
json(200, { campaign: getCampaign(db, campaign.id) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/campaigns/:id/pause', async (req, res, { params, body, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const reason = typeof body?.reason === 'string' ? body.reason.trim() : '';
|
||||
if (!reason || reason.length > 500) {
|
||||
json(400, { erro: 'motivo da pausa e obrigatorio e deve ter ate 500 caracteres' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transaction(db, () => {
|
||||
const current = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id);
|
||||
if (!current || !['ready', 'running', 'paused'].includes(current.status)) {
|
||||
transitionConflict('campanha precisa estar ready, running ou paused para ser pausada');
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE outreach_campaigns SET status = 'paused', pause_reason = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(reason, campaign.id);
|
||||
});
|
||||
} catch (error) {
|
||||
respondTransitionError(json, error);
|
||||
return;
|
||||
}
|
||||
json(200, { campaign: getCampaign(db, campaign.id) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/campaigns/replies/pending', async (req, res, { json }) => {
|
||||
json(200, { pending: listPendingReplies(db) });
|
||||
});
|
||||
|
||||
router.add('PUT', '/api/campaigns/leads/:leadId/stage', async (req, res, { params, body, json }) => {
|
||||
const leadId = parseId(params.leadId);
|
||||
if (!leadId || !body || !body.stage) {
|
||||
json(400, { erro: 'leadId valido e stage sao obrigatorios' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = setLeadCommercialStage(db, {
|
||||
leadId,
|
||||
campaignId: body.campaignId ? parseId(body.campaignId) : null,
|
||||
stage: body.stage,
|
||||
note: body.note || null
|
||||
});
|
||||
json(200, { state });
|
||||
} catch (error) {
|
||||
if (/etapa comercial invalida/.test(error.message)) {
|
||||
json(400, { erro: error.message });
|
||||
} else {
|
||||
json(500, { erro: 'falha interna ao atualizar etapa' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.add('POST', '/api/campaigns/leads/:leadId/appointments', async (req, res, { params, body, json }) => {
|
||||
const leadId = parseId(params.leadId);
|
||||
if (!leadId || !body) {
|
||||
json(400, { erro: 'leadId valido e corpo da requisicao sao obrigatorios' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appointment = saveAppointment(db, {
|
||||
leadId,
|
||||
campaignId: body.campaignId ? parseId(body.campaignId) : null,
|
||||
kind: body.kind,
|
||||
participantName: body.participantName || null,
|
||||
companyName: body.companyName || null,
|
||||
preferredDay: body.preferredDay || null,
|
||||
preferredTime: body.preferredTime || null
|
||||
});
|
||||
json(201, { appointment });
|
||||
} catch (error) {
|
||||
if (/tipo de agendamento invalido/.test(error.message) || /requer participante/.test(error.message)) {
|
||||
json(400, { erro: error.message });
|
||||
} else {
|
||||
json(500, { erro: 'falha interna ao salvar agendamento' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.add('PUT', '/api/campaigns/:id/week', async (req, res, { params, body, json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const campaign = findCampaign(db, params.id, json);
|
||||
if (!campaign) return;
|
||||
const limit = WEEK_LIMITS.get(body?.week);
|
||||
if (!limit) {
|
||||
json(400, { erro: 'semana deve ser explicitamente 1, 2, 3 ou 4' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transaction(db, () => {
|
||||
if (campaignDayUsage(db, campaign.id, localDateKey(now())) > limit) {
|
||||
transitionConflict('fila ativa do dia excede o novo limite semanal');
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE outreach_campaigns
|
||||
SET week_number = ?, approved_daily_limit = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(body.week, limit, campaign.id);
|
||||
});
|
||||
} catch (error) {
|
||||
respondTransitionError(json, error);
|
||||
return;
|
||||
}
|
||||
json(200, { campaign: getCampaign(db, campaign.id) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { getSource } from '../../sources/index.mjs';
|
||||
import { isSqliteConstraintError } from '../../db.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.map((value) => typeof value === 'string' ? value.trim() : value) : [];
|
||||
const uf = String(body?.uf || '').toUpperCase();
|
||||
const cities = Array.isArray(body?.cities) ? body.cities.map((value) => typeof value === 'string' ? value.trim() : value) : [];
|
||||
const segments = Array.isArray(body?.segments) ? body.segments.map((value) => typeof value === 'string' ? value.trim() : value) : [];
|
||||
const limits = {
|
||||
maxPerQuery: Number(body?.limits?.maxPerQuery),
|
||||
queryLimit: Number(body?.limits?.queryLimit),
|
||||
googlePages: body?.limits?.googlePages === undefined ? 3 : Number(body.limits.googlePages)
|
||||
};
|
||||
|
||||
const invalidArray = (values, max) => !values.length || values.length > max
|
||||
|| values.some((value) => typeof value !== 'string' || !value || value.length > 100);
|
||||
if (invalidArray(sources, 10) || new Set(sources).size !== sources.length) {
|
||||
json(400, { erro: 'fontes invalidas: selecione de 1 a 10 fontes unicas' });
|
||||
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 (invalidArray(cities, 1000) || (cities.includes('all') && cities.length !== 1)) {
|
||||
json(400, { erro: 'cidades invalidas: selecione de 1 a 1000 municipios ou somente "all"' });
|
||||
return;
|
||||
}
|
||||
if (!cities.includes('all')) {
|
||||
const findCity = db.prepare('SELECT 1 FROM municipios WHERE uf = ? AND nome = ?');
|
||||
const unknown = cities.find((city) => !findCity.get(uf, city));
|
||||
if (unknown) {
|
||||
json(400, { erro: `municipio invalido para ${uf}: ${unknown}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (invalidArray(segments, 250)
|
||||
|| (segments.includes('all') && segments.some((segment) => segment !== 'all' && !segment.startsWith('cnae:')))) {
|
||||
json(400, { erro: 'segmentos invalidos: selecione de 1 a 250 nichos ou somente "all"' });
|
||||
return;
|
||||
}
|
||||
const findSegment = db.prepare('SELECT 1 FROM segments WHERE name = ? AND active = 1');
|
||||
const findCnae = db.prepare('SELECT 1 FROM cnaes WHERE codigo = ?');
|
||||
for (const segment of segments) {
|
||||
if (segment === 'all') continue;
|
||||
if (/^cnae:\d{7}$/.test(segment)) {
|
||||
if (findCnae.get(segment.slice(5))) continue;
|
||||
} else if (findSegment.get(segment)) {
|
||||
continue;
|
||||
}
|
||||
json(400, { erro: `segmento ou CNAE invalido: ${segment}` });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(limits.maxPerQuery) || limits.maxPerQuery < 1 || limits.maxPerQuery > 100
|
||||
|| !Number.isInteger(limits.queryLimit) || limits.queryLimit < 0 || limits.queryLimit > 10000
|
||||
|| !Number.isInteger(limits.googlePages) || limits.googlePages < 1 || limits.googlePages > 3) {
|
||||
json(400, { erro: 'limites invalidos: maxPerQuery 1..100, queryLimit 0..10000, googlePages 1..3' });
|
||||
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 };
|
||||
let inserted;
|
||||
try {
|
||||
inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')")
|
||||
.run(JSON.stringify(params));
|
||||
} catch (error) {
|
||||
if (!isSqliteConstraintError(error)) throw error;
|
||||
json(409, { erro: 'outra captura foi iniciada simultaneamente. Aguarde ou cancele.' });
|
||||
return;
|
||||
}
|
||||
const runId = Number(inserted.lastInsertRowid);
|
||||
const ownerToken = randomUUID();
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'captura',
|
||||
script: 'src/capture/capture-job.mjs',
|
||||
args: [`--run-id=${runId}`],
|
||||
meta: { runId, uf, sources },
|
||||
ownerToken
|
||||
});
|
||||
db.prepare("UPDATE capture_runs SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
|
||||
.run(jobId, ownerToken, 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,115 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { countLeads } from '../../treatment/queries.mjs';
|
||||
import { isSqliteConstraintError } from '../../db.mjs';
|
||||
|
||||
const TIPOS = new Set(['excel']);
|
||||
|
||||
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 requestedParams = body?.params && typeof body.params === 'object' ? body.params : {};
|
||||
const params = requestedParams.marcarExportado === false ? { marcarExportado: false } : {};
|
||||
|
||||
const running = db.prepare("SELECT id FROM exports WHERE status IN ('pending','running')").get();
|
||||
if (running) {
|
||||
json(409, { erro: `ja existe uma exportacao em andamento (#${running.id})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = countLeads(db, filtro);
|
||||
if (!total) {
|
||||
json(400, { erro: 'nenhum lead corresponde ao filtro selecionado' });
|
||||
return;
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = db.prepare(
|
||||
"INSERT INTO exports (tipo, filtro_json, params_json, status) VALUES (?, ?, ?, 'pending')"
|
||||
).run(tipo, JSON.stringify(filtro), JSON.stringify(params));
|
||||
} catch (error) {
|
||||
if (!isSqliteConstraintError(error)) throw error;
|
||||
json(409, { erro: 'outra exportacao foi iniciada simultaneamente. Aguarde terminar.' });
|
||||
return;
|
||||
}
|
||||
const exportId = Number(inserted.lastInsertRowid);
|
||||
const ownerToken = randomUUID();
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'exportacao',
|
||||
script: 'src/export/export-job.mjs',
|
||||
args: [`--export-id=${exportId}`],
|
||||
meta: { exportId, tipo, total },
|
||||
ownerToken
|
||||
});
|
||||
db.prepare("UPDATE exports SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
|
||||
.run(jobId, ownerToken, 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,77 @@
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { resolveZipReader } from '../../extract/rfb-extractor.mjs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isSqliteConstraintError } from '../../db.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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = db.prepare("INSERT INTO extractions (uf, status) VALUES (?, 'pending')").run(uf);
|
||||
} catch (error) {
|
||||
if (!isSqliteConstraintError(error)) throw error;
|
||||
json(409, { erro: 'outra extracao foi iniciada simultaneamente. Aguarde terminar.' });
|
||||
return;
|
||||
}
|
||||
const extractionId = Number(inserted.lastInsertRowid);
|
||||
const ownerToken = randomUUID();
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'extracao',
|
||||
script: 'src/extract/extract-job.mjs',
|
||||
args: [`--extraction-id=${extractionId}`],
|
||||
meta: { extractionId, uf },
|
||||
ownerToken
|
||||
});
|
||||
db.prepare("UPDATE extractions SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
|
||||
.run(jobId, ownerToken, 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,206 @@
|
||||
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.APP_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.APP_RESET_PASSWORD;
|
||||
}
|
||||
|
||||
const EDITABLE_SETTINGS = new Set([
|
||||
'meta_diaria',
|
||||
'outreach.ativo',
|
||||
'outreach.mensagensPorDia'
|
||||
]);
|
||||
|
||||
function validateSetting(key, value) {
|
||||
if (key === 'outreach.ativo') return typeof value === 'string' && (value === 'true' || value === 'false');
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number)) return false;
|
||||
if (key === 'meta_diaria') return number >= 1 && number <= 100000;
|
||||
if (key === 'outreach.mensagensPorDia') return number >= 1 && number <= 1000;
|
||||
return false;
|
||||
}
|
||||
|
||||
function requireAdmin(user, json) {
|
||||
if (user?.role === 'admin') return true;
|
||||
json(403, { erro: 'apenas administradores podem alterar configuracoes' });
|
||||
return false;
|
||||
}
|
||||
|
||||
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, whatsapp = { getStatus: () => ({ pendingSends: 0 }) } }) {
|
||||
router.add('GET', '/api/health', async (req, res, { json }) => {
|
||||
json(200, { ok: true, app: 'maquina-comercial-pessoal', 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, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
if (!body || typeof body !== 'object') {
|
||||
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
|
||||
return;
|
||||
}
|
||||
const invalidKeys = Object.keys(body).filter((key) => !EDITABLE_SETTINGS.has(key));
|
||||
if (invalidKeys.length) {
|
||||
json(400, { erro: `configuracoes nao permitidas: ${invalidKeys.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const invalidValues = Object.entries(body).filter(([key, value]) => !validateSetting(key, value));
|
||||
if (invalidValues.length) {
|
||||
json(400, { erro: `valores invalidos: ${invalidValues.map(([key]) => key).join(', ')}` });
|
||||
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, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const configuredPassword = resetBasePassword();
|
||||
if (typeof configuredPassword !== 'string' || !configuredPassword.trim()) {
|
||||
json(503, { erro: 'configure APP_RESET_PASSWORD no arquivo .env antes de usar o reset' });
|
||||
return;
|
||||
}
|
||||
if (!body || typeof body.senha !== 'string' || !body.senha || body.senha !== configuredPassword) {
|
||||
json(403, { erro: 'senha incorreta' });
|
||||
return;
|
||||
}
|
||||
if (jobs.activeJobs().length) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs ativos' });
|
||||
return;
|
||||
}
|
||||
if ((whatsapp.getStatus()?.pendingSends || 0) > 0) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha envios WhatsApp pendentes' });
|
||||
return;
|
||||
}
|
||||
const persistentJob = ['capture_runs', 'extractions', 'exports'].some((table) =>
|
||||
db.prepare(`SELECT 1 FROM ${table} WHERE status IN ('pending','running') LIMIT 1`).get()
|
||||
);
|
||||
if (persistentJob) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs persistentes ativos' });
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeadsBase(db);
|
||||
fs.rmSync(paths.exports, { recursive: true, force: true });
|
||||
fs.mkdirSync(paths.exports, { recursive: true });
|
||||
|
||||
json(200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
addOptOut,
|
||||
countDailyOutreachUsage,
|
||||
getSetting,
|
||||
isOptedOut,
|
||||
listMessageTemplates,
|
||||
listOptOuts,
|
||||
removeOptOut,
|
||||
reserveOutreachSend,
|
||||
saveWhatsAppVerification,
|
||||
finishOutreachSend,
|
||||
updateMessageTemplate
|
||||
} from '../../db.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 = countDailyOutreachUsage(db, { origem: 'manual' });
|
||||
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
|
||||
}
|
||||
|
||||
function requireAdmin(user, json) {
|
||||
if (user?.role === 'admin') return true;
|
||||
json(403, { erro: 'apenas administradores podem alterar opt-outs e templates' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function saveSendVerification(db, verification) {
|
||||
try {
|
||||
saveWhatsAppVerification(db, verification);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Nao foi possivel atualizar a verificacao local. Nao reenvie a mensagem por esse motivo.';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOutreachRoutes(router, { db, whatsapp }) {
|
||||
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
|
||||
const connection = whatsapp.getStatus();
|
||||
const limit = dailyLimitStatus(db);
|
||||
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: getSetting(db, 'outreach.ativo', 'false') === 'true',
|
||||
configurado: connection.state === 'READY',
|
||||
whatsapp: connection,
|
||||
mensagensPorDia: limit.limite,
|
||||
enviadosHoje: limit.enviadosHoje,
|
||||
restante: limit.restante,
|
||||
ultimos
|
||||
});
|
||||
});
|
||||
|
||||
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
|
||||
const requestedLeadId = body?.leadId;
|
||||
const leadId = requestedLeadId === undefined || requestedLeadId === null || requestedLeadId === '' ? null : requestedLeadId;
|
||||
if (leadId !== null && (!Number.isSafeInteger(leadId) || leadId <= 0)) {
|
||||
json(404, { erro: 'lead nao encontrado' });
|
||||
return;
|
||||
}
|
||||
const lead = leadId === null ? null : db.prepare('SELECT * FROM leads WHERE id = ?').get(leadId);
|
||||
if (leadId !== null && !lead) {
|
||||
json(404, { erro: 'lead nao encontrado' });
|
||||
return;
|
||||
}
|
||||
const rawNumber = String(body?.numero || '').trim();
|
||||
const messageTemplate = String(body?.mensagem || '').trim();
|
||||
const validation = validatePhone(rawNumber);
|
||||
if (!validation.valid) {
|
||||
json(400, { erro: `numero invalido (${validation.motivo}): use DDI+DDD+numero` });
|
||||
return;
|
||||
}
|
||||
if (!messageTemplate) {
|
||||
json(400, { erro: 'mensagem obrigatoria' });
|
||||
return;
|
||||
}
|
||||
|
||||
const number = onlyDigits(rawNumber);
|
||||
if (isOptedOut(db, number)) {
|
||||
json(409, { ok: false, erro: 'numero bloqueado por opt-out' });
|
||||
return;
|
||||
}
|
||||
const limit = dailyLimitStatus(db);
|
||||
if (limit.restante <= 0) {
|
||||
json(429, { ok: false, erro: `limite diario atingido (${limit.enviadosHoje}/${limit.limite})`, ...limit });
|
||||
return;
|
||||
}
|
||||
if (whatsapp.getStatus().state !== 'READY') {
|
||||
json(409, { ok: false, erro: 'WhatsApp local nao esta conectado' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
|
||||
const context = lead || clientContext;
|
||||
const message = renderTemplate(messageTemplate, { ...context, whatsapp: number });
|
||||
const produto = String(context.produto_principal || context.produto || '');
|
||||
|
||||
try {
|
||||
const externalKey = `pessoal-manual-${Date.now()}`;
|
||||
const result = await whatsapp.sendText({
|
||||
number,
|
||||
message,
|
||||
externalKey,
|
||||
beforeSend: () => reserveOutreachSend(db, { leadId: lead?.id || null, produto, numero: number, mensagem: message, origem: 'manual' }),
|
||||
afterSend: (sendResult, error, sendId) => finishOutreachSend(db, sendId, {
|
||||
messageId: sendResult?.messageId,
|
||||
error,
|
||||
unknown: error?.deliveryUnknown === true
|
||||
})
|
||||
});
|
||||
const verificationWarning = saveSendVerification(db, {
|
||||
...result,
|
||||
numero: number,
|
||||
status: 'confirmado'
|
||||
});
|
||||
json(202, {
|
||||
ok: true,
|
||||
status: 'aceito',
|
||||
messageId: result.messageId,
|
||||
...(result.resolvedNumber ? { resolvedNumber: result.resolvedNumber } : {}),
|
||||
...(result.variant ? { variant: result.variant } : {}),
|
||||
...(verificationWarning ? { aviso: verificationWarning } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.deliveryUnknown) {
|
||||
const verificationWarning = saveSendVerification(db, {
|
||||
...error,
|
||||
numero: number,
|
||||
status: 'confirmado'
|
||||
});
|
||||
json(202, {
|
||||
ok: false,
|
||||
status: 'resultado_desconhecido',
|
||||
...(error.resolvedNumber ? { resolvedNumber: error.resolvedNumber } : {}),
|
||||
...(error.variant ? { variant: error.variant } : {}),
|
||||
aviso: [
|
||||
'O envio pode ter sido aceito pelo WhatsApp, mas nao foi possivel confirmar o resultado. Nao reenvie sem confirmar no celular.',
|
||||
verificationWarning
|
||||
].filter(Boolean).join(' ')
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error.status === 'nao_cadastrado' || error.status === 'erro_temporario') {
|
||||
const verificationStatus = error.status === 'nao_cadastrado' ? 'nao_cadastrado' : 'erro_temporario';
|
||||
const verificationWarning = saveSendVerification(db, {
|
||||
...error,
|
||||
numero: number,
|
||||
status: verificationStatus,
|
||||
erro: verificationStatus === 'erro_temporario' ? error : null
|
||||
});
|
||||
error.verificationWarning = verificationWarning;
|
||||
}
|
||||
if (error.deliverySucceeded) {
|
||||
json(202, {
|
||||
ok: false,
|
||||
status: 'persistencia_incerta',
|
||||
messageId: error.messageId || null,
|
||||
aviso: 'A mensagem pode ter sido aceita pelo WhatsApp, mas o registro local falhou. Nao reenvie sem confirmar no celular.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const status = error.statusCode || (error.status === 'nao_cadastrado' ? 422 : 502);
|
||||
json(status, {
|
||||
ok: false,
|
||||
erro: status === 502 ? `falha ao enviar pelo WhatsApp local: ${error.message}` : error.message,
|
||||
...(error.verificationWarning ? { aviso: error.verificationWarning } : {}),
|
||||
...(error.limit || {})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
|
||||
json(200, { variables: COMMERCIAL_VARIABLES });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
|
||||
json(200, { templates: listMessageTemplates(db) });
|
||||
});
|
||||
|
||||
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
if (!body || typeof body !== 'object') {
|
||||
json(400, { erro: 'corpo JSON esperado: {"mensagem":"...","ativo":true}' });
|
||||
return;
|
||||
}
|
||||
const updated = updateMessageTemplate(db, params.produto, { mensagem: body.mensagem, ativo: body.ativo });
|
||||
if (!updated) {
|
||||
json(404, { erro: 'template nao encontrado' });
|
||||
return;
|
||||
}
|
||||
json(200, updated);
|
||||
});
|
||||
|
||||
router.add('GET', '/api/outreach/opt-outs', async (req, res, { json }) => {
|
||||
json(200, { optOuts: listOptOuts(db) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/outreach/opt-outs', async (req, res, { json, body, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const number = onlyDigits(String(body?.numero || ''));
|
||||
const validation = validatePhone(number);
|
||||
if (!validation.valid) {
|
||||
json(400, { erro: `numero invalido (${validation.motivo})` });
|
||||
return;
|
||||
}
|
||||
json(201, { optOut: addOptOut(db, number, body?.motivo) });
|
||||
});
|
||||
|
||||
router.add('DELETE', '/api/outreach/opt-outs/:numero', async (req, res, { json, params, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
if (!removeOptOut(db, params.numero)) {
|
||||
json(404, { erro: 'opt-out nao encontrado' });
|
||||
return;
|
||||
}
|
||||
json(200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { saveWhatsAppVerification } from '../../db.mjs';
|
||||
import { normalizePhone } from '../../phone.mjs';
|
||||
|
||||
function requireAdmin(user, json) {
|
||||
if (user?.role === 'admin') return true;
|
||||
json(403, { erro: 'apenas administradores podem gerenciar o WhatsApp' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function saveVerificationSafely(db, verification) {
|
||||
try {
|
||||
return { saved: saveWhatsAppVerification(db, verification), warning: null };
|
||||
} catch {
|
||||
return {
|
||||
saved: null,
|
||||
warning: 'Nao foi possivel atualizar a verificacao local.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWhatsAppRoutes(router, { db, whatsapp, toDataURL = QRCode.toDataURL }) {
|
||||
router.add('GET', '/api/whatsapp/status', async (req, res, { json }) => {
|
||||
json(200, whatsapp.getStatus());
|
||||
});
|
||||
|
||||
router.add('POST', '/api/whatsapp/connect', async (req, res, { json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
try {
|
||||
await whatsapp.connect();
|
||||
json(202, { ok: true, status: whatsapp.getStatus() });
|
||||
} catch (error) {
|
||||
json(503, { ok: false, erro: `nao foi possivel iniciar o WhatsApp: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
router.add('POST', '/api/whatsapp/refresh-qr', async (req, res, { json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
try {
|
||||
await whatsapp.refreshQr();
|
||||
json(202, { ok: true, status: whatsapp.getStatus() });
|
||||
} catch (error) {
|
||||
json(503, { ok: false, erro: `nao foi possivel gerar novo QR: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
router.add('POST', '/api/whatsapp/check-number', async (req, res, { json, user, body }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const requestedLeadId = body?.leadId;
|
||||
const leadId = requestedLeadId === undefined || requestedLeadId === null || requestedLeadId === '' ? null : requestedLeadId;
|
||||
if (leadId !== null && (!Number.isSafeInteger(leadId) || leadId <= 0)) {
|
||||
json(404, { erro: 'lead nao encontrado' });
|
||||
return;
|
||||
}
|
||||
const lead = leadId === null ? null : db.prepare('SELECT id FROM leads WHERE id = ?').get(leadId);
|
||||
if (leadId !== null && !lead) {
|
||||
json(404, { erro: 'lead nao encontrado' });
|
||||
return;
|
||||
}
|
||||
const numero = normalizePhone(body?.numero);
|
||||
try {
|
||||
const result = await whatsapp.checkNumber(body?.numero);
|
||||
const status = result.registered ? 'confirmado' : 'nao_cadastrado';
|
||||
const verification = { ...result, numero, status };
|
||||
const persistence = saveVerificationSafely(db, verification);
|
||||
json(status === 'nao_cadastrado' ? 422 : 200, {
|
||||
...verification,
|
||||
checked_at: persistence.saved?.checked_at ?? null,
|
||||
...(persistence.warning ? { aviso: persistence.warning } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
let persistence = null;
|
||||
let status = null;
|
||||
if (error.status === 'erro_temporario' || error.status === 'nao_cadastrado') {
|
||||
status = error.status === 'nao_cadastrado' ? 'nao_cadastrado' : 'erro_temporario';
|
||||
persistence = saveVerificationSafely(db, {
|
||||
...error,
|
||||
numero,
|
||||
status,
|
||||
erro: status === 'erro_temporario' ? error : null
|
||||
});
|
||||
}
|
||||
json(error.statusCode || (error.status === 'nao_cadastrado' ? 422 : 503), {
|
||||
erro: error.message,
|
||||
...(status ? {
|
||||
numero,
|
||||
status,
|
||||
checked_at: persistence.saved?.checked_at ?? null,
|
||||
...(persistence.warning ? { aviso: persistence.warning } : {})
|
||||
} : {})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.add('GET', '/api/whatsapp/qr', async (req, res, { json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const value = whatsapp.getQr();
|
||||
if (!value) {
|
||||
json(409, { erro: 'QR nao esta disponivel ou expirou; solicite uma nova conexao' });
|
||||
return;
|
||||
}
|
||||
const qr = await toDataURL(value, { errorCorrectionLevel: 'M', margin: 1 });
|
||||
json(200, { qr, expiresAt: whatsapp.getStatus().qrExpiresAt });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/whatsapp/disconnect', async (req, res, { json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const status = await whatsapp.disconnect({ logout: false });
|
||||
json(200, { ok: true, status });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/whatsapp/logout', async (req, res, { json, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const status = await whatsapp.disconnect({ logout: true });
|
||||
json(200, { ok: true, status });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 = 'maquina_pessoal_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 destroyUserSessions(user) {
|
||||
for (const [token, session] of sessions) {
|
||||
if ((user.id != null && session.user?.id === user.id)
|
||||
|| (user.username && session.user?.username === user.username)) {
|
||||
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`;
|
||||
}
|
||||
@@ -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,182 @@
|
||||
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';
|
||||
import { fetchJsonWithTimeout } from '../util/fetch.mjs';
|
||||
|
||||
async function brasilApiCnpj(baseUrl, cnpj) {
|
||||
if (!cnpj || cnpj.length !== 14) return null;
|
||||
const { response, data } = await fetchJsonWithTimeout(`${baseUrl}/cnpj/v1/${cnpj}`, { headers: UA_HEADERS });
|
||||
if (!response.ok) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
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)
|
||||
}));
|
||||
|
||||
const exact = path.join(ctx.cnpjDir, `estabelecimentos-${String(uf).toUpperCase()}.csv`);
|
||||
const legacy = path.join(ctx.cnpjDir, 'estabelecimentos-bahia.csv');
|
||||
let files = [];
|
||||
try {
|
||||
await fs.access(exact);
|
||||
files = [exact];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
if (String(uf).toUpperCase() === 'BA') {
|
||||
try {
|
||||
await fs.access(legacy);
|
||||
files = [legacy];
|
||||
} catch (legacyError) {
|
||||
if (legacyError.code !== 'ENOENT') throw legacyError;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!files.length) {
|
||||
ctx.log(`base CNPJ de ${uf} nao encontrada em ${ctx.cnpjDir} — fonte cnpj-base pulada`);
|
||||
return;
|
||||
}
|
||||
|
||||
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': 'Maquina-Comercial-Pessoal/1.0'
|
||||
};
|
||||
|
||||
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,102 @@
|
||||
import { sleep } from '../util/collections.mjs';
|
||||
import { buildLeadFromPlace } from './common.mjs';
|
||||
import { fetchJsonWithTimeout } from '../util/fetch.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, data } = await fetchJsonWithTimeout(url);
|
||||
if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`);
|
||||
|
||||
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, data } = await fetchJsonWithTimeout(url);
|
||||
if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`);
|
||||
return data.result || {};
|
||||
}
|
||||
|
||||
// Fonte google-places: exige GOOGLE_PLACES_API_KEY (paga). Traz telefone e
|
||||
// website com boa cobertura.
|
||||
export async function* collect(params, ctx) {
|
||||
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||
if (!apiKey || apiKey === 'preencher_google_maps_api_key') {
|
||||
throw new Error('GOOGLE_PLACES_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 Places (paga, exige GOOGLE_PLACES_API_KEY no .env).',
|
||||
gratuita: false,
|
||||
recomendada: false,
|
||||
needsKey: 'GOOGLE_PLACES_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';
|
||||
import { fetchTextWithTimeout } from '../util/fetch.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, text } = await fetchTextWithTimeout(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
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,225 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { sleep } from '../util/collections.mjs';
|
||||
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
|
||||
import { fetchTextWithTimeout } from '../util/fetch.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 || ''
|
||||
};
|
||||
}
|
||||
|
||||
export 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 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 result = await fetchTextWithTimeout(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...UA_HEADERS,
|
||||
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
body: new URLSearchParams({ data: query })
|
||||
}, timeoutMs, { maxBytes: 50 * 1024 * 1024 }).catch((error) => {
|
||||
lastError = /timeout/i.test(error.message)
|
||||
? new Error(`Overpass timeout apos ${timeoutMs}ms em ${apiUrl}`)
|
||||
: error;
|
||||
recordFailure();
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result) continue;
|
||||
const { response, text } = result;
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = new Error(`Overpass HTTP ${response.status} em ${apiUrl}: ${text.slice(0, 300)}`);
|
||||
recordFailure();
|
||||
if (response.status === 429) await sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
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,103 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
|
||||
import { escapeOverpassRegex } from './overpass.mjs';
|
||||
import { fetchJsonWithTimeout } from '../util/fetch.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, data } = await fetchJsonWithTimeout(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) return '';
|
||||
|
||||
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, data } = await fetchJsonWithTimeout(url, {
|
||||
headers: { ...UA_HEADERS, accept: 'application/sparql-results+json' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Wikidata HTTP ${response.status}`);
|
||||
|
||||
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,188 @@
|
||||
// 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.
|
||||
|
||||
import { getWhatsAppVerification } from '../db.mjs';
|
||||
|
||||
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).map((lead) => withWhatsAppState(db, lead));
|
||||
|
||||
return { total, page, pageSize, rows };
|
||||
}
|
||||
|
||||
export function getLead(db, id) {
|
||||
const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
|
||||
const lead = storedLead ? withWhatsAppState(db, storedLead) : null;
|
||||
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;
|
||||
|
||||
const commercialState = db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(id);
|
||||
|
||||
const stageHistory = db.prepare(
|
||||
`SELECT id, campaign_id, stage, note, created_at
|
||||
FROM outreach_stage_events
|
||||
WHERE lead_id = ?
|
||||
ORDER BY id DESC`
|
||||
).all(id);
|
||||
|
||||
const appointments = db.prepare(
|
||||
`SELECT * FROM outreach_appointments
|
||||
WHERE lead_id = ?
|
||||
ORDER BY id DESC`
|
||||
).all(id);
|
||||
|
||||
return {
|
||||
...lead,
|
||||
pains: JSON.parse(lead.pains_json || '[]'),
|
||||
produtos: JSON.parse(lead.produtos_json || '[]'),
|
||||
afinidade: segmentInfo?.affinity || null,
|
||||
exports: exportHistory,
|
||||
commercial_state: commercialState || null,
|
||||
stage_history: stageHistory,
|
||||
appointments
|
||||
};
|
||||
}
|
||||
|
||||
function withWhatsAppState(db, lead) {
|
||||
const verification = getWhatsAppVerification(db, lead.whatsapp);
|
||||
return {
|
||||
...lead,
|
||||
whatsapp_status: verification.status,
|
||||
whatsapp_numero_resolvido: verification.numero_resolvido,
|
||||
whatsapp_checked_at: verification.checked_at
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function neutralizeSpreadsheetFormula(value) {
|
||||
const text = String(value ?? '');
|
||||
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : 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,82 @@
|
||||
export async function fetchWithTimeout(url, options = {}, timeoutMs = 25_000) {
|
||||
const { fetch: fetchImpl = globalThis.fetch, ...fetchOptions } = options;
|
||||
const timeout = new AbortController();
|
||||
const signal = fetchOptions.signal
|
||||
? AbortSignal.any([fetchOptions.signal, timeout.signal])
|
||||
: timeout.signal;
|
||||
const timer = setTimeout(
|
||||
() => timeout.abort(new Error(`timeout de rede apos ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
timer.unref?.();
|
||||
try {
|
||||
return await fetchImpl(url, { ...fetchOptions, signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeText(response, maxBytes) {
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`corpo excede limite de ${maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
async function fetchBodyWithTimeout(url, options, timeoutMs, consume) {
|
||||
const { fetch: fetchImpl = globalThis.fetch, ...fetchOptions } = options;
|
||||
const timeout = new AbortController();
|
||||
const signal = fetchOptions.signal
|
||||
? AbortSignal.any([fetchOptions.signal, timeout.signal])
|
||||
: timeout.signal;
|
||||
const timer = setTimeout(() => timeout.abort(new Error(`timeout de rede apos ${timeoutMs}ms`)), timeoutMs);
|
||||
timer.unref?.();
|
||||
let rejectAbort;
|
||||
const aborted = new Promise((resolve, reject) => { rejectAbort = reject; });
|
||||
const onAbort = () => rejectAbort(signal.reason || new Error('requisicao abortada'));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([(async () => {
|
||||
const response = await fetchImpl(url, { ...fetchOptions, signal });
|
||||
return consume(response);
|
||||
})(), aborted]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchTextWithTimeout(url, options = {}, timeoutMs = 25_000, { maxBytes = 1_048_576 } = {}) {
|
||||
return fetchBodyWithTimeout(url, options, timeoutMs, async (response) => ({
|
||||
response,
|
||||
text: await consumeText(response, maxBytes)
|
||||
}));
|
||||
}
|
||||
|
||||
export function fetchJsonWithTimeout(url, options = {}, timeoutMs = 25_000, { maxBytes = 10_485_760 } = {}) {
|
||||
return fetchBodyWithTimeout(url, options, timeoutMs, async (response) => {
|
||||
const text = await consumeText(response, maxBytes);
|
||||
return { response, data: JSON.parse(text) };
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
|
||||
|
||||
let signalLogFilterUsers = 0;
|
||||
let originalConsoleInfo;
|
||||
let filteredConsoleInfo;
|
||||
|
||||
function acquireSignalLogFilter() {
|
||||
if (signalLogFilterUsers === 0) {
|
||||
originalConsoleInfo = console.info;
|
||||
filteredConsoleInfo = (...args) => {
|
||||
if (args[0] === 'Closing session:') return;
|
||||
originalConsoleInfo(...args);
|
||||
};
|
||||
console.info = filteredConsoleInfo;
|
||||
}
|
||||
signalLogFilterUsers += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
signalLogFilterUsers -= 1;
|
||||
if (signalLogFilterUsers === 0 && console.info === filteredConsoleInfo) console.info = originalConsoleInfo;
|
||||
};
|
||||
}
|
||||
|
||||
export const WHATSAPP_STATES = Object.freeze({
|
||||
STOPPED: 'STOPPED',
|
||||
STARTING: 'STARTING',
|
||||
QR_REQUIRED: 'QR_REQUIRED',
|
||||
AUTHENTICATING: 'AUTHENTICATING',
|
||||
READY: 'READY',
|
||||
RECONNECTING: 'RECONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
ERROR: 'ERROR'
|
||||
});
|
||||
|
||||
function disconnectCode(error) {
|
||||
return error?.output?.statusCode ?? error?.statusCode ?? error?.data?.statusCode;
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, createError) {
|
||||
let timer;
|
||||
const provider = Promise.resolve(promise);
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(createError()), timeoutMs);
|
||||
});
|
||||
provider.catch(() => {});
|
||||
return Promise.race([provider, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function removeAuthDir(authDir) {
|
||||
const resolved = path.resolve(authDir);
|
||||
if (resolved === path.parse(resolved).root) throw new Error('authDir inseguro');
|
||||
await fs.rm(resolved, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function hasWhatsAppSession(authDir) {
|
||||
try {
|
||||
await fs.access(path.join(authDir, 'creds.json'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWhatsAppManager({
|
||||
authDir,
|
||||
loadBaileys = () => import('baileys'),
|
||||
qrTtlMs = 60_000,
|
||||
reconnectDelayMs = 2_000,
|
||||
maxReconnectAttempts = 5,
|
||||
sendDrainTimeoutMs = 15_000,
|
||||
lookupTimeoutMs = 10_000,
|
||||
messageTimeoutMs = 30_000,
|
||||
onMessageStatus = () => {},
|
||||
onInboundMessage = () => {},
|
||||
log = () => {}
|
||||
}) {
|
||||
if (!authDir) throw new Error('authDir obrigatorio');
|
||||
const releaseSignalLogFilter = acquireSignalLogFilter();
|
||||
|
||||
let state = WHATSAPP_STATES.STOPPED;
|
||||
let socket = null;
|
||||
let connectPromise = null;
|
||||
let sendQueue = Promise.resolve();
|
||||
let qr = null;
|
||||
let qrExpiresAt = 0;
|
||||
let qrTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let intentionalDisconnect = false;
|
||||
let closed = false;
|
||||
let lastError = null;
|
||||
let generation = 0;
|
||||
let reconnectAttempts = 0;
|
||||
let lockToken = null;
|
||||
let pendingSends = 0;
|
||||
let cleanupPromise = null;
|
||||
const pendingMessageStatuses = new Map();
|
||||
const messageStatusInFlight = new Map();
|
||||
const inboundMessageIds = new Set();
|
||||
const lockPath = path.join(path.dirname(authDir), `${path.basename(authDir)}.lock`);
|
||||
const recoveryPath = `${lockPath}.recovery`;
|
||||
|
||||
const beginCleanup = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
cleanupPromise = promise;
|
||||
return () => {
|
||||
if (cleanupPromise === promise) cleanupPromise = null;
|
||||
resolve();
|
||||
};
|
||||
};
|
||||
|
||||
const processAlive = (pid) => {
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
};
|
||||
|
||||
const acquireLock = async () => {
|
||||
if (lockToken) return;
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const token = crypto.randomUUID();
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const handle = await fs.open(lockPath, 'wx');
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ pid: process.pid, token }));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
lockToken = token;
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
|
||||
const recoveryToken = crypto.randomUUID();
|
||||
let recoveryHandle;
|
||||
try {
|
||||
recoveryHandle = await fs.open(recoveryPath, 'wx');
|
||||
} catch (recoveryError) {
|
||||
if (recoveryError.code === 'EEXIST') {
|
||||
let recoveryHolder;
|
||||
try {
|
||||
recoveryHolder = JSON.parse(await fs.readFile(recoveryPath, 'utf8'));
|
||||
} catch (readError) {
|
||||
if (readError.code === 'ENOENT') continue;
|
||||
if (!(readError instanceof SyntaxError)) throw readError;
|
||||
}
|
||||
if (recoveryHolder?.pid && processAlive(recoveryHolder.pid)) {
|
||||
throw new Error(`recuperacao do lock do authDir em andamento (PID ${recoveryHolder.pid})`);
|
||||
}
|
||||
const staleRecovery = `${recoveryPath}.stale-${process.pid}-${crypto.randomUUID()}`;
|
||||
try {
|
||||
await fs.rename(recoveryPath, staleRecovery);
|
||||
} catch (renameError) {
|
||||
if (renameError.code === 'ENOENT') continue;
|
||||
throw renameError;
|
||||
}
|
||||
await fs.rm(staleRecovery, { force: true });
|
||||
recoveryHandle = await fs.open(recoveryPath, 'wx');
|
||||
} else {
|
||||
throw recoveryError;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await recoveryHandle.writeFile(JSON.stringify({ pid: process.pid, token: recoveryToken }));
|
||||
} finally {
|
||||
await recoveryHandle.close();
|
||||
}
|
||||
|
||||
let holder;
|
||||
try {
|
||||
holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
||||
} catch (readError) {
|
||||
if (readError.code === 'ENOENT') continue;
|
||||
if (readError instanceof SyntaxError) holder = null;
|
||||
else throw readError;
|
||||
}
|
||||
if (holder?.pid && processAlive(holder.pid)) throw new Error(`authDir em uso por outro processo (PID ${holder.pid})`);
|
||||
|
||||
const stalePath = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
|
||||
try {
|
||||
await fs.rename(lockPath, stalePath);
|
||||
} catch (renameError) {
|
||||
if (renameError.code === 'ENOENT') continue;
|
||||
throw renameError;
|
||||
}
|
||||
await fs.rm(stalePath, { force: true });
|
||||
} finally {
|
||||
try {
|
||||
const recoveryHolder = JSON.parse(await fs.readFile(recoveryPath, 'utf8'));
|
||||
if (recoveryHolder.token === recoveryToken) await fs.rm(recoveryPath, { force: true });
|
||||
} catch (releaseError) {
|
||||
if (releaseError.code !== 'ENOENT') throw releaseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('nao foi possivel adquirir lock do authDir');
|
||||
};
|
||||
|
||||
const releaseLock = async () => {
|
||||
const token = lockToken;
|
||||
lockToken = null;
|
||||
if (!token) return;
|
||||
try {
|
||||
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
||||
if (holder.token === token) await fs.rm(lockPath, { force: true });
|
||||
} catch {
|
||||
// lock ja removido
|
||||
}
|
||||
};
|
||||
|
||||
const clearQr = () => {
|
||||
qr = null;
|
||||
qrExpiresAt = 0;
|
||||
if (qrTimer) clearTimeout(qrTimer);
|
||||
qrTimer = null;
|
||||
};
|
||||
|
||||
const setQr = (value) => {
|
||||
clearQr();
|
||||
qr = value;
|
||||
qrExpiresAt = Date.now() + qrTtlMs;
|
||||
state = WHATSAPP_STATES.QR_REQUIRED;
|
||||
qrTimer = setTimeout(() => {
|
||||
clearQr();
|
||||
if (state === WHATSAPP_STATES.QR_REQUIRED) state = WHATSAPP_STATES.AUTHENTICATING;
|
||||
}, qrTtlMs);
|
||||
qrTimer.unref?.();
|
||||
};
|
||||
|
||||
const scheduleReconnect = (delay = reconnectDelayMs) => {
|
||||
if (closed || intentionalDisconnect || reconnectTimer) return;
|
||||
if (reconnectAttempts >= maxReconnectAttempts) {
|
||||
lastError = `limite de ${maxReconnectAttempts} reconexoes atingido`;
|
||||
state = WHATSAPP_STATES.ERROR;
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
state = WHATSAPP_STATES.RECONNECTING;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect({ manual: false }).catch((error) => {
|
||||
lastError = String(error.message || error);
|
||||
if (!reconnectTimer) state = WHATSAPP_STATES.ERROR;
|
||||
log(`whatsapp: reconexao falhou: ${lastError}`);
|
||||
});
|
||||
}, delay);
|
||||
reconnectTimer.unref?.();
|
||||
};
|
||||
|
||||
const handleUpdate = async (currentSocket, baileys, update) => {
|
||||
if (currentSocket !== socket) return;
|
||||
if (update.qr) setQr(update.qr);
|
||||
if (update.connection === 'connecting' && !update.qr) {
|
||||
state = qr ? WHATSAPP_STATES.AUTHENTICATING : WHATSAPP_STATES.STARTING;
|
||||
}
|
||||
if (update.connection === 'open') {
|
||||
clearQr();
|
||||
lastError = null;
|
||||
state = WHATSAPP_STATES.READY;
|
||||
reconnectAttempts = 0;
|
||||
return;
|
||||
}
|
||||
if (update.connection !== 'close') return;
|
||||
|
||||
clearQr();
|
||||
socket = null;
|
||||
const code = disconnectCode(update.lastDisconnect?.error);
|
||||
const fatalReasons = new Set([
|
||||
baileys.DisconnectReason?.loggedOut,
|
||||
baileys.DisconnectReason?.badSession,
|
||||
baileys.DisconnectReason?.forbidden,
|
||||
baileys.DisconnectReason?.multideviceMismatch,
|
||||
baileys.DisconnectReason?.connectionReplaced
|
||||
]);
|
||||
if (fatalReasons.has(code)) {
|
||||
const finishCleanup = beginCleanup();
|
||||
intentionalDisconnect = true;
|
||||
try {
|
||||
if (code === baileys.DisconnectReason?.loggedOut) {
|
||||
await removeAuthDir(authDir);
|
||||
}
|
||||
state = code === baileys.DisconnectReason?.loggedOut ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.ERROR;
|
||||
lastError = code === baileys.DisconnectReason?.connectionReplaced
|
||||
? 'conexao substituida por outra instancia'
|
||||
: `falha fatal do WhatsApp (${code})`;
|
||||
await releaseLock();
|
||||
} finally {
|
||||
finishCleanup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
log(`whatsapp: conexao encerrada (${code ?? 'sem codigo'}); tentando reconectar`);
|
||||
if (intentionalDisconnect || closed) return;
|
||||
scheduleReconnect(code === baileys.DisconnectReason?.restartRequired ? 0 : reconnectDelayMs);
|
||||
};
|
||||
|
||||
function connect({ manual = true } = {}) {
|
||||
if (closed) return Promise.reject(new Error('manager encerrado'));
|
||||
if (cleanupPromise) return cleanupPromise.then(() => connect({ manual }));
|
||||
if (socket || connectPromise) return connectPromise || Promise.resolve(getStatus());
|
||||
if (manual) reconnectAttempts = 0;
|
||||
intentionalDisconnect = false;
|
||||
const currentGeneration = ++generation;
|
||||
state = WHATSAPP_STATES.STARTING;
|
||||
lastError = null;
|
||||
connectPromise = (async () => {
|
||||
try {
|
||||
await acquireLock();
|
||||
const baileys = await loadBaileys();
|
||||
if (currentGeneration !== generation || closed || intentionalDisconnect) {
|
||||
await releaseLock();
|
||||
return getStatus();
|
||||
}
|
||||
const { state: authState, saveCreds } = await baileys.useMultiFileAuthState(authDir);
|
||||
if (currentGeneration !== generation || closed || intentionalDisconnect) {
|
||||
await releaseLock();
|
||||
return getStatus();
|
||||
}
|
||||
const currentSocket = baileys.default({ auth: authState, printQRInTerminal: false });
|
||||
if (currentGeneration !== generation || closed || intentionalDisconnect) {
|
||||
currentSocket.end?.(new Error('conexao cancelada'));
|
||||
await releaseLock();
|
||||
return getStatus();
|
||||
}
|
||||
socket = currentSocket;
|
||||
currentSocket.ev.on('creds.update', (...args) => {
|
||||
Promise.resolve(saveCreds(...args)).catch((error) => {
|
||||
lastError = `falha ao salvar credenciais: ${error.message || error}`;
|
||||
state = WHATSAPP_STATES.ERROR;
|
||||
log(`whatsapp: ${lastError}`);
|
||||
});
|
||||
});
|
||||
currentSocket.ev.on('connection.update', (update) => {
|
||||
handleUpdate(currentSocket, baileys, update).catch((error) => {
|
||||
lastError = String(error.message || error);
|
||||
state = WHATSAPP_STATES.ERROR;
|
||||
log(`whatsapp: falha ao processar conexao: ${lastError}`);
|
||||
});
|
||||
});
|
||||
currentSocket.ev.on('messages.upsert', (upsert) => {
|
||||
if (currentSocket !== socket || upsert?.type !== 'notify' || !Array.isArray(upsert.messages)) return;
|
||||
for (const message of upsert.messages) {
|
||||
const messageId = message?.key?.id;
|
||||
const jid = message?.key?.remoteJid;
|
||||
const match = typeof jid === 'string' ? /^(\d+)(?::\d+)?@s\.whatsapp\.net$/.exec(jid) : null;
|
||||
if (!messageId || message.key.fromMe !== false || !match || inboundMessageIds.has(String(messageId))) continue;
|
||||
const number = normalizePhone(match[1]);
|
||||
if (!number) continue;
|
||||
const rawTimestamp = typeof message.messageTimestamp?.toNumber === 'function'
|
||||
? message.messageTimestamp.toNumber()
|
||||
: Number(message.messageTimestamp);
|
||||
const timestampMs = Number.isFinite(rawTimestamp)
|
||||
? rawTimestamp * (rawTimestamp < 1e12 ? 1_000 : 1)
|
||||
: Date.now();
|
||||
inboundMessageIds.add(String(messageId));
|
||||
Promise.resolve().then(() => onInboundMessage({
|
||||
messageId: String(messageId),
|
||||
jid,
|
||||
number,
|
||||
receivedAt: new Date(timestampMs).toISOString()
|
||||
})).catch((error) => log(`whatsapp: falha ao persistir mensagem recebida: ${error.message || error}`));
|
||||
}
|
||||
});
|
||||
currentSocket.ev.on('messages.update', (updates) => {
|
||||
if (currentSocket !== socket || !Array.isArray(updates)) return;
|
||||
const statusNames = { 0: 'erro', 2: 'enviado', 3: 'entregue', 4: 'lido', 5: 'lido' };
|
||||
for (const item of updates) {
|
||||
const status = statusNames[item?.update?.status];
|
||||
const messageId = item?.key?.id;
|
||||
if (!status || !messageId || item?.key?.fromMe === false) continue;
|
||||
const event = {
|
||||
messageId: String(messageId),
|
||||
jid: item.key.remoteJid || null,
|
||||
status
|
||||
};
|
||||
const applying = Promise.resolve().then(() => onMessageStatus(event)).then((applied) => {
|
||||
if (applied) {
|
||||
pendingMessageStatuses.delete(event.messageId);
|
||||
} else if (pendingSends > 0) {
|
||||
const ranks = { erro: 5, enviado: 2, entregue: 3, lido: 4 };
|
||||
const previous = pendingMessageStatuses.get(event.messageId);
|
||||
if (!previous || ranks[event.status] > ranks[previous.status]) pendingMessageStatuses.set(event.messageId, event);
|
||||
}
|
||||
return applied;
|
||||
});
|
||||
messageStatusInFlight.set(event.messageId, applying);
|
||||
applying.catch((error) => log(`whatsapp: falha ao persistir status da mensagem: ${error.message || error}`))
|
||||
.finally(() => {
|
||||
if (messageStatusInFlight.get(event.messageId) === applying) messageStatusInFlight.delete(event.messageId);
|
||||
});
|
||||
}
|
||||
});
|
||||
return getStatus();
|
||||
} catch (error) {
|
||||
socket = null;
|
||||
lastError = String(error.message || error);
|
||||
state = WHATSAPP_STATES.ERROR;
|
||||
await releaseLock();
|
||||
if (!closed && !intentionalDisconnect) scheduleReconnect();
|
||||
throw error;
|
||||
} finally {
|
||||
connectPromise = null;
|
||||
}
|
||||
})();
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return {
|
||||
state,
|
||||
connected: state === WHATSAPP_STATES.READY,
|
||||
qrExpiresAt: qrExpiresAt ? new Date(qrExpiresAt).toISOString() : null,
|
||||
error: lastError,
|
||||
pendingSends
|
||||
};
|
||||
}
|
||||
|
||||
function getQr() {
|
||||
if (!qr || Date.now() >= qrExpiresAt) {
|
||||
clearQr();
|
||||
return null;
|
||||
}
|
||||
return qr;
|
||||
}
|
||||
|
||||
const resolutionMetadata = (digits, candidates, registered) => {
|
||||
const resolvedNumber = registered?.normalizedNumber || null;
|
||||
return {
|
||||
numero: digits,
|
||||
status: registered ? 'confirmado' : 'nao_cadastrado',
|
||||
registered: Boolean(registered),
|
||||
resolvedNumber,
|
||||
variant: !registered ? null : resolvedNumber === digits ? 'exata' : digits.length === 13 ? 'sem_nono_digito' : 'com_nono_digito',
|
||||
candidatesChecked: candidates.length
|
||||
};
|
||||
};
|
||||
|
||||
const temporaryResolutionMetadata = (digits, candidates) => ({
|
||||
numero: digits,
|
||||
status: 'erro_temporario',
|
||||
registered: false,
|
||||
resolvedNumber: null,
|
||||
variant: null,
|
||||
candidatesChecked: candidates.length
|
||||
});
|
||||
|
||||
async function resolveNumber(number) {
|
||||
const digits = normalizePhone(number);
|
||||
if (!digits) {
|
||||
const error = new Error('numero obrigatorio');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const candidates = equivalentPhoneNumbers(digits);
|
||||
if (state !== WHATSAPP_STATES.READY || !socket) {
|
||||
throw Object.assign(new Error('WhatsApp nao esta conectado'), temporaryResolutionMetadata(digits, candidates));
|
||||
}
|
||||
const providerSocket = socket;
|
||||
let matches;
|
||||
try {
|
||||
matches = await withTimeout(
|
||||
Promise.resolve().then(() => providerSocket.onWhatsApp(...candidates)),
|
||||
lookupTimeoutMs,
|
||||
() => Object.assign(new Error(`consulta do numero no WhatsApp excedeu ${lookupTimeoutMs}ms`), {
|
||||
statusCode: 504,
|
||||
code: 'WHATSAPP_LOOKUP_TIMEOUT'
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
throw Object.assign(error, temporaryResolutionMetadata(digits, candidates));
|
||||
}
|
||||
const candidateSet = new Set(candidates.map((candidate) => normalizePhone(candidate)).filter(Boolean));
|
||||
const available = (matches || [])
|
||||
.filter((item) => item?.exists && item?.jid)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
normalizedNumber: normalizePhone(item.jid.split('@')[0].split(':')[0])
|
||||
}))
|
||||
.filter((item) => candidateSet.has(item.normalizedNumber));
|
||||
const registered = available.find((item) => item.normalizedNumber === digits) || available[0] || null;
|
||||
return { providerSocket, registered, metadata: resolutionMetadata(digits, candidates, registered) };
|
||||
}
|
||||
|
||||
async function checkNumber(number) {
|
||||
const { metadata } = await resolveNumber(number);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function sendText({ number, message, externalKey, beforeSend, afterSend }) {
|
||||
pendingSends += 1;
|
||||
const operation = sendQueue.then(async () => {
|
||||
const text = String(message || '').trim();
|
||||
if (!text) throw new Error('mensagem obrigatoria');
|
||||
const { providerSocket, registered, metadata } = await resolveNumber(number);
|
||||
if (!registered) {
|
||||
const error = new Error('numero nao esta cadastrado no WhatsApp');
|
||||
error.statusCode = 422;
|
||||
throw Object.assign(error, metadata);
|
||||
}
|
||||
const context = await beforeSend?.();
|
||||
let result;
|
||||
try {
|
||||
if (providerSocket !== socket || state !== WHATSAPP_STATES.READY) {
|
||||
throw Object.assign(new Error('conexao do WhatsApp mudou antes do envio'), {
|
||||
statusCode: 503,
|
||||
code: 'WHATSAPP_SOCKET_STALE',
|
||||
numero: metadata.numero,
|
||||
resolvedNumber: metadata.resolvedNumber,
|
||||
variant: metadata.variant
|
||||
});
|
||||
}
|
||||
const response = await withTimeout(
|
||||
Promise.resolve().then(() => providerSocket.sendMessage(registered.jid, { text })),
|
||||
messageTimeoutMs,
|
||||
() => Object.assign(new Error(`envio pelo WhatsApp excedeu ${messageTimeoutMs}ms`), {
|
||||
statusCode: 504,
|
||||
code: 'WHATSAPP_SEND_TIMEOUT',
|
||||
deliveryUnknown: true,
|
||||
numero: metadata.numero,
|
||||
resolvedNumber: metadata.resolvedNumber,
|
||||
variant: metadata.variant
|
||||
})
|
||||
);
|
||||
const messageId = response?.key?.id;
|
||||
if (!messageId) {
|
||||
throw Object.assign(new Error('WhatsApp nao retornou o identificador da mensagem'), {
|
||||
code: 'WHATSAPP_MESSAGE_ID_MISSING',
|
||||
deliveryUnknown: true
|
||||
});
|
||||
}
|
||||
result = { messageId: String(messageId), resolvedNumber: metadata.resolvedNumber, variant: metadata.variant };
|
||||
} catch (error) {
|
||||
Object.assign(error, {
|
||||
numero: error.numero ?? metadata.numero,
|
||||
resolvedNumber: error.resolvedNumber ?? metadata.resolvedNumber,
|
||||
variant: error.variant ?? metadata.variant
|
||||
});
|
||||
if (error.code === 'WHATSAPP_SEND_TIMEOUT' && providerSocket === socket) {
|
||||
generation += 1;
|
||||
socket = null;
|
||||
clearQr();
|
||||
try {
|
||||
providerSocket.end?.(error);
|
||||
} catch (endError) {
|
||||
log(`whatsapp: falha ao invalidar socket apos timeout de envio: ${endError.message || endError}`);
|
||||
}
|
||||
if (!closed && !intentionalDisconnect) scheduleReconnect(0);
|
||||
}
|
||||
const persisted = await afterSend?.(null, error, context);
|
||||
if (afterSend && persisted === false) throw new Error('Falha ao persistir resultado do envio', { cause: error });
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const persisted = await afterSend?.(result, null, context);
|
||||
if (afterSend && persisted === false) throw new Error('registro de envio removido durante persistencia');
|
||||
await messageStatusInFlight.get(result.messageId)?.catch(() => {});
|
||||
const pendingStatus = pendingMessageStatuses.get(result.messageId);
|
||||
if (pendingStatus) {
|
||||
try {
|
||||
if (await onMessageStatus(pendingStatus)) pendingMessageStatuses.delete(result.messageId);
|
||||
} catch (error) {
|
||||
log(`whatsapp: falha ao reaplicar status da mensagem: ${error.message || error}`);
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
const error = new Error(`Falha ao persistir confirmacao da mensagem ${result.messageId}`, { cause });
|
||||
error.messageId = result.messageId;
|
||||
error.deliverySucceeded = true;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { pendingSends -= 1; });
|
||||
sendQueue = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function disconnect({ logout = false } = {}) {
|
||||
if (cleanupPromise) await cleanupPromise;
|
||||
const finishCleanup = beginCleanup();
|
||||
generation += 1;
|
||||
intentionalDisconnect = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
clearQr();
|
||||
const currentSocket = socket;
|
||||
socket = null;
|
||||
state = logout ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.STOPPED;
|
||||
let failure;
|
||||
try {
|
||||
let timer;
|
||||
const drained = await Promise.race([
|
||||
sendQueue.then(() => true),
|
||||
new Promise((resolve) => { timer = setTimeout(() => resolve(false), sendDrainTimeoutMs); })
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!drained) throw new Error(`fila de envios excedeu o prazo de ${sendDrainTimeoutMs}ms`);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
if (currentSocket) {
|
||||
if (logout) await currentSocket.logout();
|
||||
else currentSocket.end?.(new Error('desconectado pelo usuario'));
|
||||
}
|
||||
} catch (error) {
|
||||
failure ||= error;
|
||||
}
|
||||
try {
|
||||
if (logout) await removeAuthDir(authDir);
|
||||
} catch (error) {
|
||||
failure ||= error;
|
||||
} finally {
|
||||
state = logout ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.STOPPED;
|
||||
pendingMessageStatuses.clear();
|
||||
messageStatusInFlight.clear();
|
||||
inboundMessageIds.clear();
|
||||
await releaseLock();
|
||||
finishCleanup();
|
||||
}
|
||||
if (failure) throw failure;
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
async function refreshQr() {
|
||||
if (state === WHATSAPP_STATES.READY) throw new Error('WhatsApp ja esta conectado; use sair e esquecer sessao para trocar a conta');
|
||||
await disconnect({ logout: false });
|
||||
reconnectAttempts = 0;
|
||||
return connect();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
closed = true;
|
||||
try {
|
||||
await disconnect();
|
||||
} finally {
|
||||
releaseSignalLogFilter();
|
||||
}
|
||||
}
|
||||
|
||||
return { getStatus, connect, refreshQr, getQr, checkNumber, disconnect, sendText, close };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Task 10: Real Browser Acceptance and Final Verification
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Modified | `package.json` (added `test:browser` script) |
|
||||
| Created | `playwright.config.mjs` |
|
||||
| Created | `tests/browser/server.mjs` |
|
||||
| Created | `tests/browser/campaign-pilot.spec.mjs` |
|
||||
|
||||
## Test Results
|
||||
|
||||
### `npm test` — All Node tests: **370 passed, 0 failed**
|
||||
- All server, campaign, outreach, message, schedule, worker, store, auth, WhatsApp manager, and integration tests pass.
|
||||
|
||||
### `npm run test:browser` — Playwright tests: **42 passed, 0 failed**
|
||||
- **Desktop (1440×900):** 21/21 passed
|
||||
- **Mobile (390×844):** 21/21 passed
|
||||
- No console errors detected during any test.
|
||||
|
||||
### `npm audit --omit=dev` — **0 vulnerabilities**
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
| Test Area | Status |
|
||||
|-----------|--------|
|
||||
| Login with seeded admin credentials | ✅ |
|
||||
| Campaign cards (status, week, limit) | ✅ |
|
||||
| Separate manual/automatic limits display | ✅ |
|
||||
| Campaign prepare creates queue items | ✅ |
|
||||
| Preview rows show server-rendered text | ✅ |
|
||||
| Per-row removal works | ✅ |
|
||||
| Approval flow (transactional, scheduled, campaign→ready) | ✅ |
|
||||
| Pause with explicit reason | ✅ |
|
||||
| Start on paused/ready campaign with scheduled items | ✅ |
|
||||
| Stage classification via API | ✅ |
|
||||
| Appointment/demo scheduling form | ✅ |
|
||||
| Pending replies endpoint | ✅ |
|
||||
| Metrics endpoint (segmento + variant filters) | ✅ |
|
||||
| UI: Pending replies section visible | ✅ |
|
||||
| UI: Stage classification section renders | ✅ |
|
||||
| UI: Appointment form renders | ✅ |
|
||||
| UI: Metrics section with filter selects | ✅ |
|
||||
| Fake provider send count remains zero | ✅ |
|
||||
| Mobile viewport: cards visible, full-width controls | ✅ |
|
||||
|
||||
## Smoke Check Instructions
|
||||
|
||||
### 1. Migration v15 Verification
|
||||
Start the app with `npm start`. Check `schema_migrations` table has version 15:
|
||||
```sql
|
||||
SELECT MAX(version) FROM schema_migrations;
|
||||
```
|
||||
|
||||
### 2. Server-Restart Campaign Pause
|
||||
Start the app, go to Campanhas tab. Verify all 3 campaigns show as `Pausada`. Hover the pause reason chip to confirm no unexpected text.
|
||||
|
||||
### 3. WhatsApp Status Display
|
||||
Open Configurações. The WhatsApp connection state chip will show `Parado` initially. Click "Conectar / gerar QR" only when ready to pair a device.
|
||||
|
||||
### 4. Campaign Preview
|
||||
In Campanhas tab, click "Preparar fila" on any campaign card. The queue table should appear with rendered messages and selection reasons.
|
||||
|
||||
### 5. Manual/Automatic Counters
|
||||
Each campaign card shows `Manual: 0/10` and `Automático: 0/10`. These increment independently — manual sends count toward `Manual` only, campaign sends count toward `Automático` only.
|
||||
|
||||
### 6. Queue Approval
|
||||
After preparing, select items and click "Aprovar fila". Approved items become `scheduled` and remaining preview items are cancelled with reason `removed_by_operator`. Campaign status changes to `Pronta`.
|
||||
|
||||
### 7. Pending-Reply UI
|
||||
The "Respostas pendentes" section lists leads classified as `respondeu`. Use the "Classificar" button to move leads through the commercial funnel stages.
|
||||
|
||||
### 8. Mobile Layout
|
||||
Open DevTools (F12) and toggle device toolbar (Ctrl+Shift+M). Select a mobile viewport (390×844). Verify:
|
||||
- Campaign cards stack vertically
|
||||
- Tables scroll horizontally
|
||||
- Controls are full-width
|
||||
- No layout breakage
|
||||
|
||||
## Safety Confirmation
|
||||
- **Real provider never contacted**: All 42 browser tests use a fake WhatsApp manager that records send attempts in memory. Zero real sends occurred (asserted in test).
|
||||
- **Isolated database**: Each test run uses a temporary SQLite database created via `fs.mkdtempSync` and deleted on teardown.
|
||||
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
import { createRouter } from '../src/server/router.mjs';
|
||||
import { registerAuthRoutes } from '../src/server/routes/auth.mjs';
|
||||
import { createSession, getSession } from '../src/server/session.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();
|
||||
});
|
||||
|
||||
test('usuario autenticado troca a propria senha validando atual e minimo', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ensureDefaultAdmin(db);
|
||||
const router = createRouter();
|
||||
registerAuthRoutes(router, { db });
|
||||
const handler = router.match('PUT', '/api/auth/password').handler;
|
||||
const invoke = async (body) => {
|
||||
let response;
|
||||
await handler({}, {}, { body, user: { id: 1, username: 'admin', role: 'admin' }, json: (status, data) => { response = { status, data }; } });
|
||||
return response;
|
||||
};
|
||||
assert.equal((await invoke({ senhaAtual: 'errada', novaSenha: 'novaSenha123' })).status, 403);
|
||||
assert.equal((await invoke({ senhaAtual: 'admin', novaSenha: 'curta' })).status, 400);
|
||||
const token1 = createSession({ id: 1, username: 'admin', role: 'admin' });
|
||||
const token2 = createSession({ id: 1, username: 'admin', role: 'admin' });
|
||||
const changed = await invoke({ senhaAtual: 'admin', novaSenha: 'novaSenha123' });
|
||||
assert.equal(changed.status, 200);
|
||||
assert.equal(changed.data.relogin, true);
|
||||
assert.equal(getSession(token1), null);
|
||||
assert.equal(getSession(token2), null);
|
||||
assert.ok(verifyPassword('novaSenha123', findUserByUsername(db, 'admin').password_hash));
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('criacao de usuario exige senha com pelo menos 8 caracteres', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = createRouter();
|
||||
registerAuthRoutes(router, { db });
|
||||
let response;
|
||||
await router.match('POST', '/api/users').handler({}, {}, {
|
||||
user: { role: 'admin' }, body: { username: 'novo', password: '1234567' },
|
||||
json: (status, data) => { response = { status, data }; }
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { startTestServer } from './server.mjs';
|
||||
|
||||
let ctx;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
ctx = await startTestServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (ctx) await ctx.teardown();
|
||||
});
|
||||
|
||||
async function loginViaApi() {
|
||||
const login = await ctx.apiRequest('POST', '/api/auth/login', {
|
||||
body: { username: 'admin', password: ctx.adminPw }
|
||||
});
|
||||
expect(login.status).toBe(200);
|
||||
const setCookie = login.headers.get('set-cookie');
|
||||
return setCookie ? setCookie.split(';')[0] : '';
|
||||
}
|
||||
|
||||
async function setCookieOnPage(page, cookie) {
|
||||
const match = cookie ? cookie.match(/maquina_pessoal_session=([^;]+)/) : null;
|
||||
if (!match) return;
|
||||
await page.context().addCookies([{
|
||||
name: 'maquina_pessoal_session',
|
||||
value: match[1],
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict'
|
||||
}]);
|
||||
}
|
||||
|
||||
test.describe('Campaign Pilot Browser Tests', () => {
|
||||
test.describe('Authentication', () => {
|
||||
test('login with seeded admin credentials', async ({ page }) => {
|
||||
await page.goto('/login.html');
|
||||
await expect(page.locator('#form-login')).toBeVisible();
|
||||
|
||||
await page.fill('#login-username', 'admin');
|
||||
await page.fill('#login-password', ctx.adminPw);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await page.waitForURL('**/', { timeout: 10000 });
|
||||
await expect(page.locator('#usuario-logado')).toContainText('admin');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Campaign Cards', () => {
|
||||
let cookie;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
cookie = await loginViaApi();
|
||||
});
|
||||
|
||||
test('campaign tab shows cards with correct status/limits', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const cards = page.locator('.campaign-card');
|
||||
const count = await cards.count();
|
||||
expect(count).toBe(3);
|
||||
|
||||
const cardText = await cards.first().textContent();
|
||||
expect(cardText).toContain('G-Delivery');
|
||||
expect(cardText).toContain('Semana 1');
|
||||
expect(cardText).toContain('Limite: 10');
|
||||
});
|
||||
|
||||
test('separate manual/automatic limits display', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const cardText = await page.locator('.campaign-card').first().textContent();
|
||||
expect(cardText).toMatch(/Manual|manual/);
|
||||
expect(cardText).toMatch(/Automático|auto/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Campaign Queue Operations (via API)', () => {
|
||||
let cookie;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
cookie = await loginViaApi();
|
||||
});
|
||||
|
||||
test('prepare creates queue items', async () => {
|
||||
const { data } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaignId = data.campaigns[0].id;
|
||||
|
||||
const response = await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
|
||||
headers: { cookie }, body: {}
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.summary.previewed).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('preview rows show server-rendered text', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaignId = campaigns.campaigns[0].id;
|
||||
|
||||
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
|
||||
headers: { cookie }, body: {}
|
||||
});
|
||||
|
||||
const { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(queue.queue.length).toBeGreaterThanOrEqual(1);
|
||||
expect(queue.queue[0].rendered_message).toBeTruthy();
|
||||
expect(queue.queue[0].status).toBe('preview');
|
||||
});
|
||||
|
||||
test('per-row removal works', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaignId = campaigns.campaigns[0].id;
|
||||
|
||||
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
|
||||
headers: { cookie }, body: {}
|
||||
});
|
||||
|
||||
const { data: before } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
|
||||
if (before.queue.length === 0) return;
|
||||
|
||||
const firstId = before.queue[0].id;
|
||||
const removeResponse = await ctx.apiRequest('DELETE', `/api/campaigns/${campaignId}/queue/${firstId}`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(removeResponse.status).toBe(200);
|
||||
|
||||
const { data: after } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(after.queue.find((q) => q.id === firstId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('approval flow works', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaign = campaigns.campaigns.find((c) => c.status === 'paused');
|
||||
if (!campaign) return;
|
||||
|
||||
let { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
|
||||
if (queue.queue.length === 0) {
|
||||
await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/prepare`, {
|
||||
headers: { cookie }, body: {}
|
||||
});
|
||||
const { data: refetched } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
if (refetched.queue.length === 0) return;
|
||||
queue.queue = refetched.queue;
|
||||
}
|
||||
|
||||
const ids = queue.queue.slice(0, 1).map((q) => q.id);
|
||||
const approveResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/approve`, {
|
||||
headers: { cookie },
|
||||
body: { queueIds: ids, date: '2026-07-29' }
|
||||
});
|
||||
expect(approveResponse.status).toBe(200);
|
||||
expect(approveResponse.data.scheduled).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const { data: updated } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const updatedCampaign = updated.campaigns.find((c) => c.id === campaign.id);
|
||||
expect(['ready', 'running']).toContain(updatedCampaign.status);
|
||||
});
|
||||
|
||||
test('pause shows reason', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaign = campaigns.campaigns.find((c) => c.status === 'ready' || c.status === 'paused');
|
||||
if (!campaign) return;
|
||||
|
||||
const pauseResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/pause`, {
|
||||
headers: { cookie },
|
||||
body: { reason: 'revisao manual do operador' }
|
||||
});
|
||||
expect(pauseResponse.status).toBe(200);
|
||||
expect(pauseResponse.data.campaign.pause_reason).toBe('revisao manual do operador');
|
||||
expect(pauseResponse.data.campaign.status).toBe('paused');
|
||||
});
|
||||
|
||||
test('start succeeds on paused campaign with scheduled items', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
const campaign = campaigns.campaigns.find((c) => c.status === 'paused' || c.status === 'ready');
|
||||
if (!campaign) return;
|
||||
|
||||
const startResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/start`, {
|
||||
headers: { cookie }, body: {}
|
||||
});
|
||||
expect(startResponse.status).toBe(200);
|
||||
expect(startResponse.data.campaign.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Stage Classification', () => {
|
||||
let cookie;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
cookie = await loginViaApi();
|
||||
});
|
||||
|
||||
test('stage classification works via API', async () => {
|
||||
const leadId = Number(ctx.db.prepare(
|
||||
"SELECT id FROM leads WHERE empresa LIKE '%Restaurante%' LIMIT 1"
|
||||
).get()?.id);
|
||||
|
||||
if (!leadId) return;
|
||||
|
||||
const stageResponse = await ctx.apiRequest('PUT', `/api/campaigns/leads/${leadId}/stage`, {
|
||||
headers: { cookie },
|
||||
body: { stage: 'interessado_gti_vendas', note: 'demonstracao agendada' }
|
||||
});
|
||||
expect(stageResponse.status).toBe(200);
|
||||
expect(stageResponse.data.state.stage).toBe('interessado_gti_vendas');
|
||||
});
|
||||
|
||||
test('appointment/demo scheduling form works via API', async () => {
|
||||
const leadId = Number(ctx.db.prepare(
|
||||
"SELECT id FROM leads WHERE empresa LIKE '%Pizzaria%' LIMIT 1"
|
||||
).get()?.id);
|
||||
|
||||
if (!leadId) return;
|
||||
|
||||
const appointmentResponse = await ctx.apiRequest('POST', `/api/campaigns/leads/${leadId}/appointments`, {
|
||||
headers: { cookie },
|
||||
body: {
|
||||
kind: 'demonstracao',
|
||||
participantName: 'Maria Silva',
|
||||
companyName: 'Pizzaria Teste',
|
||||
preferredDay: '2026-08-05',
|
||||
preferredTime: '15:30'
|
||||
}
|
||||
});
|
||||
expect(appointmentResponse.status).toBe(201);
|
||||
expect(appointmentResponse.data.appointment.kind).toBe('demonstracao');
|
||||
});
|
||||
|
||||
test('pending replies section accessible via API', async () => {
|
||||
const { data } = await ctx.apiRequest('GET', '/api/campaigns/replies/pending', {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(Array.isArray(data.pending)).toBe(true);
|
||||
});
|
||||
|
||||
test('metrics endpoint returns data', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
if (campaigns.campaigns.length === 0) return;
|
||||
|
||||
const campaignId = campaigns.campaigns[0].id;
|
||||
const { data } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/metrics`, {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(data.metrics).toBeDefined();
|
||||
});
|
||||
|
||||
test('metrics supports segmento and variant filters', async () => {
|
||||
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
|
||||
if (campaigns.campaigns.length === 0) return;
|
||||
|
||||
const campaignId = campaigns.campaigns[0].id;
|
||||
const { data: filtered } = await ctx.apiRequest(
|
||||
'GET',
|
||||
`/api/campaigns/${campaignId}/metrics?segmento=hamburguerias&variant=A`,
|
||||
{ headers: { cookie } }
|
||||
);
|
||||
expect(filtered.metrics).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('UI Rendering', () => {
|
||||
let cookie;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
cookie = await loginViaApi();
|
||||
});
|
||||
|
||||
test('pending replies section visible on campaigns page', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const repliesSection = page.locator('#pending-replies');
|
||||
await expect(repliesSection).toBeVisible();
|
||||
const content = await repliesSection.textContent();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('stage classification section renders', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const stageSection = page.locator('#stage-classification');
|
||||
await expect(stageSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('appointment scheduling form renders', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const appointmentSection = page.locator('#appointment-form');
|
||||
await expect(appointmentSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('metrics section renders with filters', async ({ page }) => {
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const metricsFilters = page.locator('#metrics-filters');
|
||||
await expect(metricsFilters).toBeVisible();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const filterSelects = metricsFilters.locator('select');
|
||||
const selectCount = await filterSelects.count();
|
||||
expect(selectCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Safety', () => {
|
||||
test('fake provider send count remains zero', async () => {
|
||||
expect(ctx.fakeSends.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Viewport', () => {
|
||||
let cookie;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
cookie = await loginViaApi();
|
||||
});
|
||||
|
||||
test('mobile viewport renders campaign cards', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const cards = page.locator('.campaign-card');
|
||||
const count = await cards.count();
|
||||
expect(count).toBe(3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(cards.nth(i)).toBeVisible();
|
||||
}
|
||||
|
||||
const queueSection = page.locator('#campaign-queue');
|
||||
await expect(queueSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile viewport shows full-width controls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await setCookieOnPage(page, cookie);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button[data-tab="campanhas"]');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const section = page.locator('#tab-campanhas');
|
||||
const box = await section.boundingBox();
|
||||
expect(box.width).toBeGreaterThanOrEqual(350);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { openDb, setSetting } from '../../src/db.mjs';
|
||||
import { hashPassword } from '../../src/auth.mjs';
|
||||
import { createUser } from '../../src/db.mjs';
|
||||
import { createAppServer } from '../../src/server/index.mjs';
|
||||
import { seedCampaigns } from '../../src/outreach/campaign-store.mjs';
|
||||
|
||||
const DEBUG = process.env.DEBUG_SERVER === '1';
|
||||
const log = DEBUG ? (m) => process.stderr.write(`[test-server] ${m}\n`) : () => {};
|
||||
|
||||
function randomHex(length) {
|
||||
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
}
|
||||
|
||||
export async function startTestServer() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-'));
|
||||
const dbPath = path.join(tmpDir, 'test.db');
|
||||
|
||||
process.env.APP_DB_PATH = dbPath;
|
||||
process.env.APP_WHATSAPP_AUTH_DIR = path.join(tmpDir, 'whatsapp-auth');
|
||||
|
||||
const db = openDb(dbPath);
|
||||
|
||||
const adminPw = 'admin-senha-teste';
|
||||
createUser(db, { username: 'admin', passwordHash: hashPassword(adminPw), role: 'admin' });
|
||||
|
||||
seedCampaigns(db);
|
||||
setSetting(db, 'outreach.mensagensPorDia', '10');
|
||||
|
||||
const campaignIds = db.prepare('SELECT id, code FROM outreach_campaigns ORDER BY id').all();
|
||||
const leadIds = [];
|
||||
const empresas = {
|
||||
'1': ['Hamburgueria Bella', 'Pizzaria Famosa', 'Acaiteria Tropical', 'Marmitaria Sabor', 'Lanchonete do Ze'],
|
||||
'2': ['Restaurante Chefe', 'Mercado Central', 'Mercearia Sao Jose', 'Minimercado Bom', 'Padaria Pao Quente'],
|
||||
'3': ['Loja de Moda', 'Material Construcao', 'Autopecas Veloz', 'Oficina Mecanica']
|
||||
};
|
||||
|
||||
let leadIdCounter = 1;
|
||||
for (const c of campaignIds) {
|
||||
const names = empresas[c.code] || ['Empresa Generica'];
|
||||
for (const nome of names) {
|
||||
const whatsapp = `5511999${String(leadIdCounter).padStart(6, '0')}`;
|
||||
const r = db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score, classificacao, telefone_valido)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'bom', 1)`
|
||||
).run(
|
||||
`browser-test-lead-${leadIdCounter}`,
|
||||
nome,
|
||||
whatsapp,
|
||||
c.code === 'gdelivery-food' ? 'hamburguerias' : c.code === 'gti-food' ? 'restaurantes' : 'lojas_de_roupa',
|
||||
leadIdCounter <= 5 ? 1 : 2,
|
||||
100 - leadIdCounter
|
||||
);
|
||||
const lid = Number(r.lastInsertRowid);
|
||||
leadIds.push({ id: lid, empresa: nome, whatsapp, campaignId: c.id });
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO whatsapp_number_verifications (numero, status, numero_resolvido, checked_at)
|
||||
VALUES (?, 'confirmado', ?, datetime('now'))`
|
||||
).run(whatsapp, whatsapp);
|
||||
|
||||
leadIdCounter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const firstLead = leadIds[0];
|
||||
setSetting(db, 'outreach.ativo', 'true');
|
||||
|
||||
const firstCampaignId = campaignIds[0].id;
|
||||
const variantIds = db.prepare(
|
||||
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1'
|
||||
).all(firstCampaignId);
|
||||
if (variantIds.length > 0 && firstLead) {
|
||||
const variantId = variantIds[0].id;
|
||||
const produto = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(firstCampaignId).produto;
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'preview')`
|
||||
).run(firstCampaignId, firstLead.id, variantId, produto, firstLead.whatsapp, 'hamburguerias',
|
||||
'Ola! Teste de mensagem renderizada para campanha.', 'browser-test');
|
||||
}
|
||||
|
||||
const stageLeadId = leadIds.length > 2 ? leadIds[2].id : (firstLead ? firstLead.id : null);
|
||||
if (stageLeadId) {
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
|
||||
VALUES (?, 'respondeu', datetime('now'))`
|
||||
).run(stageLeadId);
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note)
|
||||
VALUES (?, ?, 'respondeu', 'cliente quer saber mais sobre G-Delivery')`
|
||||
).run(stageLeadId, firstCampaignId);
|
||||
}
|
||||
|
||||
const replyLeadId = leadIds.length > 1 ? leadIds[1].id : (firstLead ? firstLead.id : null);
|
||||
if (replyLeadId) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(`test-inbound-${Date.now()}`, replyLeadId, leadIds[1]?.whatsapp || '5511999990002');
|
||||
}
|
||||
|
||||
let fakeSends = [];
|
||||
|
||||
const whatsapp = {
|
||||
getStatus: () => ({ state: 'READY', connected: true }),
|
||||
getQr: () => null,
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
checkNumber: async (number) => ({
|
||||
registered: true,
|
||||
numero: number,
|
||||
numeroResolvido: number,
|
||||
status: 'confirmado'
|
||||
}),
|
||||
sendText: async () => {
|
||||
fakeSends.push({ at: new Date().toISOString() });
|
||||
throw new Error('fake manager nao envia mensagens reais');
|
||||
}
|
||||
};
|
||||
|
||||
const jobs = { subscribe: () => null, activeJobs: () => [] };
|
||||
|
||||
const server = createAppServer({ db, jobs, whatsapp, log });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(3911, '127.0.0.1', () => {
|
||||
log(`server listening on 3911`);
|
||||
|
||||
const apiRequest = async (method, path, options = {}) => {
|
||||
const url = `http://127.0.0.1:3911${path}`;
|
||||
const headers = { ...options.headers };
|
||||
if (options.body && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return { status: response.status, headers: response.headers, data, raw: response };
|
||||
};
|
||||
|
||||
const teardown = async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
log('test server teardown complete');
|
||||
};
|
||||
|
||||
resolve({ server, db, tmpDir, whatsapp, fakeSends, apiRequest, teardown, adminPw });
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
managementFocus,
|
||||
naturalBusinessName,
|
||||
renderCampaignMessage,
|
||||
variantForLead
|
||||
} from '../src/outreach/campaign-messages.mjs';
|
||||
|
||||
const refusal = 'Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.';
|
||||
|
||||
const approvedCases = [
|
||||
{
|
||||
segment: 'restaurantes',
|
||||
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? ${refusal}`,
|
||||
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam vendas, estoque, comandas e emissão fiscal em um unico sistema? ${refusal}`
|
||||
},
|
||||
{
|
||||
segment: 'restaurantes',
|
||||
template: `Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? ${refusal}`,
|
||||
expected: `Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje vendas, estoque, comandas e emissão fiscal? ${refusal}`
|
||||
},
|
||||
{
|
||||
segment: 'restaurantes',
|
||||
template: `Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? ${refusal}`,
|
||||
expected: `Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para vendas, estoque, comandas e emissão fiscal? ${refusal}`
|
||||
},
|
||||
{
|
||||
segment: 'pizzarias',
|
||||
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? ${refusal}`,
|
||||
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? ${refusal}`
|
||||
},
|
||||
{
|
||||
segment: 'pizzarias',
|
||||
template: `Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? ${refusal}`,
|
||||
expected: `Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? ${refusal}`
|
||||
},
|
||||
{
|
||||
segment: 'pizzarias',
|
||||
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. ${refusal}`,
|
||||
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. ${refusal}`
|
||||
}
|
||||
];
|
||||
|
||||
test('nome natural aceita marcas curtas com pelo menos duas letras', () => {
|
||||
assert.equal(naturalBusinessName({ empresa: 'OX' }), 'OX');
|
||||
assert.equal(naturalBusinessName({ empresa: 'BR2' }), 'BR2');
|
||||
assert.equal(naturalBusinessName({ empresa: 'Pizzaria Central' }), 'Pizzaria Central');
|
||||
});
|
||||
|
||||
test('nome natural rejeita identificadores, nomes legais, controles, placeholders e ruido generico', () => {
|
||||
const rejected = [
|
||||
'',
|
||||
'7',
|
||||
'7 Mares',
|
||||
'61.590.668 A ARAUJO',
|
||||
'12.345.678/0001-90',
|
||||
'A ARAUJO LTDA',
|
||||
'Loja Sol MEI',
|
||||
'Loja Sol ME',
|
||||
'Loja Sol EPP',
|
||||
'Loja Sol EIRELI',
|
||||
'Loja Sol S/A',
|
||||
'Loja\nSol',
|
||||
'Loja\u0000Sol',
|
||||
'Loja\u200bSol',
|
||||
'Loja {nome}',
|
||||
'Loja {{nome}}',
|
||||
'Empresa',
|
||||
'Nome Fantasia',
|
||||
'Nao informado',
|
||||
'Comercio',
|
||||
'Pizzaria Central com um nome comercial excessivamente longo para uma saudacao natural e segura'
|
||||
];
|
||||
|
||||
for (const empresa of rejected) {
|
||||
assert.equal(naturalBusinessName({ empresa, legal_name: 'A ARAUJO LTDA' }), '', empresa);
|
||||
}
|
||||
});
|
||||
|
||||
test('foco de gestao segue os quatro grupos aprovados', () => {
|
||||
assert.equal(managementFocus('restaurantes'), 'vendas, estoque, comandas e emissão fiscal');
|
||||
assert.equal(managementFocus('mercados_de_bairro'), 'vendas, estoque e emissão fiscal');
|
||||
assert.equal(managementFocus('oficinas_mecanicas'), 'ordens de serviço, estoque e financeiro');
|
||||
assert.equal(managementFocus('lojas_de_roupa'), 'vendas, estoque, financeiro e notas fiscais');
|
||||
});
|
||||
|
||||
test('variante permanece estavel e considera apenas variantes ativas', () => {
|
||||
const variants = [{ code: 'A', ativo: 1 }, { code: 'B', ativo: 1 }, { code: 'C', ativo: 1 }];
|
||||
assert.equal(variantForLead(variants, 4).code, 'B');
|
||||
assert.equal(variantForLead(variants, 4).code, 'B');
|
||||
assert.equal(variantForLead([{ code: 'A', ativo: 0 }, ...variants.slice(1)], 4).code, 'B');
|
||||
assert.throws(() => variantForLead([], 4), /variante ativa/i);
|
||||
});
|
||||
|
||||
test('variante usa a ordem ativa mesmo com ids nao contiguos', () => {
|
||||
const variants = [
|
||||
{ id: 8, code: 'A', ativo: 1 },
|
||||
{ id: 37, code: 'B', ativo: 1 },
|
||||
{ id: 104, code: 'C', ativo: 1 }
|
||||
];
|
||||
assert.equal(variantForLead(variants, 4), variants[1]);
|
||||
});
|
||||
|
||||
test('variante rejeita array nao vazio quando todas estao inativas', () => {
|
||||
assert.throws(() => variantForLead([
|
||||
{ id: 8, code: 'A', ativo: 0 },
|
||||
{ id: 37, code: 'B', ativo: false }
|
||||
], 4), /variante ativa/i);
|
||||
});
|
||||
|
||||
test('renderizacao preserva os seis textos aprovados fora das substituicoes permitidas', () => {
|
||||
for (const { segment, template, expected } of approvedCases) {
|
||||
assert.equal(renderCampaignMessage(template, {
|
||||
empresa: '61.590.668 A ARAUJO',
|
||||
legal_name: 'A ARAUJO LTDA',
|
||||
segmento: segment
|
||||
}), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderizacao personaliza somente a saudacao inicial com nome natural', () => {
|
||||
const template = approvedCases[0].template;
|
||||
const rendered = renderCampaignMessage(template, {
|
||||
empresa: 'Pizzaria Central',
|
||||
segmento: 'restaurantes'
|
||||
});
|
||||
|
||||
assert.equal(rendered, `Olá, pessoal da Pizzaria Central, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam vendas, estoque, comandas e emissão fiscal em um unico sistema? ${refusal}`);
|
||||
});
|
||||
|
||||
test('validacao aceita texto portugues legitimo sem remover diacriticos', () => {
|
||||
const template = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Gestão ágil para comércio. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
|
||||
assert.equal(renderCampaignMessage(template, { empresa: '', segmento: 'lojas_de_roupa' }), template);
|
||||
|
||||
const punctuation = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Gestão simples (vendas/estoque), ágil e segura. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
|
||||
assert.equal(renderCampaignMessage(punctuation, { empresa: '' }), punctuation);
|
||||
});
|
||||
|
||||
test('validacao rejeita placeholders simples ou duplos que restarem no texto', () => {
|
||||
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
|
||||
const placeholders = [
|
||||
'{produto}',
|
||||
'{{produto_2}}',
|
||||
'{ produto }',
|
||||
'{produto-nome}',
|
||||
'{123}',
|
||||
'{{ produto }}',
|
||||
'{{produto-nome}}',
|
||||
'{{123}}'
|
||||
];
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
const template = safe.replace('Grupo GTI.', `Grupo GTI. ${placeholder}`);
|
||||
assert.throws(() => renderCampaignMessage(template, {}), /mensagem inicial insegura/i, placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
test('validacao de placeholders preserva portugues normal sem chaves', () => {
|
||||
const template = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Produto 123 - nome e gestão simples. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
|
||||
assert.equal(renderCampaignMessage(template, {}), template);
|
||||
});
|
||||
|
||||
test('validacao rejeita localhost, enderecos IP e caminhos absolutos', () => {
|
||||
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
|
||||
const addresses = [
|
||||
'localhost',
|
||||
'localhost:3000/agenda',
|
||||
'127.0.0.1',
|
||||
'192.168.0.1:8080/agenda',
|
||||
'[::1]',
|
||||
'[2001:db8::1]:8080/agenda',
|
||||
'2001:db8::1',
|
||||
'/agenda/demonstracao'
|
||||
];
|
||||
|
||||
for (const address of addresses) {
|
||||
const template = safe.replace('Grupo GTI.', `Grupo GTI. ${address}`);
|
||||
assert.throws(() => renderCampaignMessage(template, {}), /mensagem inicial insegura/i, address);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderizacao e selecao nao alteram os objetos de entrada', () => {
|
||||
const template = { mensagem: approvedCases[0].template };
|
||||
const lead = { empresa: 'Pizzaria Central', segmento: 'restaurantes' };
|
||||
const variants = [{ id: 8, code: 'A', ativo: 0 }, { id: 37, code: 'B', ativo: 1 }];
|
||||
const before = structuredClone({ template, lead, variants });
|
||||
|
||||
renderCampaignMessage(template, lead);
|
||||
variantForLead(variants, 7);
|
||||
|
||||
assert.deepEqual({ template, lead, variants }, before);
|
||||
});
|
||||
|
||||
test('validacao rejeita URL, CNPJ, preco e requisitos obrigatorios ausentes', () => {
|
||||
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
|
||||
const unsafe = [
|
||||
safe.replace('Grupo GTI.', 'Grupo GTI. https://gti.example'),
|
||||
safe.replace('Grupo GTI.', 'Grupo GTI. gti.com.br'),
|
||||
safe.replace('Grupo GTI.', 'Grupo GTI. 12.345.678/0001-90'),
|
||||
safe.replace('Grupo GTI.', 'Grupo GTI. 12345678000190'),
|
||||
safe.replace('Grupo GTI.', 'Grupo GTI. R$ 99'),
|
||||
safe.replace('Gustavo', 'Consultor'),
|
||||
safe.replace('Grupo GTI', 'nossa empresa'),
|
||||
safe.replace(` ${refusal}`, '')
|
||||
];
|
||||
|
||||
for (const template of unsafe) {
|
||||
assert.throws(() => renderCampaignMessage(template, { empresa: '' }), /mensagem inicial insegura/i);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,659 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, addOptOut, saveWhatsAppVerification } from '../src/db.mjs';
|
||||
import { seedCampaigns, setLeadCommercialStage } from '../src/outreach/campaign-store.mjs';
|
||||
|
||||
async function metricsModule() {
|
||||
return import('../src/outreach/campaign-metrics.mjs');
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const db = openDb(':memory:');
|
||||
seedCampaigns(db);
|
||||
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
|
||||
return { db, campaign };
|
||||
}
|
||||
|
||||
function addLead(db, suffix, segmento) {
|
||||
const numero = `55119999${String(suffix).padStart(5, '0')}`;
|
||||
return Number(db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES (?, ?, ?, ?, 1)`
|
||||
).run(`metrics-lead-${suffix}`, `Empresa ${suffix}`, numero, segmento || 'restaurantes').lastInsertRowid);
|
||||
}
|
||||
|
||||
function addQueue(db, campaign, overrides = {}) {
|
||||
const {
|
||||
leadId, suffix = Date.now(), status = 'preview', variantCode = 'A',
|
||||
produto = campaign.produto, segmento = 'restaurantes'
|
||||
} = overrides;
|
||||
const lid = leadId !== undefined ? leadId : addLead(db, suffix, segmento);
|
||||
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(lid);
|
||||
const variantId = db.prepare(
|
||||
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? AND code = ?'
|
||||
).get(campaign.id, variantCode).id;
|
||||
return Number(db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento,
|
||||
rendered_message, selection_reason, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'Msg', 'test', ?)`
|
||||
).run(campaign.id, lid, variantId, produto, lead.whatsapp, segmento, status).lastInsertRowid);
|
||||
}
|
||||
|
||||
function addSend(db, overrides = {}) {
|
||||
const {
|
||||
leadId, produto = 'G-Delivery', status = 'aceito',
|
||||
sentAt = '2026-07-29T12:00:00.000Z', origem = 'automatico', queueId
|
||||
} = overrides;
|
||||
const sendId = Number(db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(leadId, produto, '5511999990000', 'Msg', status, origem, sentAt).lastInsertRowid);
|
||||
if (queueId) {
|
||||
db.prepare('UPDATE outreach_campaign_queue SET outreach_send_id = ? WHERE id = ?').run(sendId, queueId);
|
||||
}
|
||||
return sendId;
|
||||
}
|
||||
|
||||
function addReply(db, leadId, numero) {
|
||||
return Number(db.prepare(
|
||||
`INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(`reply-${leadId}`, leadId, numero || '5511999990000', '2026-07-29T12:30:00.000Z').lastInsertRowid);
|
||||
}
|
||||
|
||||
function addStageEvent(db, leadId, campaignId, stage) {
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
|
||||
VALUES (?, ?, CASE WHEN ? = 'respondeu' THEN datetime('now') END, datetime('now'))
|
||||
ON CONFLICT(lead_id) DO UPDATE SET
|
||||
stage = excluded.stage,
|
||||
first_reply_at = CASE
|
||||
WHEN excluded.stage = 'respondeu' THEN COALESCE(outreach_lead_states.first_reply_at, datetime('now'))
|
||||
ELSE outreach_lead_states.first_reply_at
|
||||
END,
|
||||
updated_at = datetime('now')`
|
||||
).run(leadId, stage, stage);
|
||||
db.prepare(
|
||||
'INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, ?)'
|
||||
).run(leadId, campaignId, stage);
|
||||
}
|
||||
|
||||
function addAppointment(db, leadId, campaignId, kind) {
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_appointments (lead_id, campaign_id, kind)
|
||||
VALUES (?, ?, ?)`
|
||||
).run(leadId, campaignId, kind);
|
||||
}
|
||||
|
||||
// --- campaignMetrics tests ---
|
||||
|
||||
test('campaignMetrics retorna zeros para campanha vazia', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.previewed, 0);
|
||||
assert.equal(metrics.approved, 0);
|
||||
assert.equal(metrics.removed, 0);
|
||||
assert.equal(metrics.attempted, 0);
|
||||
assert.deepEqual(metrics.provider, { aceito: 0, erro: 0, desconhecido: 0 });
|
||||
assert.equal(metrics.replies, 0);
|
||||
assert.equal(metrics.firstResponseTimeAvg, null);
|
||||
assert.equal(metrics.stages.positive, 0);
|
||||
assert.equal(metrics.stages.negative, 0);
|
||||
assert.equal(metrics.stages.optOut, 0);
|
||||
assert.equal(metrics.appointments.demos, 0);
|
||||
assert.equal(metrics.appointments.tests, 0);
|
||||
assert.equal(metrics.sales, 0);
|
||||
assert.deepEqual(metrics.groups.byProduct, {});
|
||||
assert.deepEqual(metrics.groups.bySegment, {});
|
||||
assert.deepEqual(metrics.groups.byVariant, {});
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics conta status da fila e provedor', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 1);
|
||||
const lead2 = addLead(db, 2);
|
||||
const lead3 = addLead(db, 3);
|
||||
const lead4 = addLead(db, 4);
|
||||
const lead5 = addLead(db, 5);
|
||||
const lead6 = addLead(db, 6);
|
||||
const lead7 = addLead(db, 7);
|
||||
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'preview' });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'preview' });
|
||||
addQueue(db, campaign, { leadId: lead3, status: 'approved' });
|
||||
addQueue(db, campaign, { leadId: lead4, status: 'approved' });
|
||||
addQueue(db, campaign, { leadId: lead5, status: 'cancelled', suffix: 5 });
|
||||
addQueue(db, campaign, { leadId: lead6, status: 'scheduled', suffix: 6 });
|
||||
const q7 = addQueue(db, campaign, { leadId: lead7, status: 'sent', suffix: 7 });
|
||||
addSend(db, { leadId: lead7, status: 'aceito', queueId: q7 });
|
||||
|
||||
const lead8 = addLead(db, 8);
|
||||
const q8 = addQueue(db, campaign, { leadId: lead8, status: 'unknown', suffix: 8 });
|
||||
addSend(db, { leadId: lead8, status: 'desconhecido', queueId: q8 });
|
||||
|
||||
const lead9 = addLead(db, 9);
|
||||
const q9 = addQueue(db, campaign, { leadId: lead9, status: 'error', suffix: 9 });
|
||||
addSend(db, { leadId: lead9, status: 'erro', queueId: q9 });
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.previewed, 2);
|
||||
assert.equal(metrics.approved, 2);
|
||||
assert.equal(metrics.removed, 1);
|
||||
assert.equal(metrics.attempted, 4);
|
||||
assert.deepEqual(metrics.provider, { aceito: 1, erro: 1, desconhecido: 1 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics conta replies e first-response time', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead = addLead(db, 10);
|
||||
const q10 = addQueue(db, campaign, { leadId: lead, status: 'sent', suffix: 10 });
|
||||
addSend(db, { leadId: lead, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: q10 });
|
||||
addReply(db, lead, '55119999900010');
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.replies, 1);
|
||||
assert.ok(metrics.firstResponseTimeAvg !== null);
|
||||
assert.ok(typeof metrics.firstResponseTimeAvg === 'number');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics conta stages positivos, negativos e opt-out', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 20);
|
||||
const lead2 = addLead(db, 21);
|
||||
const lead3 = addLead(db, 22);
|
||||
const lead4 = addLead(db, 23);
|
||||
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'sent', suffix: 20 });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'sent', suffix: 21 });
|
||||
addQueue(db, campaign, { leadId: lead3, status: 'sent', suffix: 22 });
|
||||
addQueue(db, campaign, { leadId: lead4, status: 'sent', suffix: 23 });
|
||||
addStageEvent(db, lead1, campaign.id, 'interessado_gti_vendas');
|
||||
addStageEvent(db, lead2, campaign.id, 'venda_fechada');
|
||||
addStageEvent(db, lead3, campaign.id, 'sem_interesse');
|
||||
addStageEvent(db, lead4, campaign.id, 'opt_out');
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.stages.positive, 2);
|
||||
assert.equal(metrics.stages.negative, 1);
|
||||
assert.equal(metrics.stages.optOut, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics conta appointments e sales', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 30);
|
||||
const lead2 = addLead(db, 31);
|
||||
const lead3 = addLead(db, 32);
|
||||
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'sent', suffix: 30 });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'sent', suffix: 31 });
|
||||
addQueue(db, campaign, { leadId: lead3, status: 'sent', suffix: 32 });
|
||||
addAppointment(db, lead1, campaign.id, 'demonstracao');
|
||||
addAppointment(db, lead2, campaign.id, 'teste_g_delivery');
|
||||
addStageEvent(db, lead3, campaign.id, 'venda_fechada');
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.appointments.demos, 1);
|
||||
assert.equal(metrics.appointments.tests, 1);
|
||||
assert.equal(metrics.sales, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics filtra por segmento', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 40, 'restaurantes');
|
||||
const lead2 = addLead(db, 41, 'pizzarias');
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'preview', segmento: 'restaurantes' });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'preview', segmento: 'pizzarias', suffix: 41 });
|
||||
|
||||
const all = campaignMetrics(db, campaign.id);
|
||||
assert.equal(all.previewed, 2);
|
||||
|
||||
const filtered = campaignMetrics(db, campaign.id, { segmento: 'restaurantes' });
|
||||
assert.equal(filtered.previewed, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics filtra por variant', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 50);
|
||||
const lead2 = addLead(db, 51);
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'preview', variantCode: 'A' });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'preview', variantCode: 'B', suffix: 51 });
|
||||
|
||||
const filtered = campaignMetrics(db, campaign.id, { variant: 'B' });
|
||||
assert.equal(filtered.previewed, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaignMetrics agrupa por product, segment e variant', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const lead1 = addLead(db, 60, 'restaurantes');
|
||||
const lead2 = addLead(db, 61, 'pizzarias');
|
||||
addQueue(db, campaign, { leadId: lead1, status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
|
||||
addQueue(db, campaign, { leadId: lead2, status: 'preview', variantCode: 'B', segmento: 'pizzarias', suffix: 61 });
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.ok(metrics.groups.byProduct[campaign.produto]);
|
||||
assert.ok(metrics.groups.bySegment.restaurantes);
|
||||
assert.ok(metrics.groups.bySegment.pizzarias);
|
||||
assert.ok(metrics.groups.byVariant.A);
|
||||
assert.ok(metrics.groups.byVariant.B);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- evaluateCampaignHealth tests ---
|
||||
|
||||
test('evaluateCampaignHealth retorna pause false para campanha saudavel', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, false);
|
||||
assert.equal(result.reason, null);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por unknown_outcome quando unknownCount > 0', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const lead = addLead(db, 70);
|
||||
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 70 });
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, true);
|
||||
assert.equal(result.reason, 'unknown_outcome');
|
||||
assert.equal(db.prepare('SELECT status, pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
|
||||
assert.equal(db.prepare('SELECT pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id).pause_reason, 'unknown_outcome');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por high_failure_rate quando > 10%', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const lid = addLead(db, 100 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 100 + i });
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
|
||||
}
|
||||
for (let i = 8; i < 10; i++) {
|
||||
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' });
|
||||
}
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, true);
|
||||
assert.equal(result.reason, 'high_failure_rate');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth NAO pausa por failure rate exatamente 10%', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const lid = addLead(db, 200 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 200 + i });
|
||||
}
|
||||
for (let i = 0; i < 9; i++) {
|
||||
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
|
||||
}
|
||||
addSend(db, { leadId: leads[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' }); // 1/10 = 10% → not > 10%
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, false);
|
||||
assert.equal(result.reason, null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por high_negative_rate quando > 10%', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const lid = addLead(db, 300 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 300 + i });
|
||||
}
|
||||
for (let i = 0; i < 17; i++) {
|
||||
addStageEvent(db, leads[i], campaign.id, 'interessado_gti_vendas');
|
||||
}
|
||||
addStageEvent(db, leads[17], campaign.id, 'sem_interesse');
|
||||
addStageEvent(db, leads[18], campaign.id, 'opt_out');
|
||||
addStageEvent(db, leads[19], campaign.id, 'sem_interesse'); // 3/20 = 15% > 10%
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, true);
|
||||
assert.equal(result.reason, 'high_negative_rate');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth NAO pausa por negative rate exatamente 10%', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const lid = addLead(db, 400 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 400 + i });
|
||||
}
|
||||
for (let i = 0; i < 18; i++) {
|
||||
addStageEvent(db, leads[i], campaign.id, 'interessado_gti_vendas');
|
||||
}
|
||||
addStageEvent(db, leads[18], campaign.id, 'sem_interesse');
|
||||
addStageEvent(db, leads[19], campaign.id, 'interessado_g_delivery'); // 1 negative/20 = 5% → not > 10%
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, false);
|
||||
assert.equal(result.reason, null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por low_reply_rate quando < 5% com contacts >= 50', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const lid = addLead(db, 500 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 500 + i });
|
||||
}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
|
||||
}
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, true);
|
||||
assert.equal(result.reason, 'low_reply_rate');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth NAO pausa por reply rate exatamente 5%', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const leads = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const lid = addLead(db, 600 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 600 + i });
|
||||
}
|
||||
for (let i = 0; i < 60; i++) {
|
||||
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
addStageEvent(db, leads[i], campaign.id, 'respondeu');
|
||||
db.prepare(
|
||||
'INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES (?, ?, ?, ?)'
|
||||
).run(`reply-exact-${i}`, leads[i], '5511999990000', '2026-07-29T12:30:00.000Z');
|
||||
}
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, false);
|
||||
assert.equal(result.reason, null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth prioriza first-match: unknown > failure > negative > reply', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
const lead = addLead(db, 80);
|
||||
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 80 });
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.reason, 'unknown_outcome');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pause nunca volta para running automaticamente', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
|
||||
const lead = addLead(db, 90);
|
||||
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 90 });
|
||||
|
||||
const first = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(first.pause, true);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
|
||||
|
||||
db.prepare("UPDATE outreach_campaigns SET pause_reason = NULL WHERE id = ?").run(campaign.id);
|
||||
const second = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(second.pause, true);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- variant comparison tests ---
|
||||
|
||||
test('variant eligibleForComparison true at 40 sends for campaign/segment/variant', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const leadIds = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lid = addLead(db, 2000 + i, 'restaurantes');
|
||||
leadIds.push(lid);
|
||||
const qid = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'A', suffix: 2000 + i, segmento: 'restaurantes' });
|
||||
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qid });
|
||||
}
|
||||
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
const variantA = metrics.groups.byVariant.A;
|
||||
assert.ok(variantA !== undefined);
|
||||
assert.equal(variantA.sends, 40);
|
||||
assert.equal(variantA.eligibleForComparison, true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('declare winner only when all active compared variants reach 40 sends', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const leadIdsA = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lid = addLead(db, 3000 + i, 'restaurantes');
|
||||
leadIdsA.push(lid);
|
||||
const qidA = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'A', suffix: 3000 + i, segmento: 'restaurantes' });
|
||||
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidA });
|
||||
}
|
||||
const leadIdsB = [];
|
||||
for (let i = 0; i < 39; i++) {
|
||||
const lid = addLead(db, 3100 + i, 'restaurantes');
|
||||
leadIdsB.push(lid);
|
||||
const qidB = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'B', suffix: 3100 + i, segmento: 'restaurantes' });
|
||||
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidB });
|
||||
}
|
||||
|
||||
const metricsBefore = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metricsBefore.groups.byVariant.A.eligibleForComparison, true);
|
||||
assert.equal(metricsBefore.groups.byVariant.B.eligibleForComparison, false);
|
||||
|
||||
const extraLid = addLead(db, 3199, 'restaurantes');
|
||||
const qidExtra = addQueue(db, campaign, { leadId: extraLid, status: 'sent', variantCode: 'B', suffix: 3199, segmento: 'restaurantes' });
|
||||
addSend(db, { leadId: extraLid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidExtra });
|
||||
|
||||
const metricsAfter = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metricsAfter.groups.byVariant.A.eligibleForComparison, true);
|
||||
assert.equal(metricsAfter.groups.byVariant.B.eligibleForComparison, true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- F1 regression: cross-campaign send contamination ---
|
||||
|
||||
test('F1: campaignMetrics sends nao contaminam entre campanhas (usa outreach_send_id)', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
|
||||
const campaign2 = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1 OFFSET 1').get();
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign2.id);
|
||||
|
||||
const lead = addLead(db, 8000);
|
||||
|
||||
const qid1 = addQueue(db, campaign, { leadId: lead, status: 'sent', suffix: 8000 });
|
||||
const qid2 = addQueue(db, campaign2, { leadId: lead, status: 'sent', suffix: 8000 });
|
||||
|
||||
addSend(db, { leadId: lead, status: 'aceito', queueId: qid2 });
|
||||
|
||||
const metrics1 = campaignMetrics(db, campaign.id);
|
||||
assert.deepEqual(metrics1.provider, { aceito: 0, erro: 0, desconhecido: 0 },
|
||||
'campanha 1 nao deve contar send vinculado a campanha 2');
|
||||
|
||||
const metrics2 = campaignMetrics(db, campaign2.id);
|
||||
assert.deepEqual(metrics2.provider, { aceito: 1, erro: 0, desconhecido: 0 },
|
||||
'campanha 2 deve contar seu proprio send');
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- F2 regression: negative/opt-out count uses COUNT(DISTINCT) ---
|
||||
|
||||
test('F2: evaluateCampaignHealth negative count usa COUNT(DISTINCT) para evitar cartesian', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
|
||||
const leads = [];
|
||||
for (let i = 0; i < 19; i++) {
|
||||
const lid = addLead(db, 9000 + i);
|
||||
leads.push(lid);
|
||||
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 9000 + i });
|
||||
addStageEvent(db, lid, campaign.id, 'interessado_gti_vendas');
|
||||
}
|
||||
// 1 sem_interesse / 20 = 5% -> NOT >10%, no pause
|
||||
const lidNeg = addLead(db, 9019);
|
||||
leads.push(lidNeg);
|
||||
addQueue(db, campaign, { leadId: lidNeg, status: 'sent', suffix: 9019 });
|
||||
addStageEvent(db, lidNeg, campaign.id, 'sem_interesse');
|
||||
|
||||
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||
assert.equal(result.pause, false);
|
||||
assert.equal(result.reason, null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- F3 regression: group aggregation ignores filters ---
|
||||
|
||||
test('F3: campaignMetrics groups respeitam filtro segmento', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
addQueue(db, campaign, { leadId: addLead(db, 7000, 'restaurantes'), status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
|
||||
addQueue(db, campaign, { leadId: addLead(db, 7001, 'pizzarias'), status: 'sent', variantCode: 'A', segmento: 'pizzarias', suffix: 7001 });
|
||||
|
||||
const filtered = campaignMetrics(db, campaign.id, { segmento: 'restaurantes' });
|
||||
assert.ok(filtered.groups.bySegment.restaurantes, 'restaurantes deve estar nos grupos');
|
||||
assert.equal(filtered.groups.bySegment.pizzarias, undefined, 'pizzarias NAO deve estar nos grupos quando filtrado');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('F3: campaignMetrics groups respeitam filtro variant', async () => {
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
addQueue(db, campaign, { leadId: addLead(db, 7100, 'restaurantes'), status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
|
||||
addQueue(db, campaign, { leadId: addLead(db, 7101, 'restaurantes'), status: 'sent', variantCode: 'B', segmento: 'restaurantes', suffix: 7101 });
|
||||
|
||||
const filtered = campaignMetrics(db, campaign.id, { variant: 'A' });
|
||||
assert.ok(filtered.groups.byVariant.A, 'variant A deve estar nos grupos');
|
||||
assert.equal(filtered.groups.byVariant.B, undefined, 'variant B NAO deve estar nos grupos quando filtrado');
|
||||
db.close();
|
||||
});
|
||||
|
||||
// --- worker and store wiring tests ---
|
||||
|
||||
test('campaign-worker avalia saude apos finalizacao de envio', async () => {
|
||||
const { runDueCampaignQueue } = await import('../src/outreach/campaign-worker.mjs');
|
||||
const { campaignMetrics } = await metricsModule();
|
||||
const { db, campaign } = setup();
|
||||
saveWhatsAppVerification(db, { numero: '5511999900001', status: 'confirmado' });
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(campaign.id);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('worker-metrics', 'Teste', '5511999900001', 'restaurantes', 1)`
|
||||
).run();
|
||||
const leadId = Number(db.prepare('SELECT id FROM leads').get().id);
|
||||
const variantId = db.prepare(
|
||||
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
|
||||
).get(campaign.id).id;
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento,
|
||||
rendered_message, selection_reason, status, scheduled_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Msg', 'test', 'scheduled', ?)`
|
||||
).run(campaign.id, leadId, variantId, campaign.produto, '5511999900001',
|
||||
'2026-07-29T12:00:00.000Z');
|
||||
|
||||
let afterSendCalled = false;
|
||||
const whatsapp = {
|
||||
getStatus: () => ({ state: 'READY' }),
|
||||
sendText: async (args) => {
|
||||
const context = await args.beforeSend();
|
||||
const result = { messageId: 'metrics-test' };
|
||||
await args.afterSend(result, null, context);
|
||||
afterSendCalled = true;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
await runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now: new Date('2026-07-29T12:00:00.000Z'),
|
||||
log: () => {}
|
||||
});
|
||||
|
||||
assert.equal(afterSendCalled, true);
|
||||
const metrics = campaignMetrics(db, campaign.id);
|
||||
assert.equal(metrics.provider.aceito, 1);
|
||||
assert.equal(metrics.attempted, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('campaign-store avalia saude apos mudanca manual de etapa', async () => {
|
||||
const { evaluateCampaignHealth } = await metricsModule();
|
||||
const db = openDb(':memory:');
|
||||
seedCampaigns(db);
|
||||
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||
|
||||
db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('store-test', 'Teste')").run();
|
||||
const leadId = Number(db.prepare('SELECT id FROM leads').get().id);
|
||||
|
||||
setLeadCommercialStage(db, { leadId, campaignId: campaign.id, stage: 'sem_interesse', note: 'nao quis' });
|
||||
|
||||
const health = evaluateCampaignHealth(db, campaign.id);
|
||||
assert.equal(typeof health.pause, 'boolean');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
addOptOut,
|
||||
openDb,
|
||||
saveWhatsAppVerification
|
||||
} from '../src/db.mjs';
|
||||
import { prepareDailyQueue } from '../src/outreach/campaign-queue.mjs';
|
||||
import {
|
||||
getCampaign,
|
||||
listCampaignQueue,
|
||||
listCampaigns,
|
||||
seedCampaigns
|
||||
} from '../src/outreach/campaign-store.mjs';
|
||||
|
||||
function setupCampaign(code = 'gdelivery-food') {
|
||||
const db = openDb(':memory:');
|
||||
seedCampaigns(db);
|
||||
const campaign = listCampaigns(db).find((item) => item.code === code);
|
||||
return { db, campaign };
|
||||
}
|
||||
|
||||
function insertLead(db, {
|
||||
key,
|
||||
empresa = '',
|
||||
whatsapp,
|
||||
segmento = 'pizzarias',
|
||||
prioridade = 1,
|
||||
score = 0
|
||||
}) {
|
||||
return Number(db.prepare(
|
||||
`INSERT INTO leads
|
||||
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`
|
||||
).run(key, empresa || key, whatsapp, segmento, prioridade, score).lastInsertRowid);
|
||||
}
|
||||
|
||||
function confirmed(db, numero, {
|
||||
resolvedNumber = numero,
|
||||
checkedAt = null,
|
||||
status = 'confirmado'
|
||||
} = {}) {
|
||||
saveWhatsAppVerification(db, {
|
||||
numero,
|
||||
status,
|
||||
resolvedNumber: status === 'confirmado' ? resolvedNumber : null,
|
||||
variant: 'exata'
|
||||
});
|
||||
if (checkedAt) {
|
||||
db.prepare('UPDATE whatsapp_number_verifications SET checked_at = ? WHERE numero = ?')
|
||||
.run(checkedAt, numero);
|
||||
}
|
||||
}
|
||||
|
||||
function noSendWhatsapp(checkNumber = async (numero) => ({
|
||||
numero,
|
||||
status: 'nao_cadastrado',
|
||||
registered: false,
|
||||
resolvedNumber: null,
|
||||
variant: null,
|
||||
candidatesChecked: 2
|
||||
})) {
|
||||
return {
|
||||
checkNumber,
|
||||
sendText() {
|
||||
throw new Error('prepareDailyQueue nao pode enviar mensagens');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function traceCandidateQuery(db) {
|
||||
const trace = { sql: null, args: null };
|
||||
const tracedDb = new Proxy(db, {
|
||||
get(target, property) {
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
if (!sql.includes('campaign-candidates')) return statement;
|
||||
trace.sql = sql;
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
if (statementProperty === 'all') {
|
||||
return (...args) => {
|
||||
trace.args = args;
|
||||
return statementTarget.all(...args);
|
||||
};
|
||||
}
|
||||
const value = statementTarget[statementProperty];
|
||||
return typeof value === 'function' ? value.bind(statementTarget) : value;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = target[property];
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
}
|
||||
});
|
||||
return { tracedDb, trace };
|
||||
}
|
||||
|
||||
test('prepara confirmados na ordem comercial e exclui inelegiveis em SQL', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const highScore = insertLead(db, {
|
||||
key: 'high-score', empresa: 'Pizzaria Sol', whatsapp: '5575991234001', prioridade: 1, score: 90
|
||||
});
|
||||
const lowScore = insertLead(db, {
|
||||
key: 'low-score', whatsapp: '5575991234002', prioridade: 1, score: 40
|
||||
});
|
||||
const lowerPriority = insertLead(db, {
|
||||
key: 'lower-priority', whatsapp: '5575991234003', prioridade: 2, score: 100
|
||||
});
|
||||
const priorProduct = insertLead(db, {
|
||||
key: 'prior-product', whatsapp: '5575991234004', prioridade: 1, score: 99
|
||||
});
|
||||
const optedOut = insertLead(db, {
|
||||
key: 'opted-out', whatsapp: '5575991234005', prioridade: 1, score: 98
|
||||
});
|
||||
const replied = insertLead(db, {
|
||||
key: 'replied', whatsapp: '5575991234006', prioridade: 1, score: 97
|
||||
});
|
||||
const queued = insertLead(db, {
|
||||
key: 'queued', whatsapp: '5575991234007', prioridade: 1, score: 96
|
||||
});
|
||||
const staleConfirmation = insertLead(db, {
|
||||
key: 'stale-confirmation', whatsapp: '5575991234008', prioridade: 1, score: 95
|
||||
});
|
||||
const outsideSegment = insertLead(db, {
|
||||
key: 'outside-segment', whatsapp: '5575991234009', segmento: 'restaurantes', prioridade: 1, score: 100
|
||||
});
|
||||
|
||||
for (const number of [
|
||||
'5575991234001', '5575991234002', '5575991234003', '5575991234004',
|
||||
'5575991234005', '5575991234006', '5575991234007', '5575991234008',
|
||||
'5575991234009'
|
||||
]) confirmed(db, number);
|
||||
confirmed(db, '557591234008', {
|
||||
status: 'nao_cadastrado', checkedAt: '2026-07-29 13:00:00'
|
||||
});
|
||||
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00' WHERE numero = '5575991234008'").run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES (?, 'G-Delivery', '5575991234004', 'anterior', 'erro', 'manual')`
|
||||
).run(priorProduct);
|
||||
addOptOut(db, '557591234005', 'pedido do contato');
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
|
||||
VALUES (?, 'respondeu', '2026-07-29 10:00:00')`
|
||||
).run(replied);
|
||||
const variantA = campaign.variants.find((variant) => variant.code === 'A');
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
|
||||
VALUES (?, ?, ?, 'G-Delivery', '5575991234007', 'pizzarias', 'existente', 'existente', 'preview')`
|
||||
).run(campaign.id, queued, variantA.id);
|
||||
|
||||
let checks = 0;
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async () => {
|
||||
checks += 1;
|
||||
throw new Error('confirmados existentes nao devem ser consultados');
|
||||
}),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
const queue = listCampaignQueue(db, campaign.id, 'preview');
|
||||
|
||||
assert.deepEqual(summary, { checked: 0, previewed: 3, skipped: 0 });
|
||||
assert.equal(checks, 0);
|
||||
assert.deepEqual(queue.map((item) => item.lead_id), [queued, highScore, lowScore, lowerPriority]);
|
||||
assert.deepEqual(queue.slice(1).map((item) => item.variant_code), ['B', 'C', 'A']);
|
||||
assert.match(queue[1].rendered_message, /^Olá, pessoal da Pizzaria Sol, tudo bem\?/);
|
||||
assert.deepEqual(queue.slice(1).map((item) => item.selection_reason), [
|
||||
'gdelivery-food:prioridade-1',
|
||||
'gdelivery-food:prioridade-1',
|
||||
'gdelivery-food:prioridade-2'
|
||||
]);
|
||||
assert.equal(queue.some((item) => [priorProduct, optedOut, replied, staleConfirmation, outsideSegment].includes(item.lead_id)), false);
|
||||
assert.equal(new Set(queue.map((item) => item.lead_id)).size, queue.length);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('estado mais recente de numero equivalente confirma e persiste o numero resolvido', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const leadId = insertLead(db, {
|
||||
key: 'equivalent-confirmed', whatsapp: '5575998765432', score: 50
|
||||
});
|
||||
confirmed(db, '5575998765432', {
|
||||
status: 'nao_cadastrado', checkedAt: '2026-07-29 10:00:00'
|
||||
});
|
||||
confirmed(db, '557598765432', {
|
||||
resolvedNumber: '557598765432', checkedAt: '2026-07-29 11:00:00'
|
||||
});
|
||||
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async () => {
|
||||
throw new Error('estado equivalente confirmado nao deve consumir verificacao');
|
||||
}),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
const queue = listCampaignQueue(db, campaign.id);
|
||||
|
||||
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
|
||||
assert.equal(queue[0].lead_id, leadId);
|
||||
assert.equal(queue[0].numero, '557598765432');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('verifica sequencialmente no maximo o teto diario sem cobrar confirmados existentes', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const confirmedLead = insertLead(db, {
|
||||
key: 'already-confirmed', whatsapp: '5575992222000', score: 100
|
||||
});
|
||||
confirmed(db, '5575992222000');
|
||||
for (let index = 1; index <= 20; index += 1) {
|
||||
insertLead(db, {
|
||||
key: `unverified-${index}`,
|
||||
whatsapp: `55759922${String(index).padStart(4, '0')}`,
|
||||
score: 100 - index
|
||||
});
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const whatsapp = noSendWhatsapp(async (numero) => {
|
||||
calls.push(numero);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
inFlight -= 1;
|
||||
return {
|
||||
numero,
|
||||
status: 'nao_cadastrado',
|
||||
registered: false,
|
||||
resolvedNumber: null,
|
||||
variant: null,
|
||||
candidatesChecked: 2
|
||||
};
|
||||
});
|
||||
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
const queue = listCampaignQueue(db, campaign.id);
|
||||
|
||||
assert.deepEqual(summary, { checked: 15, previewed: 1, skipped: 15 });
|
||||
assert.equal(summary.checked, Math.ceil(getCampaign(db, campaign.id).approved_daily_limit * 1.5));
|
||||
assert.equal(maxInFlight, 1);
|
||||
assert.equal(queue.length, 1);
|
||||
assert.equal(queue[0].lead_id, confirmedLead);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 16);
|
||||
assert.deepEqual(calls, Array.from({ length: 15 }, (_, index) => `55759922${String(index + 1).padStart(4, '0')}`));
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('limite de verificacao ignora multiplicador adulterado acima de 1.5', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
db.exec('PRAGMA ignore_check_constraints = ON');
|
||||
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_insert');
|
||||
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_update');
|
||||
db.prepare('UPDATE outreach_campaigns SET verification_multiplier = 99 WHERE id = ?').run(campaign.id);
|
||||
for (let index = 1; index <= 30; index += 1) {
|
||||
insertLead(db, {
|
||||
key: `malicious-cap-${index}`,
|
||||
whatsapp: `55759888${String(index).padStart(4, '0')}`,
|
||||
score: 100 - index
|
||||
});
|
||||
}
|
||||
const calls = [];
|
||||
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async (numero) => {
|
||||
calls.push(numero);
|
||||
return {
|
||||
numero,
|
||||
status: 'nao_cadastrado',
|
||||
registered: false,
|
||||
resolvedNumber: null,
|
||||
variant: null,
|
||||
candidatesChecked: 2
|
||||
};
|
||||
}),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
|
||||
assert.equal(summary.checked, 15);
|
||||
assert.equal(calls.length, 15);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('persiste erro temporario e nao repete equivalente na mesma preparacao', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
insertLead(db, { key: 'temporary-a', whatsapp: '5575999091780', score: 100 });
|
||||
insertLead(db, { key: 'temporary-b', whatsapp: '557599091780', score: 90 });
|
||||
const calls = [];
|
||||
const whatsapp = noSendWhatsapp(async (numero) => {
|
||||
calls.push(numero);
|
||||
throw Object.assign(new Error('consulta excedeu o prazo'), {
|
||||
numero,
|
||||
status: 'erro_temporario',
|
||||
registered: false,
|
||||
resolvedNumber: null,
|
||||
variant: null,
|
||||
candidatesChecked: 2
|
||||
});
|
||||
});
|
||||
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
|
||||
assert.deepEqual(summary, { checked: 1, previewed: 0, skipped: 2 });
|
||||
assert.deepEqual(calls, ['5575999091780']);
|
||||
assert.deepEqual({ ...db.prepare(
|
||||
'SELECT numero, status, erro FROM whatsapp_number_verifications'
|
||||
).get() }, {
|
||||
numero: '5575999091780',
|
||||
status: 'erro_temporario',
|
||||
erro: 'consulta excedeu o prazo'
|
||||
});
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('duas conexoes reais recontam a mesma campanha sem SQLITE_BUSY ou excesso', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-queue-race-'));
|
||||
const dbPath = path.join(tempDir, 'race.sqlite');
|
||||
let firstDb;
|
||||
let secondDb;
|
||||
try {
|
||||
firstDb = openDb(dbPath);
|
||||
seedCampaigns(firstDb);
|
||||
secondDb = openDb(dbPath);
|
||||
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
insertLead(firstDb, {
|
||||
key: `real-race-${index}`,
|
||||
whatsapp: `5575993300${String(index).padStart(2, '0')}`,
|
||||
segmento: 'restaurantes',
|
||||
score: 100 - index
|
||||
});
|
||||
}
|
||||
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
let arrivals = 0;
|
||||
const whatsapp = noSendWhatsapp(async (numero) => {
|
||||
arrivals += 1;
|
||||
if (arrivals === 2) release();
|
||||
await gate;
|
||||
return {
|
||||
numero,
|
||||
status: 'confirmado',
|
||||
registered: true,
|
||||
resolvedNumber: numero,
|
||||
variant: 'exata',
|
||||
candidatesChecked: 2
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
prepareDailyQueue({ first: true, db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
|
||||
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
|
||||
]);
|
||||
|
||||
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
|
||||
const queue = firstDb.prepare(
|
||||
"SELECT produto, lead_id FROM outreach_campaign_queue WHERE status = 'preview' ORDER BY lead_id"
|
||||
).all();
|
||||
assert.ok(queue.length <= 10);
|
||||
assert.equal(new Set(queue.map(({ lead_id }) => lead_id)).size, queue.length);
|
||||
assert.equal(queue.every(({ produto }) => produto === 'GTI Vendas'), true);
|
||||
} finally {
|
||||
secondDb?.close();
|
||||
firstDb?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('campanhas sobrepostas do mesmo produto nao criam duas filas ativas', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-product-race-'));
|
||||
const dbPath = path.join(tempDir, 'race.sqlite');
|
||||
let firstDb;
|
||||
let secondDb;
|
||||
try {
|
||||
firstDb = openDb(dbPath);
|
||||
seedCampaigns(firstDb);
|
||||
secondDb = openDb(dbPath);
|
||||
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
|
||||
const secondCampaign = listCampaigns(secondDb).find(({ code }) => code === 'gti-retail');
|
||||
secondDb.prepare("UPDATE outreach_campaigns SET segments_json = '[\"restaurantes\"]' WHERE id = ?")
|
||||
.run(secondCampaign.id);
|
||||
insertLead(firstDb, {
|
||||
key: 'same-product-race', whatsapp: '557599771001', segmento: 'restaurantes', score: 100
|
||||
});
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
let arrivals = 0;
|
||||
const whatsapp = noSendWhatsapp(async (numero) => {
|
||||
arrivals += 1;
|
||||
if (arrivals === 2) release();
|
||||
await gate;
|
||||
return {
|
||||
numero,
|
||||
status: 'confirmado',
|
||||
registered: true,
|
||||
resolvedNumber: numero,
|
||||
variant: 'exata',
|
||||
candidatesChecked: 2
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
prepareDailyQueue({ db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
|
||||
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: secondCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
|
||||
]);
|
||||
|
||||
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
|
||||
assert.deepEqual(firstDb.prepare(
|
||||
"SELECT produto, lead_id, COUNT(*) total FROM outreach_campaign_queue WHERE status = 'preview' GROUP BY produto, lead_id"
|
||||
).all().map((row) => ({ ...row })), [{ produto: 'GTI Vendas', lead_id: 1, total: 1 }]);
|
||||
} finally {
|
||||
secondDb?.close();
|
||||
firstDb?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
});
|
||||
|
||||
test('consulta 1.2m irrelevantes e 100k correspondentes com indice, limite e tempo pratico', async (t) => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const fixtureStartedAt = performance.now();
|
||||
db.exec(`
|
||||
WITH RECURSIVE sequence(value) AS (
|
||||
VALUES(1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM sequence WHERE value < 1200000
|
||||
)
|
||||
INSERT INTO leads
|
||||
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
|
||||
SELECT 'outside-' || value, 'Outside ' || value,
|
||||
'5511' || printf('%08d', value), 'segmento_externo', 1, value % 100, 1
|
||||
FROM sequence;
|
||||
WITH RECURSIVE sequence(value) AS (
|
||||
VALUES(1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM sequence WHERE value < 100000
|
||||
)
|
||||
INSERT INTO leads
|
||||
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
|
||||
SELECT 'matching-' || value, 'Matching ' || value,
|
||||
'5575' || printf('%08d', value), 'pizzarias', 1 + (value % 4), value % 101, 1
|
||||
FROM sequence;
|
||||
`);
|
||||
const fixtureElapsedMs = performance.now() - fixtureStartedAt;
|
||||
const { tracedDb, trace } = traceCandidateQuery(db);
|
||||
const startedAt = performance.now();
|
||||
const summary = await prepareDailyQueue({
|
||||
db: tracedDb,
|
||||
whatsapp: noSendWhatsapp(),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
assert.equal(summary.checked, 15);
|
||||
assert.ok(trace.sql, 'consulta real de candidatos nao foi observada');
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
|
||||
.map((row) => row.detail).join('\n');
|
||||
assert.match(plan, /idx_leads_campaign_order/i);
|
||||
assert.match(trace.sql, /LIMIT\s+\?/i);
|
||||
assert.ok(trace.args.at(-1) <= 25);
|
||||
assert.ok(elapsedMs < 8_000, `consulta limitada levou ${elapsedMs.toFixed(1)}ms`);
|
||||
t.diagnostic(`fixture_1_3m_ms=${fixtureElapsedMs.toFixed(1)} candidate_query_ms=${elapsedMs.toFixed(1)}`);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('exclui envios bem-sucedidos, desconhecidos e respostas inbound explicitas', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
const delivered = insertLead(db, { key: 'sent-delivered', whatsapp: '557599550001', score: 100 });
|
||||
const unknown = insertLead(db, { key: 'sent-unknown', whatsapp: '557599550002', score: 99 });
|
||||
const inboundLead = insertLead(db, { key: 'inbound-lead', whatsapp: '557599550003', score: 98 });
|
||||
const inboundEquivalent = insertLead(db, { key: 'inbound-equivalent', whatsapp: '5575999550004', score: 97 });
|
||||
const eligible = insertLead(db, { key: 'eligibility-control', whatsapp: '557599550005', score: 96 });
|
||||
for (const numero of ['557599550001', '557599550002', '557599550003', '5575999550004', '557599550005']) {
|
||||
confirmed(db, numero);
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
|
||||
).run(delivered, '557599550001', 'entregue');
|
||||
db.prepare(
|
||||
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
|
||||
).run(unknown, '557599550002', 'desconhecido');
|
||||
db.prepare(
|
||||
"INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES ('inbound-by-lead', ?, '557599550003', '2026-07-29T12:00:00Z')"
|
||||
).run(inboundLead);
|
||||
db.prepare(
|
||||
"INSERT INTO outreach_inbound_replies (provider_message_id, numero, received_at) VALUES ('inbound-by-number', '557599550004', '2026-07-29T12:01:00Z')"
|
||||
).run();
|
||||
|
||||
const summary = await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
const queue = listCampaignQueue(db, campaign.id);
|
||||
|
||||
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
|
||||
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [eligible]);
|
||||
assert.equal(queue.some(({ lead_id }) => [delivered, unknown, inboundLead, inboundEquivalent].includes(lead_id)), false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('usa somente a lista de segmentos configurada na campanha', async () => {
|
||||
const { db, campaign } = setupCampaign();
|
||||
db.prepare("UPDATE outreach_campaigns SET segments_json = '[\"pizzarias\"]' WHERE id = ?").run(campaign.id);
|
||||
const configured = insertLead(db, { key: 'configured-segment', whatsapp: '557599660001', segmento: 'pizzarias' });
|
||||
const catalogButRemoved = insertLead(db, { key: 'removed-segment', whatsapp: '557599660002', segmento: 'hamburguerias' });
|
||||
confirmed(db, '557599660001');
|
||||
confirmed(db, '557599660002');
|
||||
|
||||
await prepareDailyQueue({
|
||||
db,
|
||||
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
|
||||
campaignId: campaign.id,
|
||||
now: new Date('2026-07-29T14:00:00-03:00')
|
||||
});
|
||||
|
||||
assert.deepEqual(listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id), [configured]);
|
||||
assert.notEqual(configured, catalogButRemoved);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, setSetting } from '../src/db.mjs';
|
||||
import { listCampaigns, saveAppointment, seedCampaigns, setLeadCommercialStage } from '../src/outreach/campaign-store.mjs';
|
||||
import { createRouter } from '../src/server/router.mjs';
|
||||
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
|
||||
|
||||
function setup({
|
||||
state = 'READY',
|
||||
now = new Date(2026, 6, 29, 12),
|
||||
random = () => 0
|
||||
} = {}) {
|
||||
const db = openDb(':memory:');
|
||||
seedCampaigns(db);
|
||||
setSetting(db, 'outreach.mensagensPorDia', '7');
|
||||
let checks = 0;
|
||||
let sends = 0;
|
||||
const whatsapp = {
|
||||
getStatus: () => ({ state, connected: state === 'READY' }),
|
||||
checkNumber: async (number) => {
|
||||
checks += 1;
|
||||
return { registered: true, numero: number, numero_resolvido: number };
|
||||
},
|
||||
sendText: async () => {
|
||||
sends += 1;
|
||||
throw new Error('rota de campanha nao pode enviar mensagens');
|
||||
}
|
||||
};
|
||||
const router = createRouter();
|
||||
registerCampaignRoutes(router, { db, whatsapp, random, now: () => now });
|
||||
return { db, router, whatsapp, counts: () => ({ checks, sends }) };
|
||||
}
|
||||
|
||||
async function invoke(router, method, pathname, {
|
||||
body,
|
||||
user = { role: 'admin' },
|
||||
query = new URLSearchParams()
|
||||
} = {}) {
|
||||
const matched = router.match(method, pathname);
|
||||
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
|
||||
const calls = [];
|
||||
await matched.handler(null, null, {
|
||||
params: matched.params,
|
||||
body,
|
||||
user,
|
||||
query,
|
||||
json: (status, data) => calls.push({ status, data })
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
return calls[0];
|
||||
}
|
||||
|
||||
function campaignIds(db) {
|
||||
return db.prepare('SELECT id FROM outreach_campaigns ORDER BY id').all().map((row) => row.id);
|
||||
}
|
||||
|
||||
function addPreview(db, campaignId, suffix) {
|
||||
const campaign = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1').get(campaignId);
|
||||
const lead = db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score)
|
||||
VALUES (?, ?, ?, 'restaurantes', 1, 90)`
|
||||
).run(`lead-${campaignId}-${suffix}`, `Empresa ${suffix}`, `551199999${String(suffix).padStart(4, '0')}`);
|
||||
return Number(db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'mensagem aprovada', 'teste', 'preview')`
|
||||
).run(campaignId, Number(lead.lastInsertRowid), variant.id, campaign.produto, `551199999${String(suffix).padStart(4, '0')}`).lastInsertRowid);
|
||||
}
|
||||
|
||||
function schedulePreview(db, queueId, hour = 9, minute = 30) {
|
||||
const scheduledAt = new Date(2026, 6, 29, hour, minute).toISOString();
|
||||
db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?")
|
||||
.run(scheduledAt, queueId);
|
||||
}
|
||||
|
||||
test('lista, detalha e filtra fila sem misturar uso manual e automatico', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
addPreview(db, campaignId, 1);
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES ('G-Delivery', '5511999999001', 'manual', 'aceito', 'manual')`
|
||||
).run();
|
||||
for (const [index, status] of ['pendente', 'enviado', 'entregue', 'lido', 'desconhecido', 'erro'].entries()) {
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES ('G-Delivery', ?, 'manual', ?, 'manual')`
|
||||
).run(`55118888000${index}`, status);
|
||||
}
|
||||
const automaticSendId = Number(db.prepare(
|
||||
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES ('G-Delivery', '5511999999002', 'campanha', 'aceito', 'automatico')`
|
||||
).run().lastInsertRowid);
|
||||
const queued = addPreview(db, campaignId, 2);
|
||||
schedulePreview(db, queued);
|
||||
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(queued);
|
||||
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent', outreach_send_id = ? WHERE id = ?")
|
||||
.run(automaticSendId, queued);
|
||||
|
||||
const listed = await invoke(router, 'GET', '/api/campaigns', { user: { role: 'comercial' } });
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.data.campaigns.length, 3);
|
||||
const campaign = listed.data.campaigns.find((item) => item.id === campaignId);
|
||||
assert.deepEqual(campaign.usage, {
|
||||
manual: { limit: 7, used: 6, remaining: 1 },
|
||||
campaign: { limit: 10, used: 1, remaining: 9 }
|
||||
});
|
||||
|
||||
const detail = await invoke(router, 'GET', `/api/campaigns/${campaignId}`, { user: { role: 'comercial' } });
|
||||
assert.equal(detail.data.campaign.id, campaignId);
|
||||
assert.deepEqual(detail.data.usage, campaign.usage);
|
||||
const queue = await invoke(router, 'GET', `/api/campaigns/${campaignId}/queue`, {
|
||||
user: { role: 'comercial' }, query: new URLSearchParams({ status: 'preview' })
|
||||
});
|
||||
assert.deepEqual(queue.data.queue.map((row) => row.status), ['preview']);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('todas as mutacoes rejeitam usuario nao-admin antes de alterar estado', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
const queueId = addPreview(db, campaignId, 3);
|
||||
const requests = [
|
||||
['POST', `/api/campaigns/${campaignId}/prepare`, {}],
|
||||
['POST', `/api/campaigns/${campaignId}/approve`, { queueIds: [queueId], date: '2026-07-27' }],
|
||||
['DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`, undefined],
|
||||
['POST', `/api/campaigns/${campaignId}/start`, {}],
|
||||
['POST', `/api/campaigns/${campaignId}/pause`, { reason: 'revisao' }],
|
||||
['PUT', `/api/campaigns/${campaignId}/week`, { week: 2 }]
|
||||
];
|
||||
for (const [method, path, body] of requests) {
|
||||
const response = await invoke(router, method, path, { body, user: { role: 'comercial' } });
|
||||
assert.equal(response.status, 403, `${method} ${path}`);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'preview');
|
||||
assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('preparacao usa verificacao gradual e nunca chama envio', async () => {
|
||||
const { db, router, counts } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score)
|
||||
VALUES ('eligible', 'Hamburgueria Sol', '5511999990001', 'hamburguerias', 1, 100)`
|
||||
).run();
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/prepare`, { body: {} });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.data.summary.previewed, 1);
|
||||
assert.deepEqual(counts(), { checks: 1, sends: 0 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('aprovacao exige IDs explicitos da propria campanha e faz transicao atomica', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId, otherCampaignId] = campaignIds(db);
|
||||
const selected = addPreview(db, campaignId, 10);
|
||||
const removed = addPreview(db, campaignId, 11);
|
||||
const foreign = addPreview(db, otherCampaignId, 12);
|
||||
const originalPayload = db.prepare(
|
||||
'SELECT variant_id, rendered_message FROM outreach_campaign_queue WHERE id = ?'
|
||||
).get(selected);
|
||||
|
||||
const missing = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { date: '2026-07-27' }
|
||||
});
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
const mixed = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { queueIds: [selected, foreign], date: '2026-07-27' }
|
||||
});
|
||||
assert.equal(mixed.status, 409);
|
||||
assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(selected, removed).map((row) => row.status), ['preview', 'preview']);
|
||||
|
||||
const approved = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { queueIds: [selected], date: '2026-07-27' }
|
||||
});
|
||||
assert.equal(approved.status, 200);
|
||||
assert.equal(approved.data.scheduled, 1);
|
||||
const rows = db.prepare(
|
||||
'SELECT id, status, variant_id, rendered_message, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
|
||||
).all(campaignId);
|
||||
assert.equal(rows[0].status, 'scheduled');
|
||||
assert.match(rows[0].scheduled_at, /^2026-07-27T/);
|
||||
assert.equal(rows[0].variant_id, originalPayload.variant_id);
|
||||
assert.equal(rows[0].rendered_message, originalPayload.rendered_message);
|
||||
const { variant_id: ignoredVariant, rendered_message: ignoredMessage, ...removedRow } = rows[1];
|
||||
void ignoredVariant;
|
||||
void ignoredMessage;
|
||||
assert.deepEqual({ ...removedRow }, { id: removed, status: 'cancelled', scheduled_at: null, cancel_reason: 'removed_by_operator' });
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'ready');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('aprovacao reverte tudo quando a data ou janelas nao fornecem slots suficientes', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
const first = addPreview(db, campaignId, 20);
|
||||
const second = addPreview(db, campaignId, 21);
|
||||
db.prepare("UPDATE outreach_campaigns SET windows_json = '[{\"start\":\"09:30\",\"end\":\"09:30\"}]' WHERE id = ?").run(campaignId);
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { queueIds: [first, second], date: '2026-07-27' }
|
||||
});
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id').all(campaignId).map((row) => row.status), ['preview', 'preview']);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('aprovacao rejeita excesso somando fila ja ativa no mesmo dia local', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
const existing = Array.from({ length: 10 }, (_, index) => addPreview(db, campaignId, 100 + index));
|
||||
existing.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
||||
const selected = addPreview(db, campaignId, 111);
|
||||
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { queueIds: [selected], date: '2026-07-29' }
|
||||
});
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(selected).status, 'preview');
|
||||
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 10);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remocao cancela somente item mutavel pertencente a campanha', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId, otherCampaignId] = campaignIds(db);
|
||||
const removable = addPreview(db, campaignId, 30);
|
||||
const foreign = addPreview(db, otherCampaignId, 31);
|
||||
const sent = addPreview(db, campaignId, 32);
|
||||
schedulePreview(db, sent);
|
||||
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(sent);
|
||||
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent' WHERE id = ?").run(sent);
|
||||
|
||||
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${foreign}`)).status, 404);
|
||||
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${sent}`)).status, 409);
|
||||
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${removable}`)).status, 200);
|
||||
assert.deepEqual({ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(removable) }, {
|
||||
status: 'cancelled', cancel_reason: 'removed_by_operator'
|
||||
});
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remocao de item agendado preserva timestamp imutavel de auditoria', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
const queueId = addPreview(db, campaignId, 33);
|
||||
schedulePreview(db, queueId);
|
||||
const before = db.prepare('SELECT scheduled_at FROM outreach_campaign_queue WHERE id = ?').get(queueId).scheduled_at;
|
||||
|
||||
const response = await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual({ ...db.prepare('SELECT status, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) }, {
|
||||
status: 'cancelled', scheduled_at: before, cancel_reason: 'removed_by_operator'
|
||||
});
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('inicio exige fila agendada, WhatsApp READY e estado ready ou paused', async () => {
|
||||
const noSchedule = setup();
|
||||
const [campaignId] = campaignIds(noSchedule.db);
|
||||
assert.equal((await invoke(noSchedule.router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} })).status, 409);
|
||||
noSchedule.db.close();
|
||||
|
||||
const disconnected = setup({ state: 'DISCONNECTED' });
|
||||
const [disconnectedId] = campaignIds(disconnected.db);
|
||||
const queueId = addPreview(disconnected.db, disconnectedId, 40);
|
||||
disconnected.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(queueId);
|
||||
assert.equal((await invoke(disconnected.router, 'POST', `/api/campaigns/${disconnectedId}/start`, { body: {} })).status, 409);
|
||||
assert.equal(disconnected.db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(disconnectedId).status, 'paused');
|
||||
disconnected.db.close();
|
||||
|
||||
const ready = setup();
|
||||
const [readyId] = campaignIds(ready.db);
|
||||
const scheduled = addPreview(ready.db, readyId, 41);
|
||||
ready.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(scheduled);
|
||||
const started = await invoke(ready.router, 'POST', `/api/campaigns/${readyId}/start`, { body: {} });
|
||||
assert.equal(started.status, 200);
|
||||
assert.equal(started.data.campaign.status, 'running');
|
||||
ready.db.close();
|
||||
});
|
||||
|
||||
test('inicio rejeita dia local com tentativas ativas acima do limite aprovado', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 200 + index));
|
||||
queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} });
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('reducao de semana rejeita limite abaixo da fila ativa do dia sem alterar controles', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
db.prepare('UPDATE outreach_campaigns SET week_number = 4, approved_daily_limit = 18 WHERE id = ?').run(campaignId);
|
||||
const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 300 + index));
|
||||
queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
||||
|
||||
const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 1 } });
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual({ ...db.prepare('SELECT week_number, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaignId) }, {
|
||||
week_number: 4, approved_daily_limit: 18
|
||||
});
|
||||
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 11);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('pausa exige motivo e semana muda somente pelo mapa explicito', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
assert.equal((await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: {} })).status, 400);
|
||||
const paused = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: { reason: 'revisao diaria' } });
|
||||
assert.equal(paused.status, 200);
|
||||
assert.equal(paused.data.campaign.pause_reason, 'revisao diaria');
|
||||
|
||||
assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: {} })).status, 400);
|
||||
assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1);
|
||||
for (const [week, limit] of [[1, 10], [2, 12], [3, 15], [4, 18]]) {
|
||||
const changed = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week } });
|
||||
assert.equal(changed.status, 200);
|
||||
assert.equal(changed.data.campaign.week_number, week);
|
||||
assert.equal(changed.data.campaign.approved_daily_limit, limit);
|
||||
}
|
||||
assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 5 } })).status, 400);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('pausa aceita somente ready, running ou paused e preserva estados terminais', async () => {
|
||||
const { db, router } = setup();
|
||||
const [, draftId, completedId] = campaignIds(db);
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'draft' WHERE id = ?").run(draftId);
|
||||
db.prepare("UPDATE outreach_campaigns SET status = 'completed' WHERE id = ?").run(completedId);
|
||||
for (const campaignId of [draftId, completedId]) {
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, {
|
||||
body: { reason: 'nao deve alterar' }
|
||||
});
|
||||
assert.equal(response.status, 409);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(draftId).status, 'draft');
|
||||
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(completedId).status, 'completed');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('falha operacional retorna 500 generico sem vazar mensagem SQLite', async () => {
|
||||
const { db, router } = setup();
|
||||
const [campaignId] = campaignIds(db);
|
||||
db.exec(`
|
||||
CREATE TRIGGER campaign_operational_failure
|
||||
BEFORE UPDATE ON outreach_campaigns
|
||||
BEGIN SELECT RAISE(FAIL, 'sqlite-secret-detail'); END;
|
||||
`);
|
||||
const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 2 } });
|
||||
assert.deepEqual(response, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } });
|
||||
assert.doesNotMatch(JSON.stringify(response), /sqlite-secret-detail/i);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('aprovacao separa validacao conhecida de falha inesperada do agendador', async () => {
|
||||
const invalid = setup();
|
||||
const [invalidCampaignId] = campaignIds(invalid.db);
|
||||
const invalidQueueId = addPreview(invalid.db, invalidCampaignId, 500);
|
||||
const validation = await invoke(invalid.router, 'POST', `/api/campaigns/${invalidCampaignId}/approve`, {
|
||||
body: { queueIds: [invalidQueueId], date: '2026-02-30' }
|
||||
});
|
||||
assert.equal(validation.status, 400);
|
||||
assert.match(validation.data.erro, /data local invalida/i);
|
||||
invalid.db.close();
|
||||
|
||||
const secret = 'SCHEDULE-SENTINEL-SECRET';
|
||||
const unexpected = setup({ random: () => { throw new Error(secret); } });
|
||||
const [campaignId] = campaignIds(unexpected.db);
|
||||
const first = addPreview(unexpected.db, campaignId, 501);
|
||||
const second = addPreview(unexpected.db, campaignId, 502);
|
||||
const failure = await invoke(unexpected.router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
||||
body: { queueIds: [first, second], date: '2026-07-29' }
|
||||
});
|
||||
assert.deepEqual(failure, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } });
|
||||
assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret, 'i'));
|
||||
assert.deepEqual(unexpected.db.prepare('SELECT status FROM outreach_campaign_queue ORDER BY id').all().map((row) => row.status), ['preview', 'preview']);
|
||||
unexpected.db.close();
|
||||
});
|
||||
|
||||
test('GET /api/campaigns/replies/pending retorna lista de leads que responderam', async () => {
|
||||
const { db, router } = setup();
|
||||
const campaigns = listCampaigns(db);
|
||||
const leadId1 = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-1', 'Lead Respondeu', '5511999991001')"
|
||||
).run().lastInsertRowid);
|
||||
const leadId2 = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-2', 'Lead Avancado', '5511999991002')"
|
||||
).run().lastInsertRowid);
|
||||
setLeadCommercialStage(db, { leadId: leadId1, campaignId: campaigns[0].id, stage: 'respondeu', note: 'quer info' });
|
||||
setLeadCommercialStage(db, { leadId: leadId2, campaignId: campaigns[0].id, stage: 'enviado' });
|
||||
|
||||
const response = await invoke(router, 'GET', '/api/campaigns/replies/pending');
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.data.pending.length, 1);
|
||||
assert.equal(response.data.pending[0].lead_id, leadId1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('PUT /api/campaigns/leads/:leadId/stage atualiza etapa comercial', async () => {
|
||||
const { db, router } = setup();
|
||||
const leadId = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-1', 'Lead Stage', '5511999991010')"
|
||||
).run().lastInsertRowid);
|
||||
|
||||
const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, {
|
||||
body: { stage: 'interessado_gti_vendas', note: 'demonstracao marcada' }
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.data.state.stage, 'interessado_gti_vendas');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('PUT /api/campaigns/leads/:leadId/stage rejeita etapa invalida', async () => {
|
||||
const { db, router } = setup();
|
||||
const leadId = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-2', 'Lead Inv', '5511999991020')"
|
||||
).run().lastInsertRowid);
|
||||
|
||||
const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, {
|
||||
body: { stage: 'etapa_inexistente' }
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(JSON.stringify(response.data), /etapa comercial invalida/i);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns/leads/:leadId/appointments cria agendamento', async () => {
|
||||
const { db, router } = setup();
|
||||
const campaigns = listCampaigns(db);
|
||||
const leadId = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-1', 'Lead App', '5511999991030')"
|
||||
).run().lastInsertRowid);
|
||||
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, {
|
||||
body: {
|
||||
campaignId: campaigns[0].id,
|
||||
kind: 'demonstracao',
|
||||
participantName: 'Maria',
|
||||
companyName: 'Empresa App',
|
||||
preferredDay: '2026-08-10',
|
||||
preferredTime: '15:00'
|
||||
}
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.data.appointment.kind, 'demonstracao');
|
||||
assert.equal(response.data.appointment.participant_name, 'Maria');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns/leads/:leadId/appointments rejeita demonstracao sem campos obrigatorios', async () => {
|
||||
const { db, router } = setup();
|
||||
const leadId = Number(db.prepare(
|
||||
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-2', 'Lead App Fail', '5511999991040')"
|
||||
).run().lastInsertRowid);
|
||||
|
||||
const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, {
|
||||
body: { kind: 'demonstracao' }
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('GET /api/campaigns/replies/pending funciona sem autenticacao de admin', async () => {
|
||||
const { db, router } = setup();
|
||||
const response = await invoke(router, 'GET', '/api/campaigns/replies/pending', { user: { role: 'comercial' } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(Array.isArray(response.data.pending));
|
||||
db.close();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user