Compare commits

...
36 Commits
Author SHA1 Message Date
GustavoandClaude Sonnet 5 2f31dd2b87 chore: clean repository migration artifacts
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 15:55:41 -03:00
GustavoandClaude Sonnet 5 417da56bd6 chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
2026-09-28 15:50:17 -03:00
GustavoandClaude Sonnet 5 f5af038e19 chore: import gti-maquina-comercial history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: gti-maquina-comercial
git-subtree-mainline: 93ca82979e
git-subtree-split: b77484946f
2026-09-28 15:50:08 -03:00
GustavoandClaude Sonnet 5 93ca82979e chore: initialize commercial GTI monorepo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 15:49:53 -03:00
GustavoandClaude Sonnet 5 a9ef4d776d feat: add production deployment and runtime safeguards
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 15:48:44 -03:00
Gustavo b77484946f Merge branch 'feature/campanhas-outreach'
# Conflicts:
#	public/css/app.css
#	scripts/servidor.mjs
#	src/db.mjs
#	src/server/index.mjs
2026-08-03 13:16:37 -03:00
Gustavo eaaf7af659 Add WABA outreach automation (Z-PRO integration)
Per-product message templates, official/não-oficial channel selection,
manual per-number sending, and a 20-minute internal scheduler for the
existing automatic outreach batch — all pre-existing local work being
committed as-is.
2026-08-03 13:14:09 -03:00
Gustavo 60d28c7465 Campanhas UI: try/catch on Marcar button, inline error for segmentos, cap variants at 3
- Wrap the "Marcar" stage button's onclick body in try/catch (toast on
  error), matching the pattern already applied to the 5 campaign action
  buttons.
- carregarSegmentos' error handler (from the produto <select> change
  handler) can fire while #modal-nova-campanha is open, where a toast
  alone is hidden behind the open <dialog> (documented in api.js next to
  the toast() function) -- now also surfaces the error in the existing
  inline #campanha-erro paragraph.
- addVarianteRow now caps at 3 rows (A/B/C, matching the convention used
  elsewhere in the UI/backend) and disables "+ Variante" once the cap is
  reached.
2026-08-03 13:07:13 -03:00
Gustavo 6f6ed97206 Add worker safety rails; split evaluateCampaignHealth into pure read + mutating apply
Two issues in the campaign worker path:

1. runCampaignQueue had none of the safety rails run.mjs's automatic sender
   has: no outreach.ativo gate, no business-hours window, no jitter between
   sends. A campaign left "rodando" would send around the clock even with
   outreach disabled or outside business hours, and would hammer the same
   WhatsApp number back-to-back. Added the same outreach.ativo/horaInicio/
   horaFim checks (duplicated, not imported, to keep the modules
   independent) plus a randomized 3-8s jitter between real send attempts.
   Sleep delay is injectable via an optional sleepMs param so tests can
   pass sleepMs: 0.

2. evaluateCampaignHealth mutated state (via applyPause) on every call,
   including from the read-only GET /api/campaigns/:id/saude route -- so
   simply opening the campaign detail panel in the UI could silently pause
   a healthy-looking campaign. Split it into a pure evaluateCampaignHealth
   (computes {pause, reason}, never writes) and a new applyCampaignHealth
   (evaluates, then persists the pause if warranted). The worker now calls
   applyCampaignHealth; the GET /saude route keeps calling the pure
   evaluateCampaignHealth.

Tests: worker sends nothing with outreach.ativo=false or outside business
hours; evaluateCampaignHealth confirmed not to mutate status even when it
would return pause:true; existing mutation assertions moved onto
applyCampaignHealth.
2026-08-03 13:07:03 -03:00
Gustavo 3a91ca4732 resumeCampaign resets semana_iniciada_em to avoid immediate escalation
resumeCampaign only updated status/pause_reason, so a campaign auto-paused
for many days and then resumed could immediately satisfy
escalateWeeklyLimits' 7-day check and double its daily limit right after
coming back from an unhealthy state -- the opposite of the intended
safety behavior. Now resumeCampaign also stamps semana_iniciada_em to
datetime('now'), resetting the weekly clock on every resume.
2026-08-03 13:06:51 -03:00
Gustavo 8ce2fcdfea Fix unbounded inbound-message dedup set and un-unref'd timeout timer
inboundMessageIds was only ever cleared whole (on disconnect), so it grew
without bound proportional to total inbound message volume over a
connection's lifetime. Now capped at 5000 entries, evicting the oldest
(Set preserves insertion order) once exceeded.

withTimeout's internal setTimeout wasn't unref'd, unlike every other timer
in this file (qrTimer, reconnectTimer, the lease timer) — a pending
lookup/send timeout could keep the event loop alive if the process ever
tried to exit gracefully without an explicit process.exit().

Found via a full-project audit.
2026-08-01 01:23:31 -03:00
Gustavo 3e55b843e2 Fix evaluateCampaignHealth counting another campaign's sends for the same lead
attemptsToday/failureCount joined outreach_sends to outreach_campaign_queue
by lead_id + campaign_id instead of the actual queue-to-send link
(outreach_send_id). campaignMetrics() already used outreach_send_id for
its own equivalent queries (see the existing F1 regression test) after a
prior fix; evaluateCampaignHealth's separate queries had not received the
same fix. Currently unreachable in practice since the campaign catalog
uses mutually-exclusive segments per lead, but a latent bug: if a lead
ever had automated-send history in two campaigns, one campaign's failures
could count toward another's high_failure_rate auto-pause threshold.

Fixed two existing test fixtures that happened to pass without actually
exercising the attempt/failure-rate path they were named for (their sends
weren't linked via outreach_send_id, so attemptsToday silently came out as
0 pre-fix). Added a regression test (F1b) mirroring the existing F1 test's
cross-campaign-contamination scenario for campaignMetrics, applied to
evaluateCampaignHealth instead; verified it fails against the pre-fix
query and passes against the fix.

