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.
- 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.
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.
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.
The real manual-send route (routes/outreach.mjs) always inserts with
produto='' and lead_id=NULL, so the old single NOT EXISTS predicate
(produto = ? AND (lead_id = leads.id OR numero = leads.whatsapp)) could
never match a real manual send's row -- the numero branch was unreachable
in production even though a prior test fabricated a produto value manual
sends never write. Split into two NOT EXISTS clauses: one scoped to
produto+lead_id (automatic/campaign sends), one scoped to numero+
origem='manual' with no produto requirement (manual sends). Updated the
existing regression test to insert the row exactly like the real route
does (produto: '', lead_id: NULL, origem: 'manual').
Marking a lead opt_out via /estagio only wrote outreach_lead_states/
outreach_stage_events and never called addOptOut, so the lead stayed fully
eligible for future campaign sends. Also normalize numero with onlyDigits
in POST/DELETE opt-out routes and in the estagio lookup, matching how every
other number in the app (leads.whatsapp, queue.numero) is stored, so opt-out
comparisons never silently miss due to formatting differences.
Relabel misleading "Empresa" header to "Lead ID" in fila table (queue
has no join to leads, cell showed lead_id). Wrap iniciar/pausar/retomar
and aprovar/cancelar selecionados handlers in try/catch with toast on
failure, matching btn-gerar-fila's existing pattern.
The new-campaign segment checkboxes reused .chip/.chips, which is
reserved codebase-wide for click-to-remove selected tags (adds a
trailing " x" and removable-tag hover styling). Give the segment
picker its own segmento-opcao/segmento-lista classes with a minimal
non-removable style instead.
generateCampaignQueue computed candidatosEncontrados after slicing to
quantidade, so it could never differ from adicionadosNaFila. An operator
generating a queue with quantidade smaller than the eligible pool saw no
signal that more eligible leads existed. Now candidatosEncontrados is read
before the slice, while adicionadosNaFila still reflects rows actually
inserted.
Previously, setCampaignStatus did not set semana_iniciada_em, causing
escalateWeeklyLimits to bail early (if (!campaign.semana_iniciada_em) continue).
Now when a campaign transitions to 'rodando' for the first time, semana_iniciada_em
is set to datetime('now'). Subsequent rodando transitions leave it unchanged
(using COALESCE to preserve first-run timestamp).
Regression tests confirm:
- First transition to 'rodando' stamps semana_iniciada_em
- Subsequent transitions do not reset the timestamp
- resumeCampaign continues to work correctly (pause/resume cycle)
- All 105 tests pass
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This work already existed uncommitted in the main working tree (not
authored by this branch) and is required as a runtime dependency for the
campaign outreach port (zpro-client, templates, outreach routes/UI). Copied
in as-is, untouched, so this feature branch has a working baseline; the
original uncommitted state in the main checkout is left exactly as it was.
App local (Node + SQLite) para captura, tratamento e exportacao de leads
comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO.
Co-Authored-By: Claude <noreply@anthropic.com>