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,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
Reference in New Issue
Block a user