Found via a full-project audit.
2026-07-31 22:35:21 -03:00
Gustavo d6fdfc6e2d Fix invalid prioridade filter silently matching P4 instead of nothing
buildLeadFilter used Number(filtro.prioridade) || 4, so an explicit
prioridade=0 or any non-numeric value (both falsy after Number()) matched
P4 leads instead of the requested (invalid) priority. Not reachable via
the UI (its dropdown only offers 1-4) but reachable through a direct API
call. Drops the fallback: an invalid value becomes NaN, which node:sqlite
accepts as a bound parameter and never compares equal to anything, so it
now correctly matches zero rows instead of silently redirecting to P4.

Found via a full-project audit.
2026-07-31 22:30:45 -03:00
Gustavo 61a00e9fc2 Fix out-of-order pagination responses in the treatment table
pg-prev/pg-next deliberately skip comCarregando (it would fight the
page-boundary disabled-state logic), so nothing stopped two rapid clicks
from firing overlapping GET /api/leads requests. If the earlier page's
response resolved after the later one, it silently overwrote the table,
pager text and prev/next disabled state with a page the operator was no
longer viewing. Adds a per-call sequence number in carregarTabela(); a
response is only applied if no newer call has started since, covering
every caller (pager clicks, filter changes, tab activation) with one guard.

Found via a full-project audit; verified the regression test fails
against the pre-fix code and passes against the fix.
2026-07-31 22:26:37 -03:00
Gustavo b34d9294c6 Fix silent failure when loading campaign metrics
carregarMetrics()/carregarMetricsData() swallowed fetch errors with a bare
catch, leaving the metrics card blank with no indication anything went
wrong (same class of issue already fixed for pending replies). Now shows
an explicit error message and toasts.

Found via a full-project audit.
2026-07-31 22:22:51 -03:00
Gustavo 3be769408c Fix google-places losing already-fetched leads on a mid-pagination failure
search() accumulated results across up to 3 pages in a local array, but a
failure on page 2 or 3 (e.g. Google's next_page_token not active yet
despite the 2.5s wait, or a transient error) propagated out and lost the
whole accumulated array, not just the failing page. The caller's catch
only logs and continues, discarding leads that were already successfully
fetched. Now only rethrows (preserving existing behavior) when nothing has
been accumulated yet; a failure after at least one successful page logs
and returns what was already collected.

Found via a full-project audit.
2026-07-31 22:16:53 -03:00
Gustavo 3d552b5bc0 Fix export excel writer crashing the process on a stream write error
exportExcel's CSV/XLS write streams had no 'error' listener, so a write
failure (disk full, export directory becomes inaccessible mid-run — both
realistic for a large CNPJ export) surfaced as an uncaught exception that
crashed the whole export-job.mjs child process, bypassing every
await/try-catch in the caller. The exports row was then never marked
'error' and stayed stuck, and a half-written file could sit at the final
path (no temp+rename, unlike the same pattern already used in
rfb-extractor.mjs/rfb-downloader.mjs).

Streams now track their error via a listener instead of throwing, writes
and stream-end are rejected through the normal promise chain so
export-job.mjs's existing catch/finally handles it, and both files are
written to .tmp paths and only renamed to their final names once both
streams close successfully.

Found via a full-project audit.
2026-07-31 22:13:49 -03:00
Gustavo 0bae79e1aa Fix late cleanup-failure error line overwriting an already-successful job done
The 'line' handler applied a t:'error' event immediately and unconditionally,
even when a t:'done' had already arrived (deferred as pendingDone until
close). A job whose real work finished successfully (io.done() called,
e.g. leads already committed by the child) could still end up persisted
and reported as 'error' if something in the child's finally-block cleanup
(closing the lease/db) threw afterward, since main().catch() calls
io.fail() + process.exit(1) in that case — discarding a real success
summary for a cosmetic cleanup failure.

Fix distinguishes this from a genuinely unexplained nonzero exit (process
killed/crashed with no chance to report anything), which two existing
tests deliberately require to still override a done: a new
lateErrorAfterDone flag is only set when an explicit error line follows a
done, and close only trusts the done over a nonzero exit code when that
flag is set.

Found via a full-project audit.
2026-07-31 22:11:03 -03:00
Gustavo 2b83995446 Fix silent failure when loading pending campaign replies
carregarReplies() swallowed any fetch error, leaving whatever was already
in #pending-replies — on first load, the static "Nenhuma resposta
pendente." placeholder. A failed load was visually indistinguishable from
a genuine empty state, so a customer reply could go unnoticed with no
error surfaced (every other action in this file already toasts on
failure). Now shows an explicit error state and toasts, and the fetch
mock harness in campaign-ui.test.mjs gained toast-call tracking to prove it.

Found via a full-project audit.
2026-07-31 22:06:30 -03:00
Gustavo 0c36f27cb3 Fix opt-out asymmetric 8/9-digit bypass letting sends reach blocked numbers
isOptedOut/addOptOut/removeOptOut used equivalentPhoneNumbers, which is not
symmetric for a 12-digit local outside the 6-9 range vs its 13-digit
counterpart (same root cause already fixed for shared-number detection).
An opt-out registered against one form of such a number silently failed to
block sends to a lead whose number was stored in the other form, on both
the manual send path (reserveOutreachSend) and, independently, in two
inlined checks that bypassed isOptedOut entirely: the last-mile recheck in
campaign-worker.mjs's reserveCampaignQueueSend, and the opt-out NOT EXISTS
clause in campaign-queue.mjs's candidate query. All three now use the
symmetric canonicalPhoneVariants key already introduced for shared-number
overrides, and the worker's last-mile check now calls isOptedOut directly
instead of re-deriving its own number list.

