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,98 @@
# Oracle Always Free Docker Deployment
## 1. Create VM
- Use Ubuntu Always Free.
- Prefer Ampere A1 if available.
- Keep boot/block volume within Always Free limits.
## 2. Keep the VM private first
- Do not open `80/tcp`, `443/tcp`, or public DNS yet.
- Restrict `22/tcp` to your IP.
- The initial account is `admin` / `admin`; change it before the panel is reachable from the internet.
## 3. Install Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
## 4. Configure app
```bash
cp .env.production.example .env.production
nano .env.production
cp Caddyfile.example Caddyfile
nano Caddyfile
```
For Telegram alerts:
- Create a bot with BotFather and copy the bot token into `TELEGRAM_BOT_TOKEN`.
- Send a message to the bot from the operator account or group.
- Get the destination `TELEGRAM_CHAT_ID` from the Telegram API or your preferred bot admin tool.
- Keep `TELEGRAM_ALERTS_ENABLED=true` only when both values are configured.
- To disable alerts safely, set `TELEGRAM_ALERTS_ENABLED=false` and leave token/chat fields blank.
- Never commit `.env.production` or share bot tokens, chat IDs, databases, logs, or WhatsApp auth data.
## 5. First startup on SSH-only access
- Keep OCI ingress for `80/tcp` and `443/tcp` closed for this first run.
- Do not start Caddy yet. Start only the app with a temporary localhost-only host port:
```bash
cat > docker-compose.local-first.yml <<'YAML'
services:
app:
ports:
- "127.0.0.1:3910:3910"
YAML
docker compose -f docker-compose.yml -f docker-compose.local-first.yml up -d --build app
docker compose ps
docker compose logs -f app
```
From your workstation, create an SSH tunnel and change the default password before exposing the stack:
```bash
ssh -L 3910:127.0.0.1:3910 ubuntu@YOUR_VM_PUBLIC_IP
```
- Open `http://127.0.0.1:3910` through the tunnel.
- Log in with `admin` / `admin`.
- Open **Configuracoes > Alterar minha senha** and set a strong unique password with at least 8 characters.
- Log out and log back in with the new password.
## 6. Expose HTTPS after password change
- Only after the admin password is changed, update `Caddyfile` to the public domain and point public DNS at the VM.
- Remove the temporary local-only override and start the public stack:
```bash
rm docker-compose.local-first.yml
docker compose up -d --build
```
- Only after the public stack is ready, open OCI ingress for `80/tcp` and `443/tcp` from the internet.
## 7. Smoke test safely
- Keep campaigns paused during deployment validation.
- Do not start real campaigns during smoke tests.
- Keep Google Places and Telegram tokens blank or fake unless intentionally testing those external services.
- Do not connect a real WhatsApp account or send real WhatsApp messages during smoke tests.
- Confirm health without sending:
```bash
curl -i http://127.0.0.1/api/health
docker compose restart app
docker compose ps
docker compose logs --tail=100 app
```
Expected: health is 200, the app container is healthy after restart, data remains in `/app/data`, and no outreach sends are created.
## 8. Backup
```bash
docker compose exec app npm run backup:data
```
## 9. QR recovery
When Telegram says QR is required, open the panel and reconnect WhatsApp.
@@ -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')`.
@@ -0,0 +1,782 @@
# Docker Cloud 24/7 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:** Run Maquina Comercial Pessoal 24/7 on Oracle Cloud Always Free using Docker Compose, persistent data, HTTPS, backups, and Telegram alerts when WhatsApp or campaign safety requires operator action.
**Architecture:** Add Docker-ready network binding and health endpoints to the app, package it in a Node 24 container, and orchestrate it with Caddy via Docker Compose. Persist all runtime state under `/app/data`, keep restart-safe campaign behavior, and add a focused Telegram alert module with deduplication.
**Tech Stack:** Node.js 24 ESM, SQLite, Baileys, Docker, Docker Compose, Caddy, Telegram Bot API over HTTPS, Node test runner.
## Global Constraints
- Do not remove the current safety behavior that pauses campaigns after server restart.
- Do not send real WhatsApp messages during tests.
- Do not commit `.env.production`, Telegram tokens, database files, WhatsApp auth files, or backups.
- Local default host remains `127.0.0.1`; Docker sets `APP_HOST=0.0.0.0`.
- Persist SQLite, WhatsApp auth, exports, and backups under the mounted `data` directory.
- Telegram alerts must be deduplicated with a cooldown per alert key.
- Telegram token must never be printed to logs.
- Docker healthcheck must use an endpoint that does not require login and does not expose secrets.
- Caddy handles public HTTP/HTTPS; the app container must not publish its internal port directly to the public internet.
- Oracle Cloud target is Always Free Ubuntu VM with Docker Compose.
---
## File Structure
- Modify `src/env.mjs`: add `APP_HOST`, Docker-safe runtime path envs, and Telegram env helpers.
- Modify `scripts/servidor.mjs`: bind to `APP_HOST`, initialize alerting, and notify restart/WhatsApp conditions.
- Modify `src/server/index.mjs`: expose a no-auth health endpoint.
- Create `src/alerts/telegram.mjs`: Telegram client, alert dedupe, and safe notification helpers.
- Create `tests/alerts.test.mjs`: unit tests for Telegram alert behavior and dedupe.
- Modify existing server/env tests: cover `APP_HOST` and health route.
- Create `Dockerfile`: production image using Node 24.
- Create `.dockerignore`: exclude data, auth, backups, test artifacts, and secrets.
- Create `docker-compose.yml`: app + Caddy with persistent volumes and healthcheck.
- Create `Caddyfile.example`: reverse proxy template for a real domain.
- Create `.env.production.example`: production environment template without secrets.
- Create `scripts/backup-data.mjs`: zip SQLite + WhatsApp auth backups with retention.
- Create `docs/deployment/oracle-free-docker.md`: operator deployment runbook.
---
### Task 1: Docker-Ready Environment and Health Endpoint
**Files:**
- Modify: `src/env.mjs`
- Modify: `scripts/servidor.mjs`
- Modify: `src/server/index.mjs`
- Modify/Test: `tests/server.test.mjs`
- Modify/Test: `tests/personal-branding.test.mjs`
**Interfaces:**
- Produces: `APP_HOST: string` exported from `src/env.mjs`
- Produces: unauthenticated `GET /api/health`
- Consumes: existing `APP_PORT`, `paths`, `loadDotEnv`, `createAppServer`
- [ ] **Step 1: Write failing env test for host binding**
Add a test that imports `src/env.mjs` with `process.env.APP_HOST='0.0.0.0'` and asserts the exported value is `0.0.0.0`. Clear the env afterward.
```js
test('env exposes APP_HOST with local-safe fallback', async () => {
const previous = process.env.APP_HOST;
process.env.APP_HOST = '0.0.0.0';
const env = await import(`../src/env.mjs?host-test=${Date.now()}`);
assert.equal(env.APP_HOST, '0.0.0.0');
if (previous === undefined) delete process.env.APP_HOST; else process.env.APP_HOST = previous;
});
```
- [ ] **Step 2: Write failing health route test**
In `tests/server.test.mjs`, create an app server with fake dependencies and assert `GET /api/health` returns 200 without authentication.
```js
test('health endpoint is public and does not expose secrets', async () => {
const { server, baseUrl, close } = await startTestServer();
try {
const res = await fetch(`${baseUrl}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.ok(body.app);
assert.ok(body.database);
assert.ok(body.whatsapp);
assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i);
} finally {
await close(server);
}
});
```
- [ ] **Step 3: Run RED tests**
Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs`
Expected: FAIL because `APP_HOST` and `/api/health` do not exist yet.
- [ ] **Step 4: Implement `APP_HOST`**
In `src/env.mjs`, add:
```js
export let APP_HOST = envString('APP_HOST', '127.0.0.1');
```
Update `refreshEnvDerivedValues()`:
```js
APP_HOST = envString('APP_HOST', '127.0.0.1');
```
- [ ] **Step 5: Bind server to `APP_HOST`**
In `scripts/servidor.mjs`, import `APP_HOST` and replace:
```js
server.listen(port, '127.0.0.1', () => {
```
with:
```js
server.listen(port, APP_HOST, () => {
```
Update logs so local and Docker hosts are explicit:
```js
log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`);
if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.');
```
- [ ] **Step 6: Implement public health endpoint**
In `src/server/index.mjs`, add a route before auth-protected application routes:
```js
router.add('GET', '/api/health', async (req, res, { json }) => {
json(200, {
ok: true,
app: 'maquina-comercial-pessoal',
database: 'open',
whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN'
});
});
```
If the router context does not expose `whatsapp` there, wire it from the existing `createAppServer({ db, jobs, whatsapp, log })` closure.
- [ ] **Step 7: Run GREEN tests**
Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs`
Expected: PASS.
- [ ] **Step 8: Run focused syntax check**
Run: `node --check src/env.mjs` and `node --check scripts/servidor.mjs`
Expected: PASS.
---
### Task 2: Telegram Alert Module
**Files:**
- Create: `src/alerts/telegram.mjs`
- Create/Test: `tests/alerts.test.mjs`
- Modify: `src/env.mjs`
- Modify: `.env.example`
**Interfaces:**
- Produces: `createTelegramAlerter({ token, chatId, enabled, fetchImpl, now, cooldownMs, log })`
- Produces: `alerter.alert(key, text): Promise<{ sent: boolean, reason?: string }>`
- Consumes: Telegram Bot API `sendMessage`
- [ ] **Step 1: Write failing tests for disabled, missing config, send success, and dedupe**
Create `tests/alerts.test.mjs`:
```js
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTelegramAlerter } from '../src/alerts/telegram.mjs';
test('telegram alert skips when disabled or missing config', async () => {
const calls = [];
const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' });
assert.equal(calls.length, 0);
});
test('telegram alert sends message without leaking token to text', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl });
assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true });
assert.equal(calls.length, 1);
assert.match(calls[0].url, /sendMessage$/);
const payload = JSON.parse(calls[0].options.body);
assert.equal(payload.chat_id, '456');
assert.equal(payload.text, 'WhatsApp precisa de QR');
assert.doesNotMatch(payload.text, /SECRET/);
});
test('telegram alert deduplicates per key within cooldown', async () => {
let current = 1000;
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 });
assert.equal((await alerter.alert('same', 'A')).sent, true);
assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' });
current = 2501;
assert.equal((await alerter.alert('same', 'A later')).sent, true);
assert.equal(calls.length, 2);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/alerts.test.mjs`
Expected: FAIL because module does not exist.
- [ ] **Step 3: Implement Telegram alerter**
Create `src/alerts/telegram.mjs`:
```js
export function createTelegramAlerter({
token,
chatId,
enabled = false,
fetchImpl = fetch,
now = () => Date.now(),
cooldownMs = 15 * 60 * 1000,
log = () => {}
} = {}) {
const sentAt = new Map();
const configured = Boolean(token && chatId);
async function alert(key, text) {
if (!enabled) return { sent: false, reason: 'disabled' };
if (!configured) return { sent: false, reason: 'not_configured' };
const last = sentAt.get(key) || 0;
if (now() - last < cooldownMs) return { sent: false, reason: 'cooldown' };
const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text })
});
if (!response.ok) {
log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`);
return { sent: false, reason: 'request_failed' };
}
sentAt.set(key, now());
return { sent: true };
}
return { alert };
}
```
- [ ] **Step 4: Add env values**
In `src/env.mjs`, export:
```js
export const telegram = {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
```
After `loadDotEnv()` refresh, this object must reflect `.env`; if constants are initialized too early, use a function instead:
```js
export function telegramConfig() {
return {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
}
```
Prefer the function if tests expose stale values.
- [ ] **Step 5: Document env placeholders**
Append to `.env.example`:
```dotenv
TELEGRAM_ALERTS_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
```
- [ ] **Step 6: Run GREEN tests**
Run: `node --test tests/alerts.test.mjs`
Expected: PASS.
---
### Task 3: Wire Operational Alerts Into Server Lifecycle
**Files:**
- Modify: `scripts/servidor.mjs`
- Modify/Test: `tests/server.test.mjs` or create `tests/server-alerts.test.mjs`
**Interfaces:**
- Consumes: `createTelegramAlerter(...)`
- Consumes: `telegramConfig()` from `src/env.mjs`
- Produces: startup alerts for restart pause and WhatsApp reconnect failures
- [ ] **Step 1: Write failing server alert tests with fake alerter**
Create a small exported helper if needed:
```js
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Revise antes de reiniciar.');
if (whatsappStatus === 'QR_REQUIRED') await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
```
Test:
```js
test('startup alerts notify restart pause and QR requirement once', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyStartupState({ alerter, whatsappStatus: 'QR_REQUIRED', campaignsPausedByRestart: true });
assert.deepEqual(alerts.map((a) => a.key), ['campaigns-paused-restart', 'whatsapp-qr']);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/server-alerts.test.mjs`
Expected: FAIL because helper/wiring does not exist.
- [ ] **Step 3: Implement alert helper**
Create `src/alerts/runtime-alerts.mjs` if keeping `scripts/servidor.mjs` slim:
```js
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) {
await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.');
}
if (whatsappStatus === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.');
}
}
export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) {
if (previousState === nextState) return;
if (previousState === 'READY' && nextState !== 'READY') {
await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`);
}
if (nextState === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
}
```
- [ ] **Step 4: Wire startup alert in `scripts/servidor.mjs`**
After `pauseCampaignsAfterRestart(db)`, determine whether any campaign has `pause_reason='server_restart'`:
```js
const campaignsPausedByRestart = Boolean(db.prepare(
"SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1"
).get());
```
Create alerter:
```js
const tg = telegramConfig();
const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log });
```
After WhatsApp manager exists, call:
```js
await notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart });
```
- [ ] **Step 5: Alert on initial reconnect failure**
Change the existing reconnect catch:
```js
whatsapp.connect().catch(async (error) => {
log(`whatsapp: reconexao inicial falhou: ${error.message}`);
await alerter.alert('whatsapp-initial-reconnect-failed', 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.');
});
```
- [ ] **Step 6: Run focused tests**
Run: `node --test tests/alerts.test.mjs tests/server-alerts.test.mjs`
Expected: PASS.
---
### Task 4: Docker and Compose Packaging
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
- Create: `docker-compose.yml`
- Create: `Caddyfile.example`
- Create: `.env.production.example`
- Modify/Test: `tests/personal-branding.test.mjs`
**Interfaces:**
- Consumes: `APP_HOST`, `APP_PORT`, `APP_DB_PATH`, `APP_WHATSAPP_AUTH_DIR`
- Produces: Docker image runnable by `docker compose up -d --build`
- [ ] **Step 1: Write failing file existence/config test**
Add to `tests/personal-branding.test.mjs`:
```js
test('docker deployment files preserve persistent data and do not include secrets', async () => {
const dockerfile = await read('Dockerfile');
const compose = await read('docker-compose.yml');
const envExample = await read('.env.production.example');
const caddy = await read('Caddyfile.example');
assert.match(dockerfile, /FROM node:24/);
assert.match(compose, /restart: unless-stopped/);
assert.match(compose, /APP_HOST=0\.0\.0\.0/);
assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/);
assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/);
assert.match(caddy, /reverse_proxy app:3910/);
assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/personal-branding.test.mjs`
Expected: FAIL because files do not exist.
- [ ] **Step 3: Create Dockerfile**
```dockerfile
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY scripts ./scripts
COPY public ./public
COPY prospeccao/config.json ./prospeccao/config.json
RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups
EXPOSE 3910
CMD ["npm", "start"]
```
- [ ] **Step 4: Create `.dockerignore`**
```gitignore
data
node_modules
test-results
playwright-report
.env
.env.production
*.log
*.sqlite
*.db
.git
```
- [ ] **Step 5: Create `docker-compose.yml`**
```yaml
services:
app:
build: .
restart: unless-stopped
env_file:
- .env.production
environment:
APP_HOST: 0.0.0.0
APP_PORT: 3910
APP_DB_PATH: /app/data/maquina-pessoal.db
APP_WHATSAPP_AUTH_DIR: /app/data/runtime/whatsapp-auth
CNPJ_BASE_DIR: /app/prospeccao/cnpj
volumes:
- ./data:/app/data
expose:
- "3910"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
- [ ] **Step 6: Create `Caddyfile.example`**
```caddyfile
campanhas.seudominio.com {
encode gzip
reverse_proxy app:3910
}
```
- [ ] **Step 7: Create `.env.production.example`**
```dotenv
APP_RESET_PASSWORD=troque-esta-senha
GOOGLE_PLACES_API_KEY=
PERSONAL_COMMERCIAL_WHATSAPP=
TELEGRAM_ALERTS_ENABLED=true
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
```
- [ ] **Step 8: Run GREEN tests**
Run: `node --test tests/personal-branding.test.mjs`
Expected: PASS.
- [ ] **Step 9: Build Docker image**
Run: `docker compose build app`
Expected: image builds successfully.
---
### Task 5: Backup Script With Retention
**Files:**
- Create: `scripts/backup-data.mjs`
- Create/Test: `tests/backup-data.test.mjs`
- Modify: `package.json`
**Interfaces:**
- Produces: CLI `node scripts/backup-data.mjs`
- Produces: npm script `backup:data`
- Consumes: `paths.data`, `paths.db`, `paths.whatsappAuth`
- [ ] **Step 1: Write failing backup tests**
Create test that uses a temp directory, sample DB file, sample auth file, and asserts a backup file is created and old backups are removed.
```js
test('backup creates archive marker and keeps configured retention', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-'));
const dataDir = path.join(tmp, 'data');
await fs.mkdir(path.join(dataDir, 'runtime', 'whatsapp-auth'), { recursive: true });
await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db');
await fs.writeFile(path.join(dataDir, 'runtime', 'whatsapp-auth', 'session.json'), 'auth');
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
assert.match(result.file, /maquina-pessoal-2026-08-03/);
await fs.access(result.file);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/backup-data.test.mjs`
Expected: FAIL because backup module does not exist.
- [ ] **Step 3: Implement backup helper**
Because the project has no zip dependency, implement a safe tar/gzip through Node `child_process` only on Linux containers, or write a plain `.backup` directory copy. Prefer no new production dependency.
Export:
```js
export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) {
const stamp = now().toISOString().slice(0, 10);
const backupsDir = path.join(dataDir, 'backups');
const target = path.join(backupsDir, `maquina-pessoal-${stamp}`);
await fs.mkdir(target, { recursive: true });
await fs.copyFile(path.join(dataDir, 'maquina-pessoal.db'), path.join(target, 'maquina-pessoal.db'));
await cp(path.join(dataDir, 'runtime', 'whatsapp-auth'), path.join(target, 'whatsapp-auth'), { recursive: true });
await pruneBackups(backupsDir, retention);
return { file: target };
}
```
Use directory backups rather than zip if avoiding dependencies. Document this in the runbook.
- [ ] **Step 4: Add CLI guard**
At bottom of `scripts/backup-data.mjs`:
```js
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => {
console.error(`backup falhou: ${error.message}`);
process.exit(1);
});
}
```
- [ ] **Step 5: Add npm script**
In `package.json` scripts:
```json
"backup:data": "node scripts/backup-data.mjs"
```
- [ ] **Step 6: Run GREEN tests**
Run: `node --test tests/backup-data.test.mjs`
Expected: PASS.
---
### Task 6: Deployment Runbook and Final Verification
**Files:**
- Create: `docs/deployment/oracle-free-docker.md`
- Modify: `README.md`
**Interfaces:**
- Consumes: Docker/Compose files and Telegram envs from earlier tasks
- Produces: operator runbook for Oracle Always Free deployment
- [ ] **Step 1: Write runbook**
Create `docs/deployment/oracle-free-docker.md` with exact steps:
```markdown
# Oracle Always Free Docker Deployment
## 1. Create VM
- Use Ubuntu Always Free.
- Prefer Ampere A1 if available.
- Keep boot/block volume within Always Free limits.
## 2. Open ports
- OCI ingress: 80/tcp and 443/tcp from internet.
- Restrict 22/tcp to your IP.
## 3. Install Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
## 4. Configure app
```bash
cp .env.production.example .env.production
nano .env.production
cp Caddyfile.example Caddyfile
nano Caddyfile
```
## 5. Start
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f app
```
## 6. Backup
```bash
docker compose exec app npm run backup:data
```
## 7. QR recovery
When Telegram says QR is required, open the panel and reconnect WhatsApp.
```
```
- [ ] **Step 2: Link runbook from README**
Add a short section to `README.md`:
```markdown
## Deploy 24/7 sem custo
Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram.
```
- [ ] **Step 3: Run final automated checks**
Run:
```powershell
npm.cmd test
npm.cmd run test:browser
npm.cmd audit --omit=dev
docker compose config
docker compose build app
```
Expected:
- Node tests pass.
- Browser tests pass or any existing unrelated flake is documented.
- Audit reports 0 production vulnerabilities.
- Compose config validates.
- Docker image builds.
- [ ] **Step 4: Manual non-sending smoke test**
Run:
```powershell
docker compose up -d --build
Invoke-WebRequest http://127.0.0.1/api/health -UseBasicParsing
docker compose restart app
docker compose logs --tail=100 app
```
Expected:
- Health returns 200 through Caddy if locally mapped.
- Data directory remains present after restart.
- Campaigns pause after restart.
- Telegram alert is sent for restart pause if enabled.
- No real WhatsApp message is sent.
---
## Self-Review
- Spec coverage: Oracle Free, Docker Compose, Caddy HTTPS, persistent volumes, Telegram alerts, restart safety, backups, and verification are each assigned to tasks.
- Placeholder scan: no TBD/TODO placeholders remain; every task has concrete files, commands, and expected outcomes.
- Interface consistency: `APP_HOST`, `/api/health`, `createTelegramAlerter`, `notifyStartupState`, and `backup:data` are defined before later tasks consume them.
@@ -0,0 +1,320 @@
# Commercial Outreach Pilot Design
## Goal
Create a supervised WhatsApp prospecting operation for Gustavo, commercial representative of Grupo GTI, focused on qualified conversations and demonstrations for GTI Vendas and G-Delivery without treating volume as the primary objective.
The pilot must preserve the operator's control, identify replies before any further automation, honor opt-outs, avoid unverified numbers, and produce enough evidence to improve targeting and copy.
## Current Context
- The lead base contains 2,680 companies in Santo Antonio de Jesus, Bahia.
- The current database groups 2,066 leads under ERP or management interest and 614 under WhatsApp self-service interest.
- The current automation is disabled.
- The current generic template, `Meu servico`, does not represent either approved product.
- The existing `5-12` second interval is not suitable for this pilot and must not be reused.
- The WhatsApp number is more than two years old, has normal personal-use history, has never been restricted, and will become exclusively commercial.
- The number will remain on regular WhatsApp during the pilot. A future migration to WhatsApp Business or an official integration remains an operational option.
- Gustavo is authorized to use his number and prospect for both products.
- Grupo GTI handles demonstrations, implementation, support, and closing after qualification.
## Commercial Identity
- Sender: `Gustavo - Representante Comercial do Grupo GTI`.
- Company: `Grupo GTI`.
- ERP product: `GTI Vendas`.
- Online-order product: `G-Delivery`.
- Authorized social proof: Grupo GTI has more than seven years of operation and more than 100 clients in Santo Antonio de Jesus and the surrounding region.
- Prices, implementation fees, links, and detailed conditions must not appear in the first message.
- The message must not promise unavailable functionality, unapproved integrations or customizations, technical deadlines, or guaranteed commercial results.
## Product Positioning
### GTI Vendas
GTI Vendas centralizes sales, inventory, invoicing, tabs or orders, finance, and administrative processes. It is sold through a personalized presentation because configuration depends on the client's operation.
Primary segments:
- Restaurants.
- Markets, grocery stores, and minimarkets.
- Bakeries and convenience stores.
- Clothing stores.
- Building-material stores.
- Auto-parts stores and mechanical workshops.
- Other commerce requiring integrated management.
Commercial path:
1. Ask one segment-specific operational question.
2. Continue by text if the lead replies.
3. Identify interest without performing a complete technical diagnosis.
4. Collect participant name, company, preferred day, and preferred time.
5. Hand the scheduled demonstration to the Grupo GTI team in the office.
### G-Delivery
G-Delivery gives a food business its own order link, with a fixed monthly fee and no fee per order. The client may self-register and test the product for seven days or request a demonstration.
Primary segments:
- Hamburger restaurants.
- Pizzerias.
- Acai shops.
- Meal-prep and lunch-box businesses.
- Snack bars.
- Other businesses clearly focused on meal delivery.
Commercial path:
1. Ask whether the business has its own order channel or depends on commission-charging applications.
2. Continue by text if the lead replies.
3. Offer either a seven-day self-service test or a demonstration.
4. For a demonstration, collect participant name, company, preferred day, and preferred time.
5. Hand the scheduled demonstration to the Grupo GTI team in the office.
## Campaign Priority
Campaigns run sequentially so that early evidence can improve later campaigns:
1. G-Delivery for hamburger restaurants, pizzerias, acai shops, meal-prep businesses, and snack bars.
2. GTI Vendas for restaurants, markets, grocery stores, minimarkets, bakeries, and convenience stores.
3. GTI Vendas for clothing, building materials, auto parts, mechanical workshops, and remaining eligible commerce.
Overlapping food businesses receive the first offer selected by this order. A second product is introduced only when the conversation reveals a relevant need; the pilot does not automatically send a second product message.
## Lead Eligibility
A lead may enter a daily queue only when all conditions are true:
- It belongs to the campaign's approved segments.
- It has never received an initial campaign message for that product.
- Its latest equivalent-number verification state is `confirmado`.
- It is not opted out under either Brazilian ninth-digit variant.
- It has not replied to a previous commercial message.
- It has no pending, accepted, sent, delivered, read, or unknown campaign send for the same product.
- It has not been manually removed from the daily preview.
Phone verification is gradual. Per operating day, the system may check at most `ceil(1.5 * the approved daily send limit)` previously unverified numbers. It never scans the entire lead base in one operation. If that cap does not produce enough confirmed numbers, the daily queue remains below its send limit.
No initial customer-exclusion list is required. Existing clients may be contacted, but any reply immediately removes them from automation and allows manual classification.
## Personalization
- Use the trade name only when present and natural.
- Do not use long legal names, CNPJ values, or business names dominated by numbers.
- Without a reliable trade name, begin with `Ola, tudo bem?`.
- Do not claim that the business uses delivery, marketplaces, tabs, invoicing, or any specific process. Ask instead.
- Do not mention the RFB source or imply prior personal knowledge.
- Message variants are fixed and auditable. No generative AI writes outbound messages during execution.
- Rendering may replace the opening greeting with `Ola, pessoal da {nome_fantasia}, tudo bem?` when the trade name passes the natural-name rule. Otherwise it preserves the generic greeting. The rest of the approved variant remains unchanged.
Approved `foco_gestao` mappings:
- Restaurants: `vendas, estoque, comandas e emissao fiscal`.
- Markets and grocery commerce: `vendas, estoque e emissao fiscal`.
- Mechanical workshops: `ordens de servico, estoque e financeiro`.
- Other commerce: `vendas, estoque, financeiro e notas fiscais`.
## Approved Initial Messages
Every message must include the simple refusal sentence. Diacritics may be used in the final UI text; the examples below define meaning and structure.
### GTI Vendas - Variant A
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
### GTI Vendas - Variant B
> Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
### GTI Vendas - Variant C
> Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
### G-Delivery - Variant A
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
### G-Delivery - Variant B
> Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
### G-Delivery - Variant C
> Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.
The queue records the exact variant and fully rendered text before approval. Retries never choose a different variant.
## Cadence
The pilot has no follow-up. Each eligible lead receives at most one initial message for the selected product.
Operating windows:
- Monday through Friday, `09:30-11:30` and `14:00-17:00` local time.
- No weekend sends.
- Public holidays are handled by the supervisor pausing or not approving that day's queue; automatic holiday-calendar integration is outside the pilot.
Daily progression:
- Week 1: up to 10 messages per operating day.
- Week 2: up to 12 messages per operating day.
- Week 3: up to 15 messages per operating day.
- Week 4: up to 18 messages per operating day.
The supervised campaign quota and the application's manual-send quota are separate. A manual test or individual send does not reduce the automatic campaign balance, and the UI displays both totals independently.
Timing rules:
- Random interval from 12 to 25 minutes between attempts.
- At most four send attempts in any rolling hour.
- Sends are distributed across both windows rather than exhausting the quota at the start of the day.
- A weekly increase is never automatic. Gustavo reviews metrics and explicitly approves the next limit.
- The number's age, daily volume, and delay do not guarantee protection against restriction.
## Supervised Daily Operation
1. The application selects never-contacted leads from the active campaign.
2. It gradually verifies unverified phone numbers up to the daily `1.5 * send limit` cap.
3. It removes unregistered numbers, opt-outs, replied leads, duplicates, and prior sends.
4. It creates a daily preview containing business display name, phone, segment, product, variant, rendered message, scheduled window, and selection reason.
5. Gustavo approves the queue, removes individual entries, or pauses the campaign.
6. Approved entries receive immutable scheduled timestamps distributed under the cadence rules.
7. The worker reserves each send atomically immediately before provider use.
8. Replies, opt-outs, connection failures, and uncertain sends take precedence over scheduled work.
9. The end-of-day summary records outcomes and unresolved items.
Automation starts disabled after every server restart. Starting or resuming a day requires explicit operator action during the pilot.
## Reply Handling
Inbound detection uses a hybrid model:
- Any inbound message immediately pauses that lead and removes scheduled work.
- The application places the conversation in `Respostas pendentes`.
- It does not generate or send an automatic commercial reply.
- Gustavo reads the conversation on the phone and manually classifies it.
Pipeline stages:
- `fila_preparada`
- `aprovado_para_envio`
- `enviado`
- `respondeu`
- `interessado_gti_vendas`
- `interessado_g_delivery`
- `demonstracao_agendada`
- `teste_g_delivery_iniciado`
- `sem_interesse`
- `opt_out`
- `venda_fechada`
Explicit requests to stop are acknowledged respectfully and added to permanent opt-out. Ambiguous replies only pause the lead for manual review.
## Manual Conversation Guidance
### Positive GTI Vendas Reply
Continue by text, acknowledge the response, ask at most one brief question if necessary, and offer a personalized presentation. Once accepted, collect participant name, company, preferred day, and preferred time.
### Positive G-Delivery Reply
Continue by text, confirm how orders are received, and offer either the seven-day self-registration test or a demonstration. For a demonstration, collect participant name, company, preferred day, and preferred time.
### No Interest
Thank the person, confirm that no further contact will be made, and classify the conversation as `sem_interesse` plus permanent opt-out when the reply requests no more contact.
## Automatic Pauses
Pause the campaign when any condition occurs:
- WhatsApp disconnects, enters an error state, or loses its session.
- A send has an unknown outcome.
- Daily provider/send failures exceed 10 percent after at least 10 attempts.
- Negative or opt-out responses exceed 10 percent after at least 20 contacts.
- Overall response remains below 5 percent after at least 50 contacts.
- The operator presses the immediate-pause control.
An automatic pause never resumes itself. Gustavo reviews the reason and explicitly resumes or ends the campaign.
## Metrics
Metrics are segmented by campaign, product, segment, and exact message variant:
- Previewed, approved, removed, and attempted messages.
- Accepted, server-acknowledged, delivered, read, unknown, and failed sends.
- Replies and time to first reply.
- Positive replies.
- No-interest replies and opt-outs.
- GTI Vendas demonstrations scheduled.
- G-Delivery demonstrations and seven-day tests started.
- Sales closed.
Derived rates:
- Reply rate.
- Positive-reply rate.
- Reply-to-demonstration or test conversion.
- Demonstration/test-to-sale conversion.
- Opt-out/negative rate.
- Delivery failure and unknown-outcome rates.
The dashboard may display small samples but must not declare a winning variant until that exact product/segment/variant group has at least 40 sends. The first month is a learning pilot; no artificial sales target is imposed.
## Data Model Boundaries
The implementation should add focused records for:
- Campaign definitions and current approved weekly limit.
- Fixed campaign message variants.
- Daily queue items with immutable rendered message and scheduled timestamp.
- Campaign status and pause reason.
- Inbound reply detection and first-reply timestamp.
- Manual pipeline stage and stage-event history.
- Demonstration/test scheduling notes.
Existing lead source data, WhatsApp verification cache, opt-outs, provider delivery history, and message ACK timestamps remain authoritative in their current tables. The campaign layer references them rather than duplicating their meaning.
## Reliability And Error Handling
- Queue creation and send reservation are transactional.
- A lead cannot have two active queue entries for the same campaign/product.
- A scheduled entry rechecks reply, opt-out, verification, connection, daily limit, hourly limit, and campaign status immediately before sending.
- Unknown delivery remains terminal and is never retried automatically.
- Server restart recovers in-progress queue items without duplicating sends.
- An inbound message that races with a scheduled send wins when detected before provider invocation.
- A connection or database error produces a visible pause reason and operator action, not a silent retry loop.
- The application stores no generated free-form outbound copy beyond the approved rendered message.
## Compliance And Risk
- Every first message identifies Gustavo and Grupo GTI.
- Every first message includes a simple refusal mechanism.
- Equivalent Brazilian phone variants share opt-out identity.
- Contact history is retained only as needed for deduplication, audit, and commercial metrics.
- No message includes source-database details or unnecessary personal data.
- Cold outreach and device-linked automation retain restriction and policy risk. Volume controls reduce operational exposure but cannot eliminate it.
- A migration to WhatsApp Business or an official messaging integration may be evaluated after pilot evidence.
## Testing And Acceptance
Automated tests must cover:
- Product/segment campaign selection and overlap priority.
- Natural trade-name fallback and safe message rendering.
- All six fixed message variants and prohibited-content checks.
- Gradual verification without full-base scanning.
- Daily progression, two windows, 12-25 minute delays, and rolling four-per-hour limit.
- Preview approval, item removal, immutable variants, and explicit weekly increase.
- Atomic reservation and duplicate prevention.
- Reply race, immediate lead pause, and pending-response queue.
- Permanent equivalent-number opt-out.
- Disconnect, session error, unknown outcome, failure-rate, negative-rate, low-response, and manual pauses.
- Restart recovery without resend.
- Pipeline stages, stage history, demonstration/test notes, and metrics.
- No follow-up generation during the pilot.
- Browser-level supervised daily flow on desktop and mobile.
No automated or browser test sends a real message unless Gustavo explicitly authorizes that individual recipient and test.
@@ -0,0 +1,100 @@
# WhatsApp Verification and Manual Send Design
## Goal
Make the visible manual phone field the authoritative send destination and distinguish a syntactically valid telephone from a number confirmed by WhatsApp.
## Constraints
- Work only in `MAQUINA COMERCIAL PESSOAL`.
- Keep the RFB telephone value as source data; do not claim it is a WhatsApp account without a real lookup.
- Verify numbers on demand. Do not scan the full RFB lead base.
- Automatic outreach may use only numbers with a persisted successful verification.
- Brazilian mobile variants with and without the ninth digit represent the same opt-out identity.
- A send timeout is an unknown delivery outcome, not proof of failure.
## Data Model
Add a `whatsapp_number_verifications` cache keyed by the normalized requested phone number. Each record stores:
- `numero`: digits submitted for verification.
- `status`: `confirmado`, `nao_cadastrado`, or `erro_temporario`.
- `numero_resolvido`: number returned in the registered WhatsApp JID, when confirmed.
- `variante`: `exata`, `sem_nono_digito`, or `com_nono_digito`, when confirmed.
- `erro`: temporary lookup error text, otherwise empty.
- `checked_at`: lookup timestamp.
No row for any equivalent variant means `nao_consultado`. Lead queries inspect all equivalent variants and use the most recent cached result. This sparse cache avoids updating every lead and allows repeated or equivalent lead phone values to reuse evidence.
`telefone_valido` keeps its existing meaning: the value has a valid Brazilian DDI, DDD, and phone shape. It must not be renamed in storage or treated as WhatsApp verification.
## Number Identity
Move Brazilian variant generation to a provider-independent phone utility shared by WhatsApp resolution and opt-out persistence. For a valid Brazilian mobile number, the utility returns the exact number first and then the equivalent with or without the ninth digit.
Opt-out lookup checks all equivalent variants. Adding an opt-out removes conflicting equivalent records and stores one normalized representation. Removing an opt-out removes all equivalent records. This closes both route-time and queue-time bypasses.
## Manual Send Flow
1. The browser submits `numero`, `mensagem`, and optional `leadId`.
2. The server validates that the optional lead exists, but uses it only for template context, product metadata, and send history linkage.
3. The server always validates and sends to `body.numero`. It never substitutes `lead.whatsapp`.
4. WhatsApp resolution checks the exact number and its Brazilian equivalent under a bounded timeout.
5. A successful lookup or definite non-registration updates the verification cache.
6. If the submitted number is equivalent to the selected lead's stored phone, the cached result describes that lead phone. An unrelated edited destination remains a separate phone-level result and does not relabel the stored lead phone.
7. The send reservation rechecks opt-out and daily limit inside the serialized send queue.
8. A successful `sendMessage` returns the provider message ID and resolved destination for normal ACK tracking.
The visible destination is therefore authoritative even while the selected lead continues to provide variables such as company and city.
## Automatic Outreach Flow
Automatic candidate selection matches each stored lead number against all equivalent cached variants and includes it only when the most recent matching state is `confirmado`. Syntactic `telefone_valido = 1` remains required but is not sufficient.
Numbers in `nao_consultado`, `nao_cadastrado`, or `erro_temporario` are skipped. The batch does not perform implicit bulk verification.
## Timeouts and Errors
- `onWhatsApp` has a configurable bounded timeout. Timeout produces `erro_temporario`, HTTP 504 for an explicit check, and no definitive registration claim.
- `sendMessage` has a separate configurable bounded timeout.
- When `sendMessage` times out after the send reservation, the local send becomes `desconhecido`. The API returns an accepted-style warning that the message may have been sent and instructs the operator not to resend without checking the phone.
- A definite non-registration returns HTTP 422 and stores `nao_cadastrado`.
- Other pre-send provider failures remain errors and are persisted through the existing send finalization path.
The underlying provider promise is observed after a timeout to avoid unhandled rejection, but a late result does not trigger an automatic retry.
## User Interface
- Replace labels that infer WhatsApp from `telefone_valido` with `Telefone valido`, `Com telefone`, or `Sem telefone`.
- Show a separate WhatsApp state in lead details: `Nao consultado`, `Confirmado`, `Nao cadastrado`, or `Erro temporario`.
- The treatment table phone column is labeled `Telefone`.
- The search placeholder refers to telephone rather than WhatsApp.
- Selecting a lead fills context and its phone, but later edits remain authoritative.
- Add a visible action to clear selected lead context without clearing the typed destination or message.
- While the settings dialog is open, connection polling continues in every state. Transitional states poll faster than stable states.
## API Behavior
`POST /api/whatsapp/check-number` accepts `numero` and optional `leadId`. It returns the persisted state, resolved number, variant, and timestamp. A lead ID is validation/context only; the checked number always comes from `numero`.
Lead list and detail responses expose a derived `whatsapp_status`, `whatsapp_numero_resolvido`, and `whatsapp_checked_at` from the cache for the lead's stored phone.
Existing outreach status and ACK APIs remain compatible.
## Testing
Automated regressions must prove:
- An edited manual number wins over the selected lead's stored phone.
- The selected lead still supplies template variables and `lead_id` history.
- Missing or invalid leads and destinations retain explicit errors.
- Verification persists confirmed, non-registered, and temporary-error states.
- An unrelated edited destination does not alter the selected lead's derived status.
- Automatic outreach selects only confirmed numbers.
- Either Brazilian variant triggers the same opt-out in manual and queued sends.
- Lookup timeout returns a temporary result without sending.
- Send timeout records an unknown outcome and warns against retry.
- UI copy no longer represents syntactic phone validity as WhatsApp confirmation.
- Polling continues after `READY` and selected lead context can be cleared.
Run focused tests after each change, then the complete Node test suite and dependency audit. Perform a browser-level manual send-flow check without sending an uncontrolled real message.
@@ -0,0 +1,69 @@
# Redesign visual: paleta SaaS dark (app inteiro)
## Contexto
Depois de uma sessão extensa corrigindo bugs funcionais da aba Campanhas, o usuário pediu para melhorar o frontend: visual/design + UX de feedback, aplicado ao app inteiro (não só Campanhas). Foram apresentadas 3 direções visuais via mockup (Artifact) com o card de campanha real: Dispatch Board (painel escuro técnico), Route Card (papel/serifada, verde-pinho), Night Shift (roxo-ameixa/dourado). O usuário escolheu Dispatch Board, depois pediu para trocar a paleta para um estilo "SaaS dark" (tipo Linear/Vercel).
## Decisões confirmadas com o usuário
- **Modo único, sempre escuro.** Sem alternância clara/escura — o app vira dark-first como identidade fixa, não uma preferência de sistema.
- **Escopo: app inteiro.** Login, as 4 abas (Capturar Leads, Tratar Leads, Campanhas, Configurações), modais, tabelas, badges.
- **UX incluído nesta leva:** toast colorido por sucesso/erro (verde/vermelho). Estados de "carregando" nos botões ficaram fora do escopo por enquanto.
- **Manter a estrutura "Dispatch Board"** nos cards de campanha e blocos de estatística: números em fonte monoespaçada tabular, indicador de status como borda colorida à esquerda do card (em vez de só um chip).
## Achado técnico relevante
`public/css/app.css` é quase inteiramente orientado por variáveis CSS (`:root` + `@media (prefers-color-scheme: dark)`), sem cores hardcoded em `login.html`, `index.html` ou nos arquivos `.js` (confirmado por grep). As únicas exceções são 3 gradientes em `app.css` que combinam `var(--accent)` com um tom claro hardcoded (`#ff965b`, `#ff8b47`, `#ffb36d`) — todos parceiros do laranja atual, que precisam de um substituto para o novo roxo-índigo. Isso torna a troca de paleta uma mudança de baixo risco, concentrada quase toda em um bloco de tokens.
## Paleta nova (substitui `:root`; remove o bloco `@media (prefers-color-scheme: dark)` — deixa de existir modo claro)
```
--page: #0b0c10
--page-2: #0d0e13
--surface: #16171d
--surface-2: #1d1f27
--ink: #f4f4f6
--ink-2: #a4a6b0
--muted: #6c6e79
--grid: #2a2c35
--border: rgba(255, 255, 255, 0.08)
--accent: #6a63f6
--accent-light: #9d97ff (novo token — substitui os 3 gradientes hardcoded)
--accent-2: #4f8cff
--accent-ink: #ffffff
--bar: #6a63f6
--good: #22c55e
--good-text: #4ade80
--warning: #f5a524
--critical: #fb5b7c
--critical-bg: rgba(251, 91, 124, 0.12)
--radius-sm: 8px
--radius: 12px
--radius-lg: 16px
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45)
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.3)
```
`body` usa `font: 14px/1.5 Inter, ui-sans-serif, system-ui, ...` — mantém Inter (já era a fonte usada; SaaS dark real como Linear também usa Inter).
## Componentes com tratamento específico "Dispatch Board"
- **`.campaign-card`**: ganha `border-left: 3px solid var(--muted)`, com `border-left-color: var(--critical)` quando pausada e `var(--good)` quando ativa (rodando).
- **Números de uso/limite** (`.campaign-usage`, `.stat-row .mini b`, `.tile .valor`): passam a usar `font-variant-numeric: tabular-nums` com uma pilha que prioriza `ui-monospace, SFMono-Regular, Consolas, monospace` para leitura tipo "painel".
- Chips de status (`.chip`, `.chip.ok`, `.chip.warn`) continuam existindo (não são removidos) — só herdam as cores novas automaticamente, via variáveis.
## Toast colorido (UX)
`public/js/api.js`, função `toast(message, ms)`: hoje sempre estiliza igual. Passa a inferir o tipo pela própria mensagem — se começar com `"Erro"` (case-insensitive), aplica um estado de erro (borda/texto vermelho); caso contrário, estado de sucesso (borda/texto verde). Isso evita precisar tocar em cada um dos ~40 pontos de chamada de `toast(...)` espalhados por `app.js`, `tratamento.js`, `captura.js` e `campanhas.js` — a lógica fica centralizada em `api.js` e no CSS (`.toast.erro`, `.toast.sucesso`).
## Fora de escopo (explicitamente adiado)
- Estados de "carregando" em botões durante ações assíncronas.
- Qualquer redesign de copy/textos, ícones, ou reestruturação de layout além da troca de paleta/raio/sombra e do tratamento Dispatch Board nos cards de campanha.
- Modo claro (removido, não mantido como alternativa).
## Verificação
Não há testes automatizados de aparência visual neste projeto. A verificação é:
1. Suíte de testes existente (`node --test`, Playwright) continua 100% verde — nenhuma lógica funcional deve quebrar.
2. Checagem visual ao vivo no navegador (via Claude Browser) de cada tela: login, Capturar Leads, Tratar Leads, Campanhas (cards, fila, modais), Configurações (modal grande, conexão WhatsApp, templates), toast de sucesso e de erro.
@@ -0,0 +1,79 @@
# Shared Number Detection Design
## Goal
Stop automatic and manual outreach from sending multiple messages to a single WhatsApp number that is shared by more than one lead in the base (the common case: an accountant's phone registered as the contact for several client CNPJs). Surface the sharing to the operator so it can be reviewed and, if it turns out not to be a third party, explicitly released.
## Background
`leads.dedup_key` is derived from CNPJ + phone + company + city, so two different companies (different CNPJs) that share the same registered phone number produce two distinct lead rows. Neither the campaign queue (`src/outreach/campaign-queue.mjs`) nor manual send (`reserveOutreachSend` in `src/db.mjs`) currently checks for this: each lead is deduplicated against its own send history and the opt-out list, not against other leads' phone numbers. This let three separate leads pointing at the same accounting-office number each queue and send a message.
## Constraints
- Work only in `MAQUINA COMERCIAL PESSOAL`.
- A number counts as shared when it appears on 2+ leads in the base, regardless of whether the CNPJs differ.
- Brazilian mobile variants with and without the ninth digit represent the same number for this check (reuse the existing equivalence rules from `src/phone.mjs`, already used for opt-out and WhatsApp verification).
- When a number is shared, block sending to **every** lead that shares it (not just the extras) — both from the automatic campaign queue and from manual send — until an operator explicitly reviews and releases that number.
- Do not silently auto-resolve; the operator decides per number.
## Data Model
New table `outreach_shared_number_overrides`, same shape as `outreach_opt_outs`:
```sql
CREATE TABLE outreach_shared_number_overrides (
numero TEXT PRIMARY KEY,
motivo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
`numero` stores one normalized representative of the equivalence group (same convention as `addOptOut`: `equivalentPhoneNumbers(numero)[0]`). Presence of a row means the operator reviewed that number and decided sending is allowed despite the sharing.
No new column is added to `leads`. Sharing is computed on demand by comparing `leads.whatsapp` values through the existing equivalence rules — the base is personal-scale (thousands of rows, one operator), so a generated/indexed column is not justified.
## Shared Logic
The 8/9-digit equivalence expansion currently lives twice as SQL-fragment builders inside `src/outreach/campaign-queue.mjs` (`normalizedSql`, `equivalentSql`). Move them to `src/phone.mjs` so they can be reused by the campaign queue, the manual-send path, and the treatment list query without duplicating the expression.
Add to `src/db.mjs` (co-located with `isOptedOut`/`addOptOut`, which follow the same pattern):
- `leadsSharingNumber(db, numero)` — returns `{id, empresa, cnpj}` rows for every lead whose phone is equivalent to `numero`.
- `isNumberSharedAcrossLeads(db, numero)` — `leadsSharingNumber(...).length >= 2`.
- `isSharedNumberOverridden(db, numero)` / `addSharedNumberOverride(db, numero, motivo)` / `removeSharedNumberOverride(db, numero)` — same shape as the opt-out functions.
- `listSharedNumberGroups(db)` — scans leads with a non-empty phone, buckets them by equivalence group (same technique already used by `listConfirmedWhatsAppNumbers`), keeps groups with 2+ leads, and attaches override status/reason. Powers both the review screen and the treatment-list badge.
## Blocking Points
**Campaign queue** (`src/outreach/campaign-queue.mjs`, `candidateQuery`): add one more `WHERE` condition using the moved `equivalentSql` helper — a lead is a candidate only if fewer than 2 leads share its equivalent number, or an override row exists for that number. This keeps the exclusion inside the existing single SQL statement instead of post-filtering in JS.
**Manual send** (`reserveOutreachSend` in `src/db.mjs`): after the existing opt-out check, reject with the same `statusCode = 409` pattern if `isNumberSharedAcrossLeads(...)` is true and no override exists.
**Manual send route** (`src/server/routes/outreach.mjs`, `POST /api/outreach/enviar-manual`): mirror the existing fast-fail opt-out check (line ~85) with an equivalent shared-number check, returning a clear 409 (`"numero compartilhado por varios leads - revise em Configuracoes antes de enviar"`) before even attempting `whatsapp.sendText`. `reserveOutreachSend`'s check remains the authoritative guard against races; the route check just gives a faster, clearer failure.
## Review Screen
New section in **Configurações**, next to the existing opt-out list, listing groups from `listSharedNumberGroups`: the number and the companies/leads that share it. Each group has a **Liberar** action (with a required reason) that calls `addSharedNumberOverride`, and, once liberated, a **Bloquear de novo** action that calls `removeSharedNumberOverride`. Same admin-only permission pattern as the opt-out endpoints.
New routes in `src/server/routes/outreach.mjs`:
- `GET /api/outreach/shared-numbers` → `{ groups: listSharedNumberGroups(db) }`
- `POST /api/outreach/shared-numbers/:numero/liberar` → admin only, body `{ motivo }`
- `DELETE /api/outreach/shared-numbers/:numero/liberar` → admin only
## Treatment List Indicator
`listLeads` and `getLead` in `src/treatment/queries.mjs` add a `numero_compartilhado` (boolean) and `leads_com_mesmo_numero` (count) field per row, computed the same way as the campaign-queue condition. The treatment table (`public/js/tratamento.js`) shows a warning badge next to the existing WhatsApp status badge when `numero_compartilhado` is true — e.g. "Número compartilhado (3 leads)" — softened to a neutral badge when the number has been liberated via override.
## Testing
Automated regressions must prove:
- Two leads with the exact same digits are both blocked from the campaign queue and from manual send.
- Two leads whose phones are 8/9-digit equivalents of each other are treated as the same shared number.
- A single lead with a unique number is never blocked.
- An override releases sending for every lead sharing that number, in both the queue and manual send.
- Removing an override re-blocks the number.
- The review screen endpoint lists correct groups and reflects override state.
- The treatment list marks shared numbers and reflects override state.
Run focused tests after each change, then the complete Node test suite.
@@ -0,0 +1,169 @@
# Docker Cloud 24/7 Design
## Goal
Run Maquina Comercial Pessoal continuously in a free cloud environment with Docker, persistent data, safe WhatsApp recovery, and Telegram alerts when manual action is required.
## Chosen Approach
Use Oracle Cloud Always Free with an Ubuntu VM, Docker Compose, persistent volume storage, Caddy for HTTPS, and Telegram Bot API alerts.
This is the best free option because Oracle Always Free can provide an always-on VM and persistent block volume. Other free platforms may sleep or restart unpredictably, which is risky for a WhatsApp Web session and scheduled campaign windows.
## Runtime Architecture
- One Ubuntu VM on Oracle Cloud Always Free.
- Docker Engine and Docker Compose installed on the VM.
- One application container running `npm start`.
- One Caddy container acting as reverse proxy and HTTPS terminator.
- Persistent host directory or block volume mounted into the app container.
- Telegram alerts sent directly from the app using Bot API HTTPS requests.
## Required Application Changes
### Network Binding
The server currently listens on `127.0.0.1`. In Docker it must support binding to `0.0.0.0`.
Add `APP_HOST`, defaulting to `127.0.0.1` for local safety. In Docker set `APP_HOST=0.0.0.0`.
### Persistent Paths
The following paths must survive container restarts and image updates:
- SQLite database: `data/maquina-pessoal.db`
- WhatsApp auth/session: `data/runtime/whatsapp-auth`
- Exports: `data/exports`
- Any runtime logs or backups
Use a single mounted volume at `/app/data` and configure:
- `APP_DB_PATH=/app/data/maquina-pessoal.db`
- `APP_WHATSAPP_AUTH_DIR=/app/data/runtime/whatsapp-auth`
### Health Endpoint
Add or reuse a lightweight HTTP health endpoint suitable for Docker healthcheck and uptime checks.
The endpoint must not leak secrets. It can expose:
- app status
- database open/migrated
- WhatsApp state summary
- campaign scheduler state summary
### Telegram Alerts
Add Telegram configuration:
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHAT_ID`
- `TELEGRAM_ALERTS_ENABLED=true|false`
Use Telegram Bot API `sendMessage` over HTTPS.
Send alerts for:
- WhatsApp leaves `READY` after previously being ready.
- WhatsApp enters `QR_REQUIRED`.
- Initial reconnect fails on startup.
- Campaigns are paused by `server_restart`.
- Worker sees repeated send errors above a safe threshold.
Alerts must be deduplicated to avoid spam. Use a cooldown window per alert key, for example 15 minutes.
### Restart Semantics
Keep the current safe behavior: campaigns pause after server restart. This prevents blind sending after container or VM restarts.
When this happens, Telegram should notify the operator to review and resume manually.
## Docker Files
### Dockerfile
Use Node 24, install production dependencies, copy app files, expose the app port, and run `npm start`.
Do not bake secrets into the image.
### docker-compose.yml
Services:
- `app`
- build from local Dockerfile
- `restart: unless-stopped`
- `env_file: .env.production`
- mount persistent `./data:/app/data`
- expose app port only to the internal Docker network
- healthcheck hitting the app health endpoint
- `caddy`
- official Caddy image
- `restart: unless-stopped`
- ports `80:80` and `443:443`
- Caddyfile mounted read-only
- Caddy data/config volumes persisted
## HTTPS and Access
Preferred: use a domain pointed to the VM public IP and let Caddy issue Let's Encrypt certificates.
If no domain is available, HTTP by IP can work temporarily but is not recommended for a login panel.
The app already has authentication. The production admin password must be changed from the default before exposing the panel.
## Oracle Cloud Setup
- Create Always Free Ubuntu VM.
- Attach or use persistent boot/block volume within Always Free limits.
- Install Docker and Docker Compose plugin.
- Open OCI security list/NSG for ports `80` and `443` only.
- Restrict SSH to the operator IP when possible.
- Clone/copy the project to the VM.
- Create `.env.production` with production values.
- Run `docker compose up -d --build`.
## Backup Plan
Create a daily SQLite backup job on the host or in a sidecar/script.
Backup target:
- `/app/data/backups/maquina-pessoal-YYYY-MM-DD.zip`
Retain the last 7 to 14 backups to control storage usage.
The backup must include at least:
- SQLite database
- WhatsApp auth directory
## Safety Rules
- Do not send messages automatically after an app/container restart.
- Do not expose Telegram token in logs.
- Do not commit `.env.production`.
- Do not use the real WhatsApp provider in tests.
- Do not remove existing manual approval gates.
- Keep campaign resume a deliberate operator action after Telegram alert.
## Verification
Before production deployment:
- `npm test`
- `npm run test:browser` if browsers are available
- `npm audit --omit=dev`
- Docker build succeeds
- Container healthcheck becomes healthy
- Telegram test alert is received
- App can show QR and reconnect WhatsApp
- Data persists after `docker compose restart`
- Campaign pause after restart triggers Telegram alert
## Open Operational Requirements
- Operator must create a Telegram bot with BotFather.
- Operator must provide `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` locally on the VM.
- Operator should decide whether to use a domain for HTTPS. HTTPS with a domain is recommended before exposing the panel.