Tracks the app that was already in place (capture, treatment, outreach, WhatsApp integration, server, frontend, tests, docs) so future feature work can branch and use isolated worktrees.
1424 lines
58 KiB
Markdown
1424 lines
58 KiB
Markdown
# Shared Number Detection 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:** Detect when a WhatsApp number is shared by 2+ leads in the base (e.g. an accountant's phone reused across client CNPJs) and block automatic and manual outreach to all of them until an operator explicitly reviews and releases the number.
|
|
|
|
**Architecture:** A new `outreach_shared_number_overrides` table records operator-reviewed releases. A small set of `db.mjs` functions (mirroring the existing `outreach_opt_outs` functions byte-for-byte in shape) detect sharing and manage overrides. Both send paths — the campaign queue's SQL candidate query and the manual-send reservation — gain one more blocking condition each. The treatment list and a new Configurações section surface the state to the operator.
|
|
|
|
**Tech Stack:** Node.js `node:sqlite` (`DatabaseSync`), `node:test` + `node:assert/strict`, vanilla DOM-less frontend (`h()`/`el()` helpers in `public/js/*.js`), no new dependencies.
|
|
|
|
## Global Constraints
|
|
|
|
- Work only in `MAQUINA COMERCIAL PESSOAL`.
|
|
- A number counts as shared when it appears on 2+ leads, regardless of whether CNPJs differ.
|
|
- Brazilian 8/9-digit mobile variants count as the same number (`equivalentPhoneNumbers` in `src/phone.mjs`).
|
|
- Blocking an unreleased shared number applies to **every** lead sharing it, in both the automatic campaign queue and manual send.
|
|
- No new column on `leads`; sharing is computed on demand (the base is personal-scale: thousands of rows, one operator).
|
|
- Run the focused test file after every task's implementation step, then the full suite (`npm test`) at the end of the plan.
|
|
|
|
**Implementation note vs. the design doc:** the design doc suggested moving the campaign-queue's `normalizedSql`/`equivalentSql` SQL-fragment builders into `src/phone.mjs` for reuse across all three surfaces. Working out the concrete implementation shows only the campaign queue's single prepared SQL statement needs that fragment-builder form; the manual-send path and the treatment list only ever check one concrete number at a time (or scan once to build an index), which is simpler and more efficient done in plain JS with the existing `equivalentPhoneNumbers` function. This plan keeps `normalizedSql`/`equivalentSql` local to `src/outreach/campaign-queue.mjs` (no relocation) and uses `equivalentPhoneNumbers` everywhere else — same equivalence rule, no duplicated SQL-building code introduced.
|
|
|
|
---
|
|
|
|
### Task 1: Data layer — migration, override CRUD, and grouping
|
|
|
|
**Files:**
|
|
- Modify: `src/db.mjs:640-647` (append migration v19 to the `MIGRATIONS` array)
|
|
- Modify: `src/db.mjs:940-947` (insert new functions after `removeOptOut`, before `saveWhatsAppVerification`)
|
|
- Modify: `src/db.mjs:679-706` in `tests/db.test.mjs` is NOT this task — see the test file edits below instead.
|
|
- Test: `tests/db.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `equivalentPhoneNumbers(value)` from `src/phone.mjs` (already imported in `db.mjs`), `transaction(db, fn)` (already defined in `db.mjs`).
|
|
- Produces (for later tasks):
|
|
- `leadsSharingNumber(db, numero) -> Array<{id, empresa, cnpj}>`
|
|
- `isNumberSharedAcrossLeads(db, numero) -> boolean`
|
|
- `getSharedNumberOverride(db, numero) -> {numero, motivo, created_at} | null`
|
|
- `isSharedNumberOverridden(db, numero) -> boolean`
|
|
- `addSharedNumberOverride(db, numero, motivo = '') -> {numero, motivo, created_at}`
|
|
- `removeSharedNumberOverride(db, numero) -> boolean`
|
|
- `listSharedNumberGroups(db) -> Array<{numero, leads: Array<{id, empresa, cnpj}>, liberado, motivo}>`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `tests/db.test.mjs`, right after the existing `test('openDb cria esquema e aplica migrations uma unica vez', ...)` block (after its closing `});` around line 70):
|
|
|
|
```js
|
|
test('migracao v19 cria tabela de overrides de numero compartilhado', () => {
|
|
const db = openDb(':memory:');
|
|
assert.equal(db.prepare('SELECT MAX(version) v FROM schema_migrations').get().v, 19);
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
|
assert.ok(tables.includes('outreach_shared_number_overrides'));
|
|
const columns = db.prepare('PRAGMA table_info(outreach_shared_number_overrides)').all().map((r) => r.name);
|
|
assert.deepEqual(columns, ['numero', 'motivo', 'created_at']);
|
|
db.close();
|
|
});
|
|
|
|
test('isNumberSharedAcrossLeads detecta duplicidade exata e por variante 8/9 digitos', () => {
|
|
const db = openDb(':memory:');
|
|
const insert = createLeadInserter(db);
|
|
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5575999091780' }));
|
|
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '557599091780' }));
|
|
insert(sampleLead({ dedup_key: 'solo', empresa: 'Empresa C', whatsapp: '5511999990001' }));
|
|
|
|
assert.equal(database.isNumberSharedAcrossLeads(db, '5575999091780'), true);
|
|
assert.equal(database.isNumberSharedAcrossLeads(db, '557599091780'), true);
|
|
assert.equal(database.isNumberSharedAcrossLeads(db, '5511999990001'), false);
|
|
const sharing = database.leadsSharingNumber(db, '5575999091780');
|
|
assert.deepEqual(sharing.map((l) => l.empresa).sort(), ['Empresa A', 'Empresa B']);
|
|
db.close();
|
|
});
|
|
|
|
test('override libera e remover override rebloqueia numero compartilhado', () => {
|
|
const db = openDb(':memory:');
|
|
const insert = createLeadInserter(db);
|
|
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5575999091780' }));
|
|
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5575999091780' }));
|
|
|
|
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), false);
|
|
const override = database.addSharedNumberOverride(db, '5575999091780', 'confirmado contato direto');
|
|
assert.equal(override.numero, '5575999091780');
|
|
assert.equal(override.motivo, 'confirmado contato direto');
|
|
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), true);
|
|
|
|
assert.equal(database.removeSharedNumberOverride(db, '5575999091780'), true);
|
|
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), false);
|
|
db.close();
|
|
});
|
|
|
|
test('listSharedNumberGroups agrupa por equivalencia e reflete override', () => {
|
|
const db = openDb(':memory:');
|
|
const insert = createLeadInserter(db);
|
|
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780' }));
|
|
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Contabilidade Cliente B', whatsapp: '557599091780' }));
|
|
insert(sampleLead({ dedup_key: 'solo', empresa: 'Empresa Unica', whatsapp: '5511999990001' }));
|
|
|
|
let groups = database.listSharedNumberGroups(db);
|
|
assert.equal(groups.length, 1);
|
|
assert.equal(groups[0].leads.length, 2);
|
|
assert.deepEqual(groups[0].leads.map((l) => l.empresa).sort(), ['Contabilidade Cliente A', 'Contabilidade Cliente B']);
|
|
assert.equal(groups[0].liberado, false);
|
|
|
|
database.addSharedNumberOverride(db, '5575999091780', 'revisado');
|
|
groups = database.listSharedNumberGroups(db);
|
|
assert.equal(groups[0].liberado, true);
|
|
assert.equal(groups[0].motivo, 'revisado');
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
Also update the existing test `'reset apaga outreach antes de leads e preserva opt-outs'` (around line 679-706) to prove overrides survive a base reset the same way opt-outs do. Change:
|
|
|
|
```js
|
|
db.prepare("INSERT INTO outreach_opt_outs (numero) VALUES ('5511999990001')").run();
|
|
resetLeadsBase(db);
|
|
```
|
|
|
|
to:
|
|
|
|
```js
|
|
db.prepare("INSERT INTO outreach_opt_outs (numero) VALUES ('5511999990001')").run();
|
|
db.prepare("INSERT INTO outreach_shared_number_overrides (numero, motivo) VALUES ('5511999990002', 'revisado')").run();
|
|
resetLeadsBase(db);
|
|
```
|
|
|
|
and add, next to the existing `assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_opt_outs').get().total, 1);` line:
|
|
|
|
```js
|
|
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_shared_number_overrides').get().total, 1);
|
|
```
|
|
|
|
Finally, bump every hardcoded migration-version assertion from 18 to 19: line 61 (`assert.equal(version, 18);`), line 97, and line 189 (both `assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18);`).
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: FAIL — `outreach_shared_number_overrides` table does not exist, `database.isNumberSharedAcrossLeads` is not a function, version assertions read 18 instead of 19.
|
|
|
|
- [ ] **Step 3: Add the migration**
|
|
|
|
In `src/db.mjs`, the `MIGRATIONS` array currently ends with (around line 630-647):
|
|
|
|
```js
|
|
`,
|
|
// v18 - permite reconciliar resultados terminais da fila com o historico do provedor.
|
|
`
|
|
DROP TRIGGER outreach_campaign_queue_status_transition;
|
|
CREATE TRIGGER outreach_campaign_queue_status_transition
|
|
BEFORE UPDATE OF status ON outreach_campaign_queue
|
|
WHEN NEW.status <> OLD.status AND NOT (
|
|
(OLD.status = 'preview' AND NEW.status IN ('approved','scheduled','cancelled'))
|
|
OR (OLD.status = 'approved' AND NEW.status IN ('scheduled','cancelled'))
|
|
OR (OLD.status = 'scheduled' AND NEW.status IN ('reserved','cancelled'))
|
|
OR (OLD.status = 'reserved' AND NEW.status IN ('sent','unknown','error'))
|
|
OR (OLD.status IN ('sent','unknown','error') AND NEW.status IN ('sent','unknown','error'))
|
|
)
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'transicao de status da fila invalida');
|
|
END;
|
|
`
|
|
];
|
|
```
|
|
|
|
Replace the final `` ` `` and `];` with a new v19 entry:
|
|
|
|
```js
|
|
`,
|
|
// v18 - permite reconciliar resultados terminais da fila com o historico do provedor.
|
|
`
|
|
DROP TRIGGER outreach_campaign_queue_status_transition;
|
|
CREATE TRIGGER outreach_campaign_queue_status_transition
|
|
BEFORE UPDATE OF status ON outreach_campaign_queue
|
|
WHEN NEW.status <> OLD.status AND NOT (
|
|
(OLD.status = 'preview' AND NEW.status IN ('approved','scheduled','cancelled'))
|
|
OR (OLD.status = 'approved' AND NEW.status IN ('scheduled','cancelled'))
|
|
OR (OLD.status = 'scheduled' AND NEW.status IN ('reserved','cancelled'))
|
|
OR (OLD.status = 'reserved' AND NEW.status IN ('sent','unknown','error'))
|
|
OR (OLD.status IN ('sent','unknown','error') AND NEW.status IN ('sent','unknown','error'))
|
|
)
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'transicao de status da fila invalida');
|
|
END;
|
|
`,
|
|
// v19 - numeros compartilhados por mais de um lead (ex.: contabilidade que
|
|
// registra o proprio telefone para varios CNPJs de clientes) podem ser
|
|
// liberados manualmente apos revisao, revertendo o bloqueio automatico.
|
|
`
|
|
CREATE TABLE outreach_shared_number_overrides (
|
|
numero TEXT PRIMARY KEY,
|
|
motivo TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 4: Add the data-layer functions**
|
|
|
|
In `src/db.mjs`, `removeOptOut` currently ends around line 947 immediately before `export function saveWhatsAppVerification`:
|
|
|
|
```js
|
|
export function removeOptOut(db, numero) {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) return false;
|
|
return transaction(db, () => {
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
return db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers).changes > 0;
|
|
});
|
|
}
|
|
|
|
export function saveWhatsAppVerification(db, result) {
|
|
```
|
|
|
|
Insert the new functions between them:
|
|
|
|
```js
|
|
export function removeOptOut(db, numero) {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) return false;
|
|
return transaction(db, () => {
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
return db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers).changes > 0;
|
|
});
|
|
}
|
|
|
|
export function leadsSharingNumber(db, numero) {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) return [];
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
return db.prepare(`SELECT id, empresa, cnpj FROM leads WHERE whatsapp IN (${placeholders})`).all(...numbers);
|
|
}
|
|
|
|
export function isNumberSharedAcrossLeads(db, numero) {
|
|
return leadsSharingNumber(db, numero).length >= 2;
|
|
}
|
|
|
|
export function getSharedNumberOverride(db, numero) {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) return null;
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
return db.prepare(
|
|
`SELECT numero, motivo, created_at FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})`
|
|
).get(...numbers) || null;
|
|
}
|
|
|
|
export function isSharedNumberOverridden(db, numero) {
|
|
return Boolean(getSharedNumberOverride(db, numero));
|
|
}
|
|
|
|
export function addSharedNumberOverride(db, numero, motivo = '') {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) throw new Error('numero obrigatorio');
|
|
return transaction(db, () => {
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
db.prepare(`DELETE FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})`).run(...numbers);
|
|
db.prepare('INSERT INTO outreach_shared_number_overrides (numero, motivo) VALUES (?, ?)')
|
|
.run(numbers[0], String(motivo || '').trim());
|
|
return db.prepare(
|
|
'SELECT numero, motivo, created_at FROM outreach_shared_number_overrides WHERE numero = ?'
|
|
).get(numbers[0]);
|
|
});
|
|
}
|
|
|
|
export function removeSharedNumberOverride(db, numero) {
|
|
const numbers = equivalentPhoneNumbers(numero);
|
|
if (!numbers.length) return false;
|
|
return transaction(db, () => {
|
|
const placeholders = numbers.map(() => '?').join(', ');
|
|
return db.prepare(`DELETE FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})`).run(...numbers).changes > 0;
|
|
});
|
|
}
|
|
|
|
export function listSharedNumberGroups(db) {
|
|
const rows = db.prepare("SELECT id, empresa, cnpj, whatsapp FROM leads WHERE whatsapp <> ''").all();
|
|
const groups = new Map();
|
|
for (const row of rows) {
|
|
const equivalents = equivalentPhoneNumbers(row.whatsapp);
|
|
if (!equivalents.length) continue;
|
|
const key = equivalents.slice().sort().join(':');
|
|
if (!groups.has(key)) groups.set(key, { numero: equivalents[0], leads: [] });
|
|
groups.get(key).leads.push({ id: row.id, empresa: row.empresa, cnpj: row.cnpj });
|
|
}
|
|
return [...groups.values()]
|
|
.filter((group) => group.leads.length >= 2)
|
|
.map((group) => {
|
|
const override = getSharedNumberOverride(db, group.numero);
|
|
return { ...group, liberado: Boolean(override), motivo: override?.motivo || null };
|
|
})
|
|
.sort((a, b) => b.leads.length - a.leads.length || a.numero.localeCompare(b.numero));
|
|
}
|
|
|
|
export function saveWhatsAppVerification(db, result) {
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/db.mjs tests/db.test.mjs
|
|
git commit -m "Add shared-number detection and override data layer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Block manual send reservation
|
|
|
|
**Files:**
|
|
- Modify: `src/db.mjs:1035-1069` (`reserveOutreachSend`)
|
|
- Test: `tests/db.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `isNumberSharedAcrossLeads(db, numero)`, `isSharedNumberOverridden(db, numero)` (from Task 1).
|
|
- Produces: `reserveOutreachSend` now throws `{statusCode: 409}` for an unreleased shared number, same shape as its existing opt-out rejection.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `tests/db.test.mjs`, near the existing `reserveOutreachSend`/`finishOutreachSend` tests:
|
|
|
|
```js
|
|
test('reserveOutreachSend bloqueia numero compartilhado sem override e libera com override', () => {
|
|
const db = openDb(':memory:');
|
|
const insert = createLeadInserter(db);
|
|
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009' }));
|
|
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009' }));
|
|
|
|
assert.throws(
|
|
() => reserveOutreachSend(db, { numero: '5511999990009', mensagem: 'oi' }),
|
|
(error) => error.statusCode === 409
|
|
);
|
|
|
|
database.addSharedNumberOverride(db, '5511999990009', 'liberado');
|
|
const sendId = reserveOutreachSend(db, { numero: '5511999990009', mensagem: 'oi' });
|
|
assert.ok(sendId > 0);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: FAIL — the throw assertion fails because `reserveOutreachSend` currently allows the shared number through (no 409).
|
|
|
|
- [ ] **Step 3: Implement the block**
|
|
|
|
In `src/db.mjs`, `reserveOutreachSend` currently reads:
|
|
|
|
```js
|
|
const normalized = normalizePhone(numero);
|
|
if (isOptedOut(db, normalized)) {
|
|
const error = new Error('numero bloqueado por opt-out');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const limit = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
|
|
```
|
|
|
|
Change to:
|
|
|
|
```js
|
|
const normalized = normalizePhone(numero);
|
|
if (isOptedOut(db, normalized)) {
|
|
const error = new Error('numero bloqueado por opt-out');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
if (isNumberSharedAcrossLeads(db, normalized) && !isSharedNumberOverridden(db, normalized)) {
|
|
const error = new Error('numero compartilhado por multiplos leads (revise em Configuracoes antes de enviar)');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const limit = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/db.mjs tests/db.test.mjs
|
|
git commit -m "Block manual send reservation for unreleased shared numbers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Block the campaign queue candidate query
|
|
|
|
**Files:**
|
|
- Modify: `src/outreach/campaign-queue.mjs:27-77` (`candidateQuery`)
|
|
- Test: `tests/campaign-queue.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing local `normalizedSql(column)` / `equivalentSql(column)` helpers already in this file; the `outreach_shared_number_overrides` table from Task 1; `addSharedNumberOverride` from `src/db.mjs` (test only).
|
|
- Produces: `prepareDailyQueue` never previews a lead whose number is shared by 2+ leads, unless an override exists for that number.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `tests/campaign-queue.test.mjs`. First, update the import block at the top to also pull in the override helper:
|
|
|
|
```js
|
|
import {
|
|
addOptOut,
|
|
addSharedNumberOverride,
|
|
openDb,
|
|
saveWhatsAppVerification
|
|
} from '../src/db.mjs';
|
|
```
|
|
|
|
Then add two tests, near the other `prepareDailyQueue` tests:
|
|
|
|
```js
|
|
test('numero compartilhado por mais de um lead fica fora da fila ate liberacao manual', async () => {
|
|
const { db, campaign } = setupCampaign();
|
|
const sharedA = insertLead(db, { key: 'shared-a', empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', score: 100 });
|
|
const sharedB = insertLead(db, { key: 'shared-b', empresa: 'Contabilidade Cliente B', whatsapp: '5575999091780', score: 90 });
|
|
const solo = insertLead(db, { key: 'solo', whatsapp: '5575992220000', score: 80 });
|
|
for (const numero of ['5575999091780', '5575992220000']) confirmed(db, numero);
|
|
|
|
await prepareDailyQueue({
|
|
db,
|
|
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
|
|
campaignId: campaign.id,
|
|
now: new Date('2026-07-29T14:00:00-03:00')
|
|
});
|
|
let queue = listCampaignQueue(db, campaign.id);
|
|
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [solo]);
|
|
assert.equal(queue.some(({ lead_id }) => [sharedA, sharedB].includes(lead_id)), false);
|
|
|
|
addSharedNumberOverride(db, '5575999091780', 'confirmado contato direto');
|
|
await prepareDailyQueue({
|
|
db,
|
|
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
|
|
campaignId: campaign.id,
|
|
now: new Date('2026-07-29T14:00:00-03:00')
|
|
});
|
|
queue = listCampaignQueue(db, campaign.id);
|
|
assert.deepEqual(queue.map(({ lead_id }) => lead_id).sort((a, b) => a - b), [sharedA, sharedB, solo].sort((a, b) => a - b));
|
|
db.close();
|
|
});
|
|
|
|
test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila', async () => {
|
|
const { db, campaign } = setupCampaign();
|
|
const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 });
|
|
const eightDigits = insertLead(db, { key: 'eight-digits', whatsapp: '557599091780', score: 90 });
|
|
confirmed(db, '5575999091780');
|
|
confirmed(db, '557599091780');
|
|
|
|
await prepareDailyQueue({
|
|
db,
|
|
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
|
|
campaignId: campaign.id,
|
|
now: new Date('2026-07-29T14:00:00-03:00')
|
|
});
|
|
const queue = listCampaignQueue(db, campaign.id);
|
|
assert.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `node --test tests/campaign-queue.test.mjs`
|
|
Expected: FAIL — both shared leads currently get previewed since nothing checks for cross-lead number sharing.
|
|
|
|
- [ ] **Step 3: Implement the block in `candidateQuery`**
|
|
|
|
In `src/outreach/campaign-queue.mjs`, the query's `WHERE` clause currently reads (around lines 48-74):
|
|
|
|
```js
|
|
WHERE leads.segmento = ?
|
|
AND ${leadNumber} <> ''
|
|
AND ${verificationFilter}
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_sends sends
|
|
WHERE sends.lead_id = leads.id AND sends.produto = ?
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM outreach_campaign_queue queued
|
|
WHERE queued.lead_id = leads.id
|
|
AND queued.produto = ?
|
|
AND queued.status IN (${ACTIVE_QUEUE_STATUSES})
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_opt_outs opt_out
|
|
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_inbound_replies replies
|
|
WHERE replies.lead_id = leads.id
|
|
OR replies.numero IN (${leadNumber}, ${leadEquivalent})
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_lead_states states
|
|
WHERE states.lead_id = leads.id AND states.first_reply_at IS NOT NULL
|
|
)
|
|
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC
|
|
LIMIT ?`;
|
|
```
|
|
|
|
Add the shared-number condition right after the opt-out check (this ordering keeps both "external blocklist" conditions adjacent):
|
|
|
|
```js
|
|
WHERE leads.segmento = ?
|
|
AND ${leadNumber} <> ''
|
|
AND ${verificationFilter}
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_sends sends
|
|
WHERE sends.lead_id = leads.id AND sends.produto = ?
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM outreach_campaign_queue queued
|
|
WHERE queued.lead_id = leads.id
|
|
AND queued.produto = ?
|
|
AND queued.status IN (${ACTIVE_QUEUE_STATUSES})
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_opt_outs opt_out
|
|
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
|
|
)
|
|
AND (
|
|
(SELECT COUNT(DISTINCT other.id) FROM leads other
|
|
WHERE other.whatsapp IN (${leadNumber}, ${leadEquivalent})
|
|
) < 2
|
|
OR EXISTS (
|
|
SELECT 1 FROM outreach_shared_number_overrides ov
|
|
WHERE ov.numero IN (${leadNumber}, ${leadEquivalent})
|
|
)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_inbound_replies replies
|
|
WHERE replies.lead_id = leads.id
|
|
OR replies.numero IN (${leadNumber}, ${leadEquivalent})
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_lead_states states
|
|
WHERE states.lead_id = leads.id AND states.first_reply_at IS NOT NULL
|
|
)
|
|
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC
|
|
LIMIT ?`;
|
|
```
|
|
|
|
Note the inner subquery compares the bare `other.whatsapp` column (not wrapped in `normalizedSql`) against the two computed forms of the outer row's number. Since `leads.whatsapp` is already stored as clean digits (`onlyDigits` at capture time), this is equivalent to wrapping it but lets SQLite use the existing `idx_leads_whatsapp` index instead of a full table scan — the same reasoning already relied on by the existing perf test in this file.
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `node --test tests/campaign-queue.test.mjs`
|
|
Expected: PASS (all tests in the file, including the existing 1.3M-row perf test, which must still complete under 8s — the new subquery uses the same index as the existing opt-out check).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-queue.mjs tests/campaign-queue.test.mjs
|
|
git commit -m "Exclude leads sharing a WhatsApp number from the automatic campaign queue"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Routes — fast-fail on manual send, and the review endpoints
|
|
|
|
**Files:**
|
|
- Modify: `src/server/routes/outreach.mjs:1-13` (imports), `:84-88` (manual send fast-fail), end of `registerOutreachRoutes` (new routes)
|
|
- Test: `tests/outreach-routes.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `isNumberSharedAcrossLeads`, `isSharedNumberOverridden`, `listSharedNumberGroups`, `addSharedNumberOverride`, `removeSharedNumberOverride` from `src/db.mjs` (Task 1); `requireAdmin(user, json)` already defined in this file.
|
|
- Produces: `GET /api/outreach/shared-numbers`, `POST /api/outreach/shared-numbers/:numero/liberar`, `DELETE /api/outreach/shared-numbers/:numero/liberar`; `POST /api/outreach/enviar-manual` returns 409 before attempting a send for a shared, unreleased number.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `tests/outreach-routes.test.mjs` (this file already imports `createLeadInserter` from `../src/db.mjs`, so no import changes are needed). Add these tests after the existing `'rotas opt-out listam e restringem mutacoes a admin'` test:
|
|
|
|
```js
|
|
test('envio manual bloqueia numero compartilhado por leads distintos ate liberacao', async () => {
|
|
const { db, router } = setup();
|
|
const insert = createLeadInserter(db);
|
|
insert({
|
|
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
|
|
produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
insert({
|
|
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
|
|
produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
|
|
const blocked = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
|
|
body: { numero: '5511999990009', mensagem: 'Oi' }
|
|
});
|
|
assert.equal(blocked.status, 409);
|
|
|
|
await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
|
|
body: { motivo: 'confirmado contato direto' }, user: { role: 'admin' }
|
|
});
|
|
const allowed = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
|
|
body: { numero: '5511999990009', mensagem: 'Oi' }
|
|
});
|
|
assert.equal(allowed.status, 202);
|
|
db.close();
|
|
});
|
|
|
|
test('rotas de numeros compartilhados listam e restringem liberacao a admin', async () => {
|
|
const { db, router } = setup();
|
|
const insert = createLeadInserter(db);
|
|
insert({
|
|
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
|
|
produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
insert({
|
|
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
|
|
produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
|
|
const listed = await invoke(router, 'GET', '/api/outreach/shared-numbers');
|
|
assert.equal(listed.data.groups.length, 1);
|
|
assert.equal(listed.data.groups[0].leads.length, 2);
|
|
|
|
const denied = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
|
|
body: { motivo: 'teste' }, user: { role: 'comercial' }
|
|
});
|
|
assert.equal(denied.status, 403);
|
|
|
|
const missingMotivo = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
|
|
body: {}, user: { role: 'admin' }
|
|
});
|
|
assert.equal(missingMotivo.status, 400);
|
|
|
|
const created = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
|
|
body: { motivo: 'confirmado' }, user: { role: 'admin' }
|
|
});
|
|
assert.equal(created.status, 201);
|
|
|
|
const removeDenied = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'comercial' } });
|
|
assert.equal(removeDenied.status, 403);
|
|
|
|
const removed = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
|
|
assert.equal(removed.status, 200);
|
|
|
|
const removedAgain = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
|
|
assert.equal(removedAgain.status, 404);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `node --test tests/outreach-routes.test.mjs`
|
|
Expected: FAIL — the new routes do not exist (404 from the router) and `/enviar-manual` does not reject the shared number.
|
|
|
|
- [ ] **Step 3: Implement the routes**
|
|
|
|
In `src/server/routes/outreach.mjs`, extend the import block:
|
|
|
|
```js
|
|
import {
|
|
addOptOut,
|
|
addSharedNumberOverride,
|
|
countDailyOutreachUsage,
|
|
getSetting,
|
|
isNumberSharedAcrossLeads,
|
|
isOptedOut,
|
|
isSharedNumberOverridden,
|
|
listMessageTemplates,
|
|
listOptOuts,
|
|
listSharedNumberGroups,
|
|
removeOptOut,
|
|
removeSharedNumberOverride,
|
|
reserveOutreachSend,
|
|
saveWhatsAppVerification,
|
|
finishOutreachSend,
|
|
updateMessageTemplate
|
|
} from '../../db.mjs';
|
|
```
|
|
|
|
In the `POST /api/outreach/enviar-manual` handler, the fast-fail block currently reads:
|
|
|
|
```js
|
|
const number = onlyDigits(rawNumber);
|
|
if (isOptedOut(db, number)) {
|
|
json(409, { ok: false, erro: 'numero bloqueado por opt-out' });
|
|
return;
|
|
}
|
|
const limit = dailyLimitStatus(db);
|
|
```
|
|
|
|
Change to:
|
|
|
|
```js
|
|
const number = onlyDigits(rawNumber);
|
|
if (isOptedOut(db, number)) {
|
|
json(409, { ok: false, erro: 'numero bloqueado por opt-out' });
|
|
return;
|
|
}
|
|
if (isNumberSharedAcrossLeads(db, number) && !isSharedNumberOverridden(db, number)) {
|
|
json(409, { ok: false, erro: 'numero compartilhado por varios leads - revise em Configuracoes antes de enviar' });
|
|
return;
|
|
}
|
|
const limit = dailyLimitStatus(db);
|
|
```
|
|
|
|
Finally, add the three new routes right before the closing `}` of `registerOutreachRoutes` (immediately after the existing `DELETE /api/outreach/opt-outs/:numero` route):
|
|
|
|
```js
|
|
router.add('DELETE', '/api/outreach/opt-outs/:numero', async (req, res, { json, params, user }) => {
|
|
if (!requireAdmin(user, json)) return;
|
|
if (!removeOptOut(db, params.numero)) {
|
|
json(404, { erro: 'opt-out nao encontrado' });
|
|
return;
|
|
}
|
|
json(200, { ok: true });
|
|
});
|
|
|
|
router.add('GET', '/api/outreach/shared-numbers', async (req, res, { json }) => {
|
|
json(200, { groups: listSharedNumberGroups(db) });
|
|
});
|
|
|
|
router.add('POST', '/api/outreach/shared-numbers/:numero/liberar', async (req, res, { json, params, body, user }) => {
|
|
if (!requireAdmin(user, json)) return;
|
|
const motivo = String(body?.motivo || '').trim();
|
|
if (!motivo) {
|
|
json(400, { erro: 'motivo obrigatorio para liberar numero compartilhado' });
|
|
return;
|
|
}
|
|
json(201, { override: addSharedNumberOverride(db, params.numero, motivo) });
|
|
});
|
|
|
|
router.add('DELETE', '/api/outreach/shared-numbers/:numero/liberar', async (req, res, { json, params, user }) => {
|
|
if (!requireAdmin(user, json)) return;
|
|
if (!removeSharedNumberOverride(db, params.numero)) {
|
|
json(404, { erro: 'override nao encontrado' });
|
|
return;
|
|
}
|
|
json(200, { ok: true });
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `node --test tests/outreach-routes.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/server/routes/outreach.mjs tests/outreach-routes.test.mjs
|
|
git commit -m "Add shared-number review routes and block manual send for unreleased numbers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Surface shared-number state on the treatment list and detail
|
|
|
|
**Files:**
|
|
- Modify: `src/treatment/queries.mjs:1-90` (imports, `listLeads`), `:92-133` (`getLead`), `:135-143` (near `withWhatsAppState`)
|
|
- Test: `tests/personal-backend.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `listSharedNumberGroups(db)` from `src/db.mjs` (Task 1).
|
|
- Produces: every lead row/detail returned by `listLeads`/`getLead` gains `numero_compartilhado` (boolean), `numero_compartilhado_total` (number, 0 when not shared), `numero_compartilhado_liberado` (boolean).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `tests/personal-backend.test.mjs`, right after the existing `'lead sem verificacao equivalente permanece nao consultado'` test:
|
|
|
|
```js
|
|
test('listagem e detalhe marcam numero compartilhado por mais de um lead', async () => {
|
|
const { openDb, addSharedNumberOverride } = await import('../src/db.mjs');
|
|
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
|
|
const db = openDb(':memory:');
|
|
const sharedA = db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
|
|
VALUES ('shared-a', 'Contabilidade Cliente A', '5575999091780', 1)`
|
|
).run();
|
|
db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
|
|
VALUES ('shared-b', 'Contabilidade Cliente B', '557599091780', 1)`
|
|
).run();
|
|
const solo = db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
|
|
VALUES ('solo', 'Empresa Unica', '5511999990001', 1)`
|
|
).run();
|
|
|
|
const rows = listLeads(db).rows;
|
|
const shared = rows.find((row) => row.id === Number(sharedA.lastInsertRowid));
|
|
const unique = rows.find((row) => row.id === Number(solo.lastInsertRowid));
|
|
assert.equal(shared.numero_compartilhado, true);
|
|
assert.equal(shared.numero_compartilhado_total, 2);
|
|
assert.equal(shared.numero_compartilhado_liberado, false);
|
|
assert.equal(unique.numero_compartilhado, false);
|
|
assert.equal(unique.numero_compartilhado_total, 0);
|
|
|
|
const detail = getLead(db, Number(sharedA.lastInsertRowid));
|
|
assert.equal(detail.numero_compartilhado, true);
|
|
|
|
addSharedNumberOverride(db, '5575999091780', 'revisado');
|
|
const refreshed = getLead(db, Number(sharedA.lastInsertRowid));
|
|
assert.equal(refreshed.numero_compartilhado_liberado, true);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test tests/personal-backend.test.mjs`
|
|
Expected: FAIL — `numero_compartilhado` is `undefined` on both rows.
|
|
|
|
- [ ] **Step 3: Implement in `src/treatment/queries.mjs`**
|
|
|
|
Change the import at the top of the file from:
|
|
|
|
```js
|
|
import { getWhatsAppVerification } from '../db.mjs';
|
|
```
|
|
|
|
to:
|
|
|
|
```js
|
|
import { getWhatsAppVerification, listSharedNumberGroups } from '../db.mjs';
|
|
```
|
|
|
|
Add a helper next to `withWhatsAppState` (which currently sits right after `countLeads`... actually it is defined right after `getLead`, around line 135-143):
|
|
|
|
```js
|
|
function withWhatsAppState(db, lead) {
|
|
const verification = getWhatsAppVerification(db, lead.whatsapp);
|
|
return {
|
|
...lead,
|
|
whatsapp_status: verification.status,
|
|
whatsapp_numero_resolvido: verification.numero_resolvido,
|
|
whatsapp_checked_at: verification.checked_at
|
|
};
|
|
}
|
|
```
|
|
|
|
Add immediately after it:
|
|
|
|
```js
|
|
function sharedNumberIndex(db) {
|
|
const index = new Map();
|
|
for (const group of listSharedNumberGroups(db)) {
|
|
for (const lead of group.leads) {
|
|
index.set(lead.id, { total: group.leads.length, liberado: group.liberado });
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
function withSharedNumberState(lead, index) {
|
|
const shared = index.get(lead.id);
|
|
return {
|
|
...lead,
|
|
numero_compartilhado: Boolean(shared),
|
|
numero_compartilhado_total: shared?.total || 0,
|
|
numero_compartilhado_liberado: shared?.liberado || false
|
|
};
|
|
}
|
|
```
|
|
|
|
Change `listLeads` from:
|
|
|
|
```js
|
|
export function listLeads(db, filtro = {}) {
|
|
const { whereSql, params } = buildLeadFilter(filtro);
|
|
const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50));
|
|
const page = Math.max(1, Number(filtro.page) || 1);
|
|
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
|
const rows = db.prepare(
|
|
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
|
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
|
prioridade, produto_principal, telefone_valido, origem,
|
|
exported_at, captured_at, segments.affinity AS afinidade
|
|
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
|
|
${whereSql}
|
|
ORDER BY ${orderBy}
|
|
LIMIT ? OFFSET ?`
|
|
).all(...params, pageSize, (page - 1) * pageSize).map((lead) => withWhatsAppState(db, lead));
|
|
|
|
return { total, page, pageSize, rows };
|
|
}
|
|
```
|
|
|
|
to:
|
|
|
|
```js
|
|
export function listLeads(db, filtro = {}) {
|
|
const { whereSql, params } = buildLeadFilter(filtro);
|
|
const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50));
|
|
const page = Math.max(1, Number(filtro.page) || 1);
|
|
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
|
const sharedIndex = sharedNumberIndex(db);
|
|
const rows = db.prepare(
|
|
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
|
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
|
prioridade, produto_principal, telefone_valido, origem,
|
|
exported_at, captured_at, segments.affinity AS afinidade
|
|
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
|
|
${whereSql}
|
|
ORDER BY ${orderBy}
|
|
LIMIT ? OFFSET ?`
|
|
).all(...params, pageSize, (page - 1) * pageSize)
|
|
.map((lead) => withSharedNumberState(withWhatsAppState(db, lead), sharedIndex));
|
|
|
|
return { total, page, pageSize, rows };
|
|
}
|
|
```
|
|
|
|
Change `getLead` from:
|
|
|
|
```js
|
|
export function getLead(db, id) {
|
|
const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
|
|
const lead = storedLead ? withWhatsAppState(db, storedLead) : null;
|
|
if (!lead) return null;
|
|
```
|
|
|
|
to:
|
|
|
|
```js
|
|
export function getLead(db, id) {
|
|
const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
|
|
const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead), sharedNumberIndex(db)) : null;
|
|
if (!lead) return null;
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test tests/personal-backend.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/treatment/queries.mjs tests/personal-backend.test.mjs
|
|
git commit -m "Expose shared-number state on lead list and detail queries"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Treatment table badge
|
|
|
|
**Files:**
|
|
- Modify: `public/js/tratamento.js:433-442` (`whatsappStatusCell`), `:523-529` (lead detail `linha(...)` calls)
|
|
- Modify: `public/css/app.css` (near line 622, badge variants)
|
|
- Test: `tests/personal-branding.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `lead.numero_compartilhado`, `lead.numero_compartilhado_total`, `lead.numero_compartilhado_liberado` (Task 5).
|
|
- Produces: a visible badge in the treatment table's WhatsApp column and a line in the lead detail panel.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `tests/personal-branding.test.mjs`, right after whichever test currently exercises `renderTreatment` (search the file for its first usage to place this nearby):
|
|
|
|
```js
|
|
test('tabela de tratamento sinaliza numero compartilhado por mais de um lead', async () => {
|
|
const app = await executeApp();
|
|
const rows = [
|
|
{
|
|
id: 1, empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', whatsapp_status: 'confirmado',
|
|
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 90, classificacao: 'bom',
|
|
produto_principal: '', afinidade: 'media',
|
|
numero_compartilhado: true, numero_compartilhado_total: 2, numero_compartilhado_liberado: false
|
|
},
|
|
{
|
|
id: 2, empresa: 'Empresa Unica', whatsapp: '5511999990001', whatsapp_status: 'confirmado',
|
|
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 80, classificacao: 'bom',
|
|
produto_principal: '', afinidade: 'media',
|
|
numero_compartilhado: false, numero_compartilhado_total: 0, numero_compartilhado_liberado: false
|
|
}
|
|
];
|
|
const tbody = await renderTreatment(app, rows);
|
|
const texts = tbody.children.map(nodeText);
|
|
assert.match(texts[0], /N[uú]mero compartilhado \(2 leads\)/);
|
|
assert.doesNotMatch(texts[1], /compartilhado/i);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test tests/personal-branding.test.mjs`
|
|
Expected: FAIL — no "compartilhado" text is rendered anywhere yet.
|
|
|
|
- [ ] **Step 3: Implement the badge**
|
|
|
|
In `public/js/tratamento.js`, `whatsappStatusCell` currently reads:
|
|
|
|
```js
|
|
function whatsappStatusCell(lead) {
|
|
const status = lead.whatsapp_status || 'nao_consultado';
|
|
const detalhes = [];
|
|
if (lead.whatsapp_numero_resolvido) detalhes.push(`Número confirmado: ${lead.whatsapp_numero_resolvido}`);
|
|
const checkedAt = whatsappCheckedAt(lead.whatsapp_checked_at);
|
|
if (checkedAt) detalhes.push(`Consultado em ${checkedAt}`);
|
|
return h('div', { class: 'whatsapp-verification' },
|
|
h('span', { class: `badge whatsapp-${status}` }, WHATSAPP_STATUS_LABELS[status] || status),
|
|
detalhes.length ? h('div', { class: 'sub' }, detalhes.join(' · ')) : null);
|
|
}
|
|
```
|
|
|
|
Change to:
|
|
|
|
```js
|
|
function whatsappStatusCell(lead) {
|
|
const status = lead.whatsapp_status || 'nao_consultado';
|
|
const detalhes = [];
|
|
if (lead.whatsapp_numero_resolvido) detalhes.push(`Número confirmado: ${lead.whatsapp_numero_resolvido}`);
|
|
const checkedAt = whatsappCheckedAt(lead.whatsapp_checked_at);
|
|
if (checkedAt) detalhes.push(`Consultado em ${checkedAt}`);
|
|
return h('div', { class: 'whatsapp-verification' },
|
|
h('span', { class: `badge whatsapp-${status}` }, WHATSAPP_STATUS_LABELS[status] || status),
|
|
numeroCompartilhadoBadge(lead),
|
|
detalhes.length ? h('div', { class: 'sub' }, detalhes.join(' · ')) : null);
|
|
}
|
|
|
|
function numeroCompartilhadoBadge(lead) {
|
|
if (!lead.numero_compartilhado) return null;
|
|
const classe = lead.numero_compartilhado_liberado ? 'numero-compartilhado-liberado' : 'numero-compartilhado';
|
|
const texto = lead.numero_compartilhado_liberado
|
|
? `Compartilhado (liberado, ${lead.numero_compartilhado_total} leads)`
|
|
: `Número compartilhado (${lead.numero_compartilhado_total} leads)`;
|
|
return h('span', { class: `badge ${classe}` }, texto);
|
|
}
|
|
```
|
|
|
|
In the lead detail panel, the `linha(...)` calls currently include (around line 526-528):
|
|
|
|
```js
|
|
linha('Status WhatsApp', WHATSAPP_STATUS_LABELS[lead.whatsapp_status] || lead.whatsapp_status || 'Não consultado'),
|
|
linha('Número WhatsApp confirmado', lead.whatsapp_numero_resolvido),
|
|
linha('WhatsApp consultado em', whatsappCheckedAt(lead.whatsapp_checked_at)),
|
|
```
|
|
|
|
Add one more line right after:
|
|
|
|
```js
|
|
linha('Status WhatsApp', WHATSAPP_STATUS_LABELS[lead.whatsapp_status] || lead.whatsapp_status || 'Não consultado'),
|
|
linha('Número WhatsApp confirmado', lead.whatsapp_numero_resolvido),
|
|
linha('WhatsApp consultado em', whatsappCheckedAt(lead.whatsapp_checked_at)),
|
|
linha('Número compartilhado com outro lead', lead.numero_compartilhado
|
|
? `${lead.numero_compartilhado_total} leads${lead.numero_compartilhado_liberado ? ' (liberado)' : ''}`
|
|
: 'Não'),
|
|
```
|
|
|
|
In `public/css/app.css`, near the existing badge variants (around line 622):
|
|
|
|
```css
|
|
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
|
|
```
|
|
|
|
Add right after:
|
|
|
|
```css
|
|
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
|
|
.badge.numero-compartilhado { background: color-mix(in srgb, var(--critical) 14%, transparent); color: var(--critical); }
|
|
.badge.numero-compartilhado-liberado { background: var(--grid); color: var(--ink-2); }
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test tests/personal-branding.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add public/js/tratamento.js public/css/app.css tests/personal-branding.test.mjs
|
|
git commit -m "Show shared-number badge in the treatment table and lead detail"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Configurações review section
|
|
|
|
**Files:**
|
|
- Modify: `public/index.html` (new section after the opt-out `<details>`, around line 420)
|
|
- Modify: `public/js/app.js:242-291` (new list-loading/action functions, wire into settings-modal open)
|
|
- Modify: `tests/campaign-ui.test.mjs:1-190` (two default-mock additions), `tests/personal-branding.test.mjs:70-90` (one default-mock addition), plus a new interaction test
|
|
- Test: `tests/personal-branding.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/outreach/shared-numbers`, `POST/DELETE /api/outreach/shared-numbers/:numero/liberar` (Task 4).
|
|
- Produces: an admin-only Configurações section listing shared-number groups with inline "Liberar" (number + reason) and "Bloquear de novo" actions.
|
|
|
|
- [ ] **Step 1: Update the test mocks so existing tests keep passing once app.js calls the new endpoint**
|
|
|
|
In `tests/campaign-ui.test.mjs`, the `executeApp` function's default `apiGet` currently has (around line 78-84):
|
|
|
|
```js
|
|
const apiGet = async (url) => {
|
|
gets.push(url);
|
|
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
if (get) return get(url);
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
```
|
|
|
|
Add one line:
|
|
|
|
```js
|
|
const apiGet = async (url) => {
|
|
gets.push(url);
|
|
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/outreach/shared-numbers') return { groups: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
if (get) return get(url);
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
```
|
|
|
|
The same file's `renderCampaign` helper has its own local override (around line 176-180):
|
|
|
|
```js
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
```
|
|
|
|
Add the same line there too:
|
|
|
|
```js
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/outreach/shared-numbers') return { groups: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
```
|
|
|
|
In `tests/personal-branding.test.mjs`, the `executeApp` function's default `apiGet` currently has (around line 79-89):
|
|
|
|
```js
|
|
const apiGet = async (url) => {
|
|
gets.push(url);
|
|
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/whatsapp/status') return statusResponses.length ? statusResponses.shift() : { state: whatsappState };
|
|
if (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null };
|
|
if (get) return get(url);
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
```
|
|
|
|
Add one line:
|
|
|
|
```js
|
|
const apiGet = async (url) => {
|
|
gets.push(url);
|
|
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
|
|
if (url === '/api/settings') return { settings: {} };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/outreach/shared-numbers') return { groups: [] };
|
|
if (url === '/api/whatsapp/status') return statusResponses.length ? statusResponses.shift() : { state: whatsappState };
|
|
if (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null };
|
|
if (get) return get(url);
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Run the full frontend test files to confirm they still pass with the mock-only change**
|
|
|
|
Run: `node --test tests/campaign-ui.test.mjs tests/personal-branding.test.mjs`
|
|
Expected: PASS (no behavior changed yet, just added a mock branch that nothing calls yet).
|
|
|
|
- [ ] **Step 3: Write the failing interaction test**
|
|
|
|
Add to `tests/personal-branding.test.mjs`, after the Task 6 badge test:
|
|
|
|
```js
|
|
test('secao de numeros compartilhados lista grupos e libera/bloqueia via API', async () => {
|
|
const app = await executeApp();
|
|
const groups = [{
|
|
numero: '5511999990009',
|
|
leads: [{ id: 1, empresa: 'Empresa A', cnpj: '' }, { id: 2, empresa: 'Empresa B', cnpj: '' }],
|
|
liberado: false,
|
|
motivo: null
|
|
}];
|
|
app.context.API.get = async (url) => {
|
|
if (url === '/api/outreach/shared-numbers') return { groups };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
|
|
await app.element('btn-settings').click();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const list = app.element('shared-numbers-list');
|
|
assert.match(nodeText(list), /5511999990009/);
|
|
assert.match(nodeText(list), /Empresa A/);
|
|
assert.match(nodeText(list), /Empresa B/);
|
|
|
|
const row = list.children[0];
|
|
const motivoInput = row.children.find((child) => child.tagName === 'INPUT');
|
|
const liberarButton = row.children.find((child) => child.tagName === 'BUTTON');
|
|
motivoInput.value = 'confirmado contato direto';
|
|
await liberarButton.click();
|
|
|
|
assert.deepEqual(app.posts.at(-1), {
|
|
url: '/api/outreach/shared-numbers/5511999990009/liberar',
|
|
body: { motivo: 'confirmado contato direto' }
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it fails**
|
|
|
|
Run: `node --test tests/personal-branding.test.mjs`
|
|
Expected: FAIL — `app.element('shared-numbers-list')` has no children (element/section does not exist yet in `app.js`/`index.html`).
|
|
|
|
- [ ] **Step 5: Add the HTML section**
|
|
|
|
In `public/index.html`, the opt-out section currently ends at (around line 411-420):
|
|
|
|
```html
|
|
<details class="settings-section admin-only">
|
|
<summary>Lista de bloqueio / opt-out</summary>
|
|
<p class="muted">Números nesta lista nunca recebem envios manuais ou automáticos.</p>
|
|
<div class="opt-out-form">
|
|
<label>Número <input type="text" id="optout-numero" placeholder="5511999999999" inputmode="numeric"></label>
|
|
<label>Motivo <input type="text" id="optout-motivo" placeholder="Pedido do contato"></label>
|
|
<button class="btn btn-primary" id="btn-optout-add" type="button">Adicionar</button>
|
|
</div>
|
|
<p id="optout-status" class="muted"></p>
|
|
<div id="optout-list" class="lista"></div>
|
|
</details>
|
|
```
|
|
|
|
Add a new section right after its closing `</details>`:
|
|
|
|
```html
|
|
<details class="settings-section admin-only">
|
|
<summary>Lista de bloqueio / opt-out</summary>
|
|
<p class="muted">Números nesta lista nunca recebem envios manuais ou automáticos.</p>
|
|
<div class="opt-out-form">
|
|
<label>Número <input type="text" id="optout-numero" placeholder="5511999999999" inputmode="numeric"></label>
|
|
<label>Motivo <input type="text" id="optout-motivo" placeholder="Pedido do contato"></label>
|
|
<button class="btn btn-primary" id="btn-optout-add" type="button">Adicionar</button>
|
|
</div>
|
|
<p id="optout-status" class="muted"></p>
|
|
<div id="optout-list" class="lista"></div>
|
|
</details>
|
|
|
|
<details class="settings-section admin-only">
|
|
<summary>Números compartilhados entre leads</summary>
|
|
<p class="muted">Detectados automaticamente quando o mesmo número aparece em mais de um lead (ex.: contabilidade que registra o próprio telefone para clientes). Envio automático e manual ficam bloqueados até você revisar e liberar.</p>
|
|
<p id="shared-numbers-status" class="muted"></p>
|
|
<div id="shared-numbers-list" class="lista"></div>
|
|
</details>
|
|
```
|
|
|
|
- [ ] **Step 6: Add the app.js logic**
|
|
|
|
In `public/js/app.js`, right after the existing `removerOptOut` function and before the `el('btn-optout-add').addEventListener(...)` block (around line 262-270):
|
|
|
|
```js
|
|
async function removerOptOut(numero) {
|
|
try {
|
|
await API.delete(`/api/outreach/opt-outs/${encodeURIComponent(numero)}`);
|
|
el('optout-status').textContent = 'Número removido da lista.';
|
|
await carregarOptOuts();
|
|
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
|
}
|
|
|
|
async function carregarNumerosCompartilhados() {
|
|
const { groups } = await API.get('/api/outreach/shared-numbers');
|
|
const list = el('shared-numbers-list');
|
|
if (!groups.length) {
|
|
list.replaceChildren(h('p', { class: 'muted' }, 'Nenhum número compartilhado detectado.'));
|
|
return;
|
|
}
|
|
list.replaceChildren(...groups.map((group) => {
|
|
const empresas = group.leads.map((lead) => lead.empresa).join(' · ');
|
|
if (group.liberado) {
|
|
let blockButton;
|
|
blockButton = h('button', {
|
|
class: 'btn-danger btn-sm', type: 'button',
|
|
onclick: () => comCarregando(blockButton, () => bloquearNumeroCompartilhado(group.numero))
|
|
}, 'Bloquear de novo');
|
|
return h('div', { class: 'item opt-out-item' },
|
|
h('div', {},
|
|
h('strong', {}, group.numero),
|
|
h('div', { class: 'sub' }, `${group.leads.length} leads: ${empresas}`),
|
|
h('div', { class: 'sub' }, `Liberado: ${group.motivo || 'sem motivo'}`)),
|
|
blockButton);
|
|
}
|
|
const motivoInput = h('input', { type: 'text', placeholder: 'Motivo da liberação' });
|
|
let liberarButton;
|
|
liberarButton = h('button', {
|
|
class: 'btn btn-sm', type: 'button',
|
|
onclick: () => comCarregando(liberarButton, () => liberarNumeroCompartilhado(group.numero, motivoInput.value.trim()))
|
|
}, 'Liberar');
|
|
return h('div', { class: 'item opt-out-item' },
|
|
h('div', {},
|
|
h('strong', {}, group.numero),
|
|
h('div', { class: 'sub' }, `${group.leads.length} leads: ${empresas}`)),
|
|
motivoInput, liberarButton);
|
|
}));
|
|
}
|
|
|
|
async function liberarNumeroCompartilhado(numero, motivo) {
|
|
if (!motivo) {
|
|
el('shared-numbers-status').textContent = 'Informe um motivo para liberar.';
|
|
return;
|
|
}
|
|
try {
|
|
await API.post(`/api/outreach/shared-numbers/${encodeURIComponent(numero)}/liberar`, { motivo });
|
|
el('shared-numbers-status').textContent = 'Número liberado.';
|
|
await carregarNumerosCompartilhados();
|
|
} catch (error) { el('shared-numbers-status').textContent = `Erro: ${error.message}`; }
|
|
}
|
|
|
|
async function bloquearNumeroCompartilhado(numero) {
|
|
try {
|
|
await API.delete(`/api/outreach/shared-numbers/${encodeURIComponent(numero)}/liberar`);
|
|
el('shared-numbers-status').textContent = 'Número bloqueado novamente.';
|
|
await carregarNumerosCompartilhados();
|
|
} catch (error) { el('shared-numbers-status').textContent = `Erro: ${error.message}`; }
|
|
}
|
|
|
|
el('btn-optout-add').addEventListener('click', async () => {
|
|
```
|
|
|
|
Finally, wire the loader into the settings-modal open handler. It currently reads (around line 291):
|
|
|
|
```js
|
|
await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]);
|
|
```
|
|
|
|
Change to:
|
|
|
|
```js
|
|
await Promise.all([carregarOutreach(), carregarOptOuts(), carregarNumerosCompartilhados(), refreshWhatsAppStatus(false, generation)]);
|
|
```
|
|
|
|
- [ ] **Step 7: Run test to verify it passes**
|
|
|
|
Run: `node --test tests/personal-branding.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add public/index.html public/js/app.js tests/campaign-ui.test.mjs tests/personal-branding.test.mjs
|
|
git commit -m "Add Configuracoes review section for shared numbers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Full suite, README note, final check
|
|
|
|
**Files:**
|
|
- Modify: `README.md:37-44` (one bullet in "Automação e segurança comercial")
|
|
|
|
- [ ] **Step 1: Add the README bullet**
|
|
|
|
The section currently reads (lines 39-44):
|
|
|
|
```markdown
|
|
- A automação inicia desligada e deve ser ativada conscientemente pelo administrador.
|
|
- Configure um limite diário baixo e mantenha os intervalos de envio.
|
|
- Faça contato apenas em horários adequados e com uma finalidade legítima.
|
|
- Adicione imediatamente à lista de bloqueio quem pedir para não receber mensagens.
|
|
- Não faça spam, disparos indiscriminados nem compre listas de contatos.
|
|
- A conexão usa integração não oficial por dispositivo vinculado. Mudanças no WhatsApp podem interromper o funcionamento e uso abusivo pode causar bloqueio da conta.
|
|
```
|
|
|
|
Add one bullet after the opt-out line:
|
|
|
|
```markdown
|
|
- A automação inicia desligada e deve ser ativada conscientemente pelo administrador.
|
|
- Configure um limite diário baixo e mantenha os intervalos de envio.
|
|
- Faça contato apenas em horários adequados e com uma finalidade legítima.
|
|
- Adicione imediatamente à lista de bloqueio quem pedir para não receber mensagens.
|
|
- Números que aparecem em mais de um lead (ex.: contabilidade que registra o próprio telefone para clientes) ficam bloqueados para envio automático e manual até você revisar e liberar em **Configurações**.
|
|
- Não faça spam, disparos indiscriminados nem compre listas de contatos.
|
|
- A conexão usa integração não oficial por dispositivo vinculado. Mudanças no WhatsApp podem interromper o funcionamento e uso abusivo pode causar bloqueio da conta.
|
|
```
|
|
|
|
- [ ] **Step 2: Run the complete test suite**
|
|
|
|
Run: `npm test`
|
|
Expected: PASS — every test file in `tests/`, including all files touched by Tasks 1-7.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "Document shared-number blocking in the operator README"
|
|
```
|