Found via a full-project audit; reproduced and verified before fixing.
2026-07-31 22:04:19 -03:00
Gustavo 29f9f3a905 Close two minor gaps flagged by round-2 re-review of listLeads split
Drop any row whose id disappeared between the id-pagination query and the
hydrate query (e.g. a lead deleted by a base reset mid-request) instead of
leaking an undefined hole into the response. Document that SORTS may only
reference leads columns, since the id-pagination query no longer joins
segments.
2026-07-31 20:59:42 -03:00
Gustavo 240c70b715 Fix treatment listing block-sort regression from per-row shared-number subqueries
Re-review of afa350d found that the correlated-subquery rewrite in
listLeads regressed any page landing in a large sort-tie block (e.g. the
default prioridade:asc sort's prioridade=4/score=0 bucket): SQLite's block
sort materializes the whole tied group before LIMIT/OFFSET apply, so the
shared-number subqueries got evaluated for the entire block instead of just
the page (measured 8-9x slower on page 2+ of a 60k-row fixture).

Split listLeads into two queries: a cheap id-only pass that does the
sorting/pagination (same cost as before the shared-number feature existed),
then a hydrate pass scoped to WHERE leads.id IN (...) for just that page's
ids, which is where the shared-number subqueries now run. getLead was
already a single-id lookup and didn't need this.

Also fixed the accompanying test, which only checked page 1 (a small,
already-sorted block) and so couldn't have caught the regression it was
meant to guard against; it now also checks a page landing inside the large
tied block.
2026-07-31 20:52:45 -03:00
Gustavo afa350de5e Fix final-review findings: last-mile shared-number recheck, symmetric canonical key, per-row treatment query, JS/SQL agreement test
The last-mile validator in campaign-worker.mjs revalidated opt-out/inbound/
dedupe right before sending but never rechecked shared-number state, so
re-blocking a released number after scheduling didn't stop the send.

equivalentPhoneNumbers isn't symmetric for all 8/9-digit pairs, which made
isNumberSharedAcrossLeads and listSharedNumberGroups disagree for a 12-digit
local outside the 6-9 range vs its 13-digit counterpart: one side of the pair
could be enforcement-blocked while invisible and unreleasable in the review
screen. A new canonicalPhoneKey/canonicalPhoneVariants pair in phone.mjs
(plus SQL mirrors) makes the matching relation symmetric by construction.

listSharedNumberGroups did a full leads-table scan on every listLeads/getLead
call, the app's primary paginated screen. Replaced with per-row correlated
subqueries against idx_leads_whatsapp so cost tracks the page, not the table.

normalizedSql/equivalentSql (SQL) duplicated equivalentPhoneNumbers (JS) with
no test proving they agreed; moved into phone.mjs alongside their canonical
counterparts and added tests/phone.test.mjs asserting agreement across a
shared number set, including the asymmetric case.
2026-07-31 20:31:31 -03:00
Gustavo cb03597435 Document shared-number blocking in the operator README 2026-07-31 16:37:38 -03:00
Gustavo 18d427e2bd Add Configuracoes review section for shared numbers
Adds an admin-only "Numeros compartilhados entre leads" section to the
settings modal, wired to the Task 4 shared-numbers routes: lists
detected groups, lets an admin liberar (with an inline motivo input)
or bloquear de novo. Updates the two frontend VM-based test files'
default API mocks to answer GET /api/outreach/shared-numbers so
existing settings-modal tests keep passing, and adds a new
interaction test covering the liberar round trip.
2026-07-31 16:33:03 -03:00
Gustavo a6dce5c936 Show shared-number badge in the treatment table and lead detail 2026-07-31 12:22:36 -03:00
Gustavo 1370b39cee Expose shared-number state on lead list and detail queries 2026-07-31 12:18:26 -03:00
Gustavo 16b29a83d0 Add shared-number review routes and block manual send for unreleased numbers 2026-07-31 12:13:46 -03:00
Gustavo e11658b4e0 Correct misleading comment about the candidates derived-subquery rationale
Review found the comment claimed a per-row precomputation optimization
that SQLite does not actually perform: the reviewer counted replace()
opcodes in the compiled bytecode and got an identical count (305) for
both the inline and derived-subquery forms of the query, since SQLite
flattens the subquery and re-substitutes the expression text at each
usage site either way. Rewrote the comment to describe the derived
subquery as what it actually is (a readability device), and to state
plainly that the perf investigation did not conclusively isolate the
rare timing outliers to this SQL's shape.
2026-07-31 12:06:25 -03:00
Gustavo e9f585458f Fix perf flakiness in shared-number candidate query by precomputing lead numbers once
The candidate query's lead_number/lead_equivalent forms were being
re-embedded as raw SQL expression text at every usage site (verification
join, opt-out check, shared-number check, inbound-replies check). Each
embedding re-evaluates the same deeply nested replace()/substr()/length()
chain per row; adding the shared-number check as a 5th usage site made
this measurably worse, especially in the worst case where a segment has
zero pre-verified numbers and the scan can't terminate early via LIMIT
(confirmed via reconstructing the pre-Task-3 query on an identical
fixture: that scenario already cost ~3.4s before this feature, and the
5th usage site pushed it to 4.1-9.5s with real variance, including an
outright failure over 5 consecutive runs).

Restructured candidateQuery to compute lead_number/lead_equivalent once
per row in a derived subquery (aliased `candidates`), referenced by
column everywhere else instead of re-embedding the expression text.
SQLite still flattens/uses idx_leads_campaign_order for the segmento
scan and sort (verified via EXPLAIN QUERY PLAN), so the common fast path
is unaffected (~2ms), while the worst-case path dropped to a consistent
~3-5s across 5 consecutive runs of the 1.3M-row perf test.
2026-07-31 11:52:48 -03:00
Gustavo d85fb12991 Exclude leads sharing a WhatsApp number from the automatic campaign queue
candidateQuery now blocks a lead whose WhatsApp number is shared by 2+
leads, unless a shared-number override exists. The inner correlated
subquery compares the bare other.whatsapp column (not normalizedSql())
against the outer row's two computed number forms so SQLite can use
idx_leads_whatsapp instead of a full table scan, keeping the 1.3M-row
perf test within budget.

Also updates the pre-existing 'persiste erro temporario e nao repete
equivalente' test: its two leads use 8/9-digit equivalent numbers,
which the new filter now blocks by default, so an override is added
to keep exercising the intended equivalent-number dedup behavior.
2026-07-31 11:18:41 -03:00
GustavoandClaude Sonnet 5 3d2144eefc Block manual send reservation for unreleased shared numbers
Implement shared-number detection in reserveOutreachSend: reject manual WhatsApp sends with HTTP 409 when the destination number is shared across 2+ leads and hasn't been explicitly released via override.

- Add test: reserveOutreachSend bloqueia numero compartilhado sem override e libera com override
- Add check in reserveOutreachSend right after isOptedOut check
- Uses isNumberSharedAcrossLeads and isSharedNumberOverridden from Task 1

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -03:00
GustavoandClaude Sonnet 5 40bd8d096e Fix v15 migration test to drop v19 table before replaying migrations
The v15 test pattern (delete migration records >= 15, drop schema, reopen to replay)
was failing because outreach_shared_number_overrides table from v19 migration was not
dropped before replaying. Add DROP TABLE IF EXISTS to the schema cleanup block to
match the same fix applied to v17 test.

This resolves the 'table already exists' error during v19 migration replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 11:04:22 -03:00
GustavoandClaude Sonnet 5 fe70436dbb Add shared-number detection and override data layer
- Add migration v19: creates outreach_shared_number_overrides table
- Implement 7 new data-layer functions:
  - leadsSharingNumber: find leads sharing a phone number variant
  - isNumberSharedAcrossLeads: detect if number is shared by 2+ leads
  - getSharedNumberOverride: retrieve override entry for a number
  - isSharedNumberOverridden: check if number has an override
  - addSharedNumberOverride: create or update override with reason
  - removeSharedNumberOverride: delete override record
  - listSharedNumberGroups: group leads by shared numbers, reflect override state
- Add 4 comprehensive tests covering migration, detection, override crud, and grouping
- Update version assertions from 18 to 19
- Fix v17 migration test to properly clean up v19 table for schema reset

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 10:59:29 -03:00
Gustavo d330bb5ff0 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.
2026-07-31 10:54:07 -03:00
Gustavo 6cd336a41e Add design spec for shared-number detection in outreach
Detects when a WhatsApp number is shared by multiple leads (e.g. an
accountant's phone reused across client CNPJs) and blocks automatic
and manual outreach to all of them until an operator reviews and
releases the number.
2026-07-31 10:36:02 -03:00
261 changed files with 33780 additions and 223 deletions
+4 -10
View File
@@ -1,11 +1,5 @@
node_modules/
.DS_Store
Thumbs.db
.env
.env.local
data/
prospeccao/cnpj/rfb-raw/
prospeccao/cnpj/*.csv
prospeccao/cnpj/*.ndjson
*.log
GTI-Comercial.exe
launcher/publish/
.worktrees/
.env.*
!*.example
+6 -188
View File
@@ -1,192 +1,10 @@
# GTI Maquina Comercial
# COMERCIAL-GTI
Aplicativo local do Grupo GTI para capturar, qualificar, tratar e acionar leads B2B via WhatsApp. O sistema roda no computador do usuario, usa SQLite como banco local e integra com Z-PRO/GChat para envio manual ou automatico.
Monorepo corporativo do Grupo GTI para as aplicacoes de captura, qualificacao e tratamento de leads B2B via WhatsApp.
URL local padrao: `http://127.0.0.1:3900`
## Componentes
Login inicial: `admin` / `admin`
- `gti-maquina-comercial/` — versao corporativa/multiusuario (Z-PRO/GChat, login com multiplos usuarios).
- `maquina-comercial-pessoal/` — versao single-user (WhatsApp por sessao de dispositivo vinculado, Baileys).
## Uso Diario
1. Abra `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
2. Acesse `http://127.0.0.1:3900` e faca login.
3. Use a aba `Capturar Leads` para escolher UF, cidades, nichos e fontes.
4. Use a aba `Tratar Leads` para filtrar, priorizar, abrir detalhes e exportar leads.
5. Use o botao de WhatsApp na primeira coluna da tabela para iniciar uma mensagem para um lead especifico.
6. Use `Configuracoes` para ajustar Z-PRO, templates WABA, limites de envio, usuarios e exportacoes.
## Principais Recursos
- Captura de leads B2B por UF, cidade e nicho.
- Base CNPJ da Receita Federal como fonte principal.
- Fontes complementares: OpenStreetMap/Overpass, Nominatim, Wikidata, BrasilAPI e Google Places.
- Deduplicacao por CNPJ, telefone/WhatsApp ou empresa+cidade.
- Scores de qualidade e potencial comercial.
- Dashboard com totais, rankings, funil e maior oportunidade.
- Tabela de tratamento com filtros, paginação e detalhe completo do lead.
- Acao rapida de WhatsApp por lead, fixa na primeira coluna da tabela.
- Envio manual por Z-PRO para numero especifico.
- Envio oficial por template WABA aprovado.
- Sincronizacao de templates WABA do GChat.
- Pre-visualizacao de templates com variaveis resolvidas por lead.
- Automacao de envio por produto recomendado, com limite diario.
- Exportacoes para Excel/CSV, oportunidades Z-PRO e disparo em massa.
- Login local com usuarios `admin` e `comercial`.
## Fluxo Comercial
1. Capture ou importe a base de leads.
2. Filtre por cidade, nicho, qualidade, WhatsApp, prioridade ou status de exportacao.
3. Revise o lead na tabela ou no modal de detalhes.
4. Clique no botao de WhatsApp do lead para abrir o envio manual com o numero preenchido.
5. Escolha texto livre ou template oficial.
6. Se usar template, selecione o modelo salvo e confira a pre-visualizacao.
7. Envie pelo Z-PRO e acompanhe o log de envios.
Ao abrir o envio manual a partir de um lead, o app busca os dados completos desse cliente. Variaveis como `{{cnpj}}`, `{{empresa}}`, `{{businessName}}`, `{{produto}}`, `{{cidade}}`, `{{uf}}`, `{{segmento}}`, `{{whatsapp}}` e `{{email}}` sao preenchidas com os dados reais do lead na pre-visualizacao e no envio.
## WhatsApp, Z-PRO e GChat
O app suporta dois tipos de envio:
| Canal | Uso | Observacao |
|---|---|---|
| Nao oficial/texto livre | Mensagem manual simples | Usa endpoint externo do Z-PRO para enviar texto |
| Oficial/template WABA | Mensagem com template aprovado | Usa template Meta/WABA via Z-PRO |
Configuracoes importantes ficam em `Configuracoes`:
- Base URL Z-PRO.
- API ID Z-PRO.
- Token/Bearer token.
- Rota do template (`template`, `templateBody` ou equivalente configurado).
- URL de templates do GChat.
- Token GChat opcional para sincronizar modelos.
- Limite diario de envios.
- Templates automaticos por produto.
O seletor de templates WABA permite carregar modelos sincronizados do GChat. O sistema identifica variaveis posicionais como `{{1}}`, `{{2}}`, `{{3}}` no corpo do template e mostra os campos necessarios.
## Variaveis Suportadas
As mensagens e templates podem usar variaveis internas da Maquina Comercial/GChat:
| Variavel | Valor esperado |
|---|---|
| `{{name}}` | Nome do contato quando existir; fallback para empresa |
| `{{empresa}}` | Nome da empresa |
| `{{businessName}}` | Nome da empresa no padrao GChat |
| `{{firstName}}` | Primeiro nome |
| `{{lastName}}` | Sobrenome, quando existir |
| `{{produto}}` | Produto recomendado/interesse |
| `{{protocol}}` | Assunto/protocolo; fallback para produto |
| `{{cidade}}` | Cidade |
| `{{uf}}` | UF |
| `{{segmento}}` | Nicho/segmento |
| `{{cnpj}}` | CNPJ |
| `{{email}}` | E-mail |
| `{{phoneNumber}}` | WhatsApp/telefone |
| `{{whatsapp}}` | WhatsApp/telefone |
| `{{kanban}}` | Produto/interesse |
| `{{user}}` | Remetente |
| `{{userEmail}}` | E-mail do remetente |
| `{{greeting}}` | Saudacao |
Tambem existem placeholders legados: `{empresa}`, `{cidade}`, `{produto}` e `{segmento}`.
## Exportacoes
| Tipo | Saida | Para que serve |
|---|---|---|
| Planilha | CSV + XLS | Analise comercial e tratamento externo |
| Z-PRO Oportunidades | JSONL | Payloads para criacao de oportunidades |
| Z-PRO Disparo em massa | CSV | `dataInput` para disparo em massa |
Leads exportados ficam marcados. Use o filtro de nao exportados para evitar retrabalho.
## Score e Priorizacao
O app calcula scores para ajudar o time comercial a decidir quem abordar primeiro.
Regras principais:
- WhatsApp/telefone valido aumenta a prioridade.
- CNPJ valido aumenta a confiabilidade.
- Empresa valida, cidade, nicho, CNAE, website e e-mail melhoram o score.
- Nichos com maior aderencia comercial recebem mais peso.
- Leads sem WhatsApp podem continuar cadastrados, mas aparecem com prioridade menor.
- Duplicidade segue a ordem CNPJ, telefone, empresa+cidade.
## Fontes de Captura
- Base CNPJ da Receita Federal: fonte principal para empresas ativas por UF.
- OpenStreetMap/Overpass: fonte aberta complementar.
- Nominatim: geocodificacao e apoio geografico.
- Wikidata: fonte aberta de baixo volume.
- BrasilAPI: enriquecimento de dados cadastrais.
- Google Places: fonte opcional paga, exige `GOOGLE_MAPS_API_KEY` no `.env`.
## Comandos
```bash
npm start
npm test
npm run capture
npm run extract
npm run recalc-scores
npm run outreach
npm run build:launcher
```
Exemplos:
```bash
node scripts/servidor.mjs
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
node scripts/extrair-base-cnpj.mjs --uf=SP
node scripts/enviar-outreach.mjs
```
## Estrutura
```text
GTI-Comercial.exe Launcher local do app
INICIAR-GTI.bat Inicializacao alternativa no Windows
scripts/ Entradas de linha de comando
src/ Codigo do servidor e regras de negocio
src/server/ HTTP, rotas, sessoes e autenticacao
src/capture/ Pipeline de captura
src/sources/ Fontes de dados
src/treatment/ Consultas e dashboard de tratamento
src/export/ Exportacoes Excel/CSV/Z-PRO
src/outreach/ Z-PRO, templates WABA e automacao WhatsApp
public/ Interface HTML/CSS/JS
tests/ Testes automatizados
data/gti.db Banco SQLite local, nao versionado
data/exports/ Arquivos exportados, nao versionado
prospeccao/cnpj/ Base CNPJ e dados brutos da Receita
docs/ Documentacao complementar
.env Segredos locais, nao versionado
```
## Requisitos
- Windows.
- Node.js 24 ou superior.
- Para extrair novas UFs, `unzip`, Git Bash ou `tar` nativo do Windows.
## Seguranca e Dados
- O banco principal fica em `data/gti.db`.
- Segredos locais devem ficar no `.env` ou nas configuracoes locais do app.
- Tokens Z-PRO/GChat nao devem ser commitados.
- A zona de risco em Configuracoes apaga leads, capturas e exportacoes; a acao e irreversivel.
- Nao ha backup automatico do banco local.
## Problemas Comuns
- Se a tela nao abrir, reinicie por `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
- Se preferir depurar, rode `node scripts/servidor.mjs` no terminal.
- Log do servidor: `data/server.log`.
- Se a sincronizacao de templates GChat falhar com erro de token, gere/salve um token GChat valido novamente.
- Se um template WABA falhar no Z-PRO, teste outro template aprovado e confira rota, idioma e variaveis obrigatorias.
Cada componente manteve seu historico Git anterior preservado dentro deste monorepo (importado via `git subtree`, sem squash).
+11
View File
@@ -0,0 +1,11 @@
node_modules/
.env
.env.local
data/
prospeccao/cnpj/rfb-raw/
prospeccao/cnpj/*.csv
prospeccao/cnpj/*.ndjson
*.log
GTI-Comercial.exe
launcher/publish/
.worktrees/
+192
View File
@@ -0,0 +1,192 @@
# GTI Maquina Comercial
Aplicativo local do Grupo GTI para capturar, qualificar, tratar e acionar leads B2B via WhatsApp. O sistema roda no computador do usuario, usa SQLite como banco local e integra com Z-PRO/GChat para envio manual ou automatico.
URL local padrao: `http://127.0.0.1:3900`
Login inicial: `admin` / `admin`
## Uso Diario
1. Abra `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
2. Acesse `http://127.0.0.1:3900` e faca login.
3. Use a aba `Capturar Leads` para escolher UF, cidades, nichos e fontes.
4. Use a aba `Tratar Leads` para filtrar, priorizar, abrir detalhes e exportar leads.
5. Use o botao de WhatsApp na primeira coluna da tabela para iniciar uma mensagem para um lead especifico.
6. Use `Configuracoes` para ajustar Z-PRO, templates WABA, limites de envio, usuarios e exportacoes.
## Principais Recursos
- Captura de leads B2B por UF, cidade e nicho.
- Base CNPJ da Receita Federal como fonte principal.
- Fontes complementares: OpenStreetMap/Overpass, Nominatim, Wikidata, BrasilAPI e Google Places.
- Deduplicacao por CNPJ, telefone/WhatsApp ou empresa+cidade.
- Scores de qualidade e potencial comercial.
- Dashboard com totais, rankings, funil e maior oportunidade.
- Tabela de tratamento com filtros, paginação e detalhe completo do lead.
- Acao rapida de WhatsApp por lead, fixa na primeira coluna da tabela.
- Envio manual por Z-PRO para numero especifico.
- Envio oficial por template WABA aprovado.
- Sincronizacao de templates WABA do GChat.
- Pre-visualizacao de templates com variaveis resolvidas por lead.
- Automacao de envio por produto recomendado, com limite diario.
- Exportacoes para Excel/CSV, oportunidades Z-PRO e disparo em massa.
- Login local com usuarios `admin` e `comercial`.
## Fluxo Comercial
1. Capture ou importe a base de leads.
2. Filtre por cidade, nicho, qualidade, WhatsApp, prioridade ou status de exportacao.
3. Revise o lead na tabela ou no modal de detalhes.
4. Clique no botao de WhatsApp do lead para abrir o envio manual com o numero preenchido.
5. Escolha texto livre ou template oficial.
6. Se usar template, selecione o modelo salvo e confira a pre-visualizacao.
7. Envie pelo Z-PRO e acompanhe o log de envios.
Ao abrir o envio manual a partir de um lead, o app busca os dados completos desse cliente. Variaveis como `{{cnpj}}`, `{{empresa}}`, `{{businessName}}`, `{{produto}}`, `{{cidade}}`, `{{uf}}`, `{{segmento}}`, `{{whatsapp}}` e `{{email}}` sao preenchidas com os dados reais do lead na pre-visualizacao e no envio.
## WhatsApp, Z-PRO e GChat
O app suporta dois tipos de envio:
| Canal | Uso | Observacao |
|---|---|---|
| Nao oficial/texto livre | Mensagem manual simples | Usa endpoint externo do Z-PRO para enviar texto |
| Oficial/template WABA | Mensagem com template aprovado | Usa template Meta/WABA via Z-PRO |
Configuracoes importantes ficam em `Configuracoes`:
- Base URL Z-PRO.
- API ID Z-PRO.
- Token/Bearer token.
- Rota do template (`template`, `templateBody` ou equivalente configurado).
- URL de templates do GChat.
- Token GChat opcional para sincronizar modelos.
- Limite diario de envios.
- Templates automaticos por produto.
O seletor de templates WABA permite carregar modelos sincronizados do GChat. O sistema identifica variaveis posicionais como `{{1}}`, `{{2}}`, `{{3}}` no corpo do template e mostra os campos necessarios.
## Variaveis Suportadas
As mensagens e templates podem usar variaveis internas da Maquina Comercial/GChat:
| Variavel | Valor esperado |
|---|---|
| `{{name}}` | Nome do contato quando existir; fallback para empresa |
| `{{empresa}}` | Nome da empresa |
| `{{businessName}}` | Nome da empresa no padrao GChat |
| `{{firstName}}` | Primeiro nome |
| `{{lastName}}` | Sobrenome, quando existir |
| `{{produto}}` | Produto recomendado/interesse |
| `{{protocol}}` | Assunto/protocolo; fallback para produto |
| `{{cidade}}` | Cidade |
| `{{uf}}` | UF |
| `{{segmento}}` | Nicho/segmento |
| `{{cnpj}}` | CNPJ |
| `{{email}}` | E-mail |
| `{{phoneNumber}}` | WhatsApp/telefone |
| `{{whatsapp}}` | WhatsApp/telefone |
| `{{kanban}}` | Produto/interesse |
| `{{user}}` | Remetente |
| `{{userEmail}}` | E-mail do remetente |
| `{{greeting}}` | Saudacao |
Tambem existem placeholders legados: `{empresa}`, `{cidade}`, `{produto}` e `{segmento}`.
## Exportacoes
| Tipo | Saida | Para que serve |
|---|---|---|
| Planilha | CSV + XLS | Analise comercial e tratamento externo |
| Z-PRO Oportunidades | JSONL | Payloads para criacao de oportunidades |
| Z-PRO Disparo em massa | CSV | `dataInput` para disparo em massa |
Leads exportados ficam marcados. Use o filtro de nao exportados para evitar retrabalho.
## Score e Priorizacao
O app calcula scores para ajudar o time comercial a decidir quem abordar primeiro.
Regras principais:
- WhatsApp/telefone valido aumenta a prioridade.
- CNPJ valido aumenta a confiabilidade.
- Empresa valida, cidade, nicho, CNAE, website e e-mail melhoram o score.
- Nichos com maior aderencia comercial recebem mais peso.
- Leads sem WhatsApp podem continuar cadastrados, mas aparecem com prioridade menor.
- Duplicidade segue a ordem CNPJ, telefone, empresa+cidade.
## Fontes de Captura
- Base CNPJ da Receita Federal: fonte principal para empresas ativas por UF.
- OpenStreetMap/Overpass: fonte aberta complementar.
- Nominatim: geocodificacao e apoio geografico.
- Wikidata: fonte aberta de baixo volume.
- BrasilAPI: enriquecimento de dados cadastrais.
- Google Places: fonte opcional paga, exige `GOOGLE_MAPS_API_KEY` no `.env`.
## Comandos
```bash
npm start
npm test
npm run capture
npm run extract
npm run recalc-scores
npm run outreach
npm run build:launcher
```
Exemplos:
```bash
node scripts/servidor.mjs
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
node scripts/extrair-base-cnpj.mjs --uf=SP
node scripts/enviar-outreach.mjs
```
## Estrutura
```text
GTI-Comercial.exe Launcher local do app
INICIAR-GTI.bat Inicializacao alternativa no Windows
scripts/ Entradas de linha de comando
src/ Codigo do servidor e regras de negocio
src/server/ HTTP, rotas, sessoes e autenticacao
src/capture/ Pipeline de captura
src/sources/ Fontes de dados
src/treatment/ Consultas e dashboard de tratamento
src/export/ Exportacoes Excel/CSV/Z-PRO
src/outreach/ Z-PRO, templates WABA e automacao WhatsApp
public/ Interface HTML/CSS/JS
tests/ Testes automatizados
data/gti.db Banco SQLite local, nao versionado
data/exports/ Arquivos exportados, nao versionado
prospeccao/cnpj/ Base CNPJ e dados brutos da Receita
docs/ Documentacao complementar
.env Segredos locais, nao versionado
```
## Requisitos
- Windows.
- Node.js 24 ou superior.
- Para extrair novas UFs, `unzip`, Git Bash ou `tar` nativo do Windows.
## Seguranca e Dados
- O banco principal fica em `data/gti.db`.
- Segredos locais devem ficar no `.env` ou nas configuracoes locais do app.
- Tokens Z-PRO/GChat nao devem ser commitados.
- A zona de risco em Configuracoes apaga leads, capturas e exportacoes; a acao e irreversivel.
- Nao ha backup automatico do banco local.
## Problemas Comuns
- Se a tela nao abrir, reinicie por `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
- Se preferir depurar, rode `node scripts/servidor.mjs` no terminal.
- Log do servidor: `data/server.log`.
- Se a sincronizacao de templates GChat falhar com erro de token, gere/salve um token GChat valido novamente.
- Se um template WABA falhar no Z-PRO, teste outro template aprovado e confira rota, idioma e variaveis obrigatorias.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -5,7 +5,10 @@ const Campanhas = (() => {
const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' };
const MAX_VARIANTES = 3; // A/B/C — mesmo limite usado na convencao do restante da UI/backend.
function addVarianteRow(codigoSugerido) {
if (variantesCount >= MAX_VARIANTES) return;
const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount);
variantesCount += 1;
el('campanha-variantes').append(
@@ -13,6 +16,11 @@ const Campanhas = (() => {
h('span', { class: 'chip' }, codigo),
h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' }))
);
atualizarBotaoAddVariante();
}
function atualizarBotaoAddVariante() {
el('btn-add-variante').disabled = variantesCount >= MAX_VARIANTES;
}
async function carregarProdutos() {
@@ -83,7 +91,14 @@ const Campanhas = (() => {
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
el('campanha-produto').addEventListener('change', (event) => {
carregarSegmentos(event.target.value).catch((error) => toast(`Erro: ${error.message}`));
// O modal de nova campanha esta aberto (<dialog> via showModal()) quando
// isto dispara — toast sozinho fica escondido atras do dialog (ver
// comentario em api.js), entao o erro tambem precisa aparecer no
// paragrafo inline #campanha-erro que ja existe pro erro de submit.
carregarSegmentos(event.target.value).catch((error) => {
toast(`Erro: ${error.message}`);
el('campanha-erro').textContent = `Erro: ${error.message}`;
});
});
el('btn-salvar-campanha').addEventListener('click', async () => {
@@ -150,15 +165,19 @@ const Campanhas = (() => {
const marcarBtn = h('button', {
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
onclick: async () => {
if (!stageSelect.value) return;
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
toast('Estagio atualizado');
if (stageSelect.value === 'demonstracao_agendada') {
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
if (dataAgendamento) {
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
toast('Agendamento registrado');
try {
if (!stageSelect.value) return;
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
toast('Estagio atualizado');
if (stageSelect.value === 'demonstracao_agendada') {
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
if (dataAgendamento) {
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
toast('Agendamento registrado');
}
}
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
}, 'Marcar');
@@ -16,6 +16,12 @@ function applyPause(db, campaignId, reason) {
// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta),
// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue
// (nao por um valor de origem dedicado).
//
// PURA/read-only: so calcula e retorna {pause, reason}, nunca escreve no
// banco. Quem precisa da mutacao (pausar de verdade quando pause=true) deve
// chamar applyCampaignHealth, nao esta funcao — assim uma rota GET (como
// /api/campaigns/:id/saude, chamada so de abrir o painel na UI) nunca pausa
// uma campanha como efeito colateral de uma leitura.
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null };
@@ -36,7 +42,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (failureCount / attemptsToday > 0.10) {
applyPause(db, campaignId, 'high_failure_rate');
return { pause: true, reason: 'high_failure_rate' };
}
}
@@ -54,7 +59,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
AND ls.stage IN ('sem_interesse', 'opt_out')`
).get(campaignId).total);
if (negativeCount / contacts > 0.10) {
applyPause(db, campaignId, 'high_negative_rate');
return { pause: true, reason: 'high_negative_rate' };
}
}
@@ -67,7 +71,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
).get(campaignId).total);
if (respondedCount / contacts < 0.05) {
applyPause(db, campaignId, 'low_reply_rate');
return { pause: true, reason: 'low_reply_rate' };
}
}
@@ -75,6 +78,17 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
return { pause: false, reason: null };
}
// MUTANTE: chama a avaliacao pura acima e, se ela mandar pausar, persiste a
// pausa de verdade via applyPause. E isto que o worker deve chamar apos cada
// envio real — e so o worker, nunca uma rota de leitura.
export function applyCampaignHealth(db, campaignId, now = new Date()) {
const resultado = evaluateCampaignHealth(db, campaignId, now);
if (resultado.pause) {
applyPause(db, campaignId, resultado.reason);
}
return resultado;
}
// Numero de leads distintos com resposta por variante, via subquery
// correlacionada (nao um JOIN direto) para nao contar duplicado quando um
// lead tem mais de um outreach_stage_events.
@@ -62,9 +62,14 @@ export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
// So avanca campanha que estiver de fato pausada — retomar uma campanha em
// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando').
export function resumeCampaign(db, campaignId) {
// Reseta semana_iniciada_em pra agora: uma campanha que ficou pausada (por
// saude ruim ou motivo manual) por varios dias nao pode, ao ser retomada,
// já satisfazer o "rodando ha 7+ dias" do escalateWeeklyLimits e dobrar o
// limite diario logo de cara — isso inverteria a logica de seguranca.
db.prepare(
`UPDATE outreach_campaigns SET status = 'rodando', pause_reason = NULL, updated_at = datetime('now')
WHERE id = ? AND status = 'pausado'`
`UPDATE outreach_campaigns
SET status = 'rodando', pause_reason = NULL, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND status = 'pausado'`
).run(campaignId);
return getCampaign(db, campaignId);
}
@@ -1,6 +1,28 @@
import { createZproClient } from './zpro-client.mjs';
import { isOptedOut } from './campaign-store.mjs';
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
import { applyCampaignHealth } from './campaign-metrics.mjs';
import { getSetting } from '../db.mjs';
// Mesmo intervalo aleatorio entre envios do run.mjs (INTERVALO_MIN_MS/
// INTERVALO_MAX_MS), duplicado aqui de proposito — os dois workers devem se
// comportar de forma consistente contra o mesmo numero de WhatsApp, mas sao
// modulos independentes (nao importar um do outro).
const SLEEP_MIN_MS = 3000;
const SLEEP_MAX_MS = 8000;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function randomSleepMs() {
return SLEEP_MIN_MS + Math.random() * (SLEEP_MAX_MS - SLEEP_MIN_MS);
}
// Mesma logica de comparacao de string HH:MM do run.mjs.
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
const hhmm = now.toTimeString().slice(0, 5);
return hhmm >= horaInicio && hhmm <= horaFim;
}
function todayAttempts(db, campaignId) {
return Number(db.prepare(
@@ -16,10 +38,26 @@ function todayAttempts(db, campaignId) {
// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca
// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem
// um estado intermediario "reservado" nem logica de recuperacao pos-crash.
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {} }) {
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {}, sleepMs = null }) {
const client = zproClient || createZproClient(db);
const summary = { enviados: 0, erros: 0, pulados: 0 };
// Mesmas duas travas de seguranca do disparo automatico (run.mjs):
// interruptor geral e janela de horario comercial. Sem elas, o worker de
// campanha mandaria mensagem 24h/dia mesmo com o operador tendo desligado
// o outreach nas Configuracoes.
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
log('campanhas: desativado (outreach.ativo=false)');
return summary;
}
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
if (!withinBusinessHours(horaInicio, horaFim, now)) {
log(`campanhas: fora do horario comercial (${horaInicio}-${horaFim})`);
return summary;
}
const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all();
for (const { id: campaignId } of campaigns) {
const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId);
@@ -77,7 +115,12 @@ export async function runCampaignQueue({ db, zproClient, now = new Date(), log =
).run(queueStatus, sendId, row.id);
attemptsToday += 1;
const health = evaluateCampaignHealth(db, campaignId, now);
// Jitter entre tentativas reais de envio (nao entre pulos/cancelamentos,
// que nao batem no Z-PRO) — mesmo padrao de run.mjs, pra nao parecer
// disparo automatizado agressivo pro mesmo numero de WhatsApp.
await sleep(sleepMs != null ? sleepMs : randomSleepMs());
const health = applyCampaignHealth(db, campaignId, now);
if (health.pause) {
log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`);
break;
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
function inserirLead(db, dedup) {
const inserir = createLeadInserter(db);
@@ -63,11 +63,41 @@ test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 1
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_failure_rate');
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(semMutacao.status, 'rodando');
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
const aplicado = applyCampaignHealth(db, campanha.id);
assert.equal(aplicado.pause, true);
assert.equal(aplicado.reason, 'high_failure_rate');
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 8; i += 1) {
const lead = inserirLead(db, `puro-ok${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
for (let i = 0; i < 2; i += 1) {
const lead = inserirLead(db, `puro-err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
for (let i = 0; i < 5; i += 1) {
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
}
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
db.close();
});
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
@@ -79,6 +79,23 @@ test('setCampaignStatus rodando does not reset already-set semana_iniciada_em',
db.close();
});
test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 }));
setCampaignStatus(db, campanha.id, 'rodando');
setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
// Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em
// backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento.
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id);
resumeCampaign(db, campanha.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente');
const atualizada = getCampaign(db, campanha.id);
assert.equal(atualizada.limite_diario_atual, 10);
db.close();
});
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
@@ -1,10 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
// horario real em que o teste roda.
function ativarWorker(db) {
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '00:00');
setSetting(db, 'outreach.horaFim', '23:59');
}
function inserirLead(db, dedup, whatsapp) {
const inserir = createLeadInserter(db);
inserir({
@@ -43,10 +53,11 @@ function fakeZproClient(overrides = {}) {
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
@@ -62,6 +73,7 @@ test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 2);
let chamada = 0;
const zproClient = fakeZproClient({
@@ -72,7 +84,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
}
});
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(resumo.erros, 1);
const erro = listQueue(db, campanha.id, 'error')[0];
@@ -83,6 +95,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
test('runCampaignQueue respeita o limite diario da campanha', async () => {
const db = openDb(':memory:');
ativarWorker(db);
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
@@ -93,7 +106,7 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
@@ -102,12 +115,13 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
addOptOut(db, '5511999990000', 'pediu pra parar');
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.pulados, 1);
assert.equal(zproClient.enviados.length, 0);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
@@ -117,6 +131,7 @@ test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depo
test('runCampaignQueue so processa campanhas com status rodando', async () => {
const db = openDb(':memory:');
ativarWorker(db);
inserirLead(db, 'l1', '5511999990001');
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
@@ -126,7 +141,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
// campanha continua em 'rascunho' — nunca foi iniciada
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
db.close();
@@ -134,6 +149,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 10);
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
let chamada = 0;
@@ -145,9 +161,41 @@ test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa',
}
});
await runCampaignQueue({ db, zproClient });
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
const db = openDb(':memory:');
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '08:00');
setSetting(db, 'outreach.horaFim', '18:00');
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
const foraDoHorario = new Date();
foraDoHorario.setHours(3, 0, 0, 0);
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});

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