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
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -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.