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.
58 KiB
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 (
equivalentPhoneNumbersinsrc/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 theMIGRATIONSarray) - Modify:
src/db.mjs:940-947(insert new functions afterremoveOptOut, beforesaveWhatsAppVerification) - Modify:
src/db.mjs:679-706intests/db.test.mjsis NOT this task — see the test file edits below instead. - Test:
tests/db.test.mjs
Interfaces:
-
Consumes:
equivalentPhoneNumbers(value)fromsrc/phone.mjs(already imported indb.mjs),transaction(db, fn)(already defined indb.mjs). -
Produces (for later tasks):
leadsSharingNumber(db, numero) -> Array<{id, empresa, cnpj}>isNumberSharedAcrossLeads(db, numero) -> booleangetSharedNumberOverride(db, numero) -> {numero, motivo, created_at} | nullisSharedNumberOverridden(db, numero) -> booleanaddSharedNumberOverride(db, numero, motivo = '') -> {numero, motivo, created_at}removeSharedNumberOverride(db, numero) -> booleanlistSharedNumberGroups(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):
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:
db.prepare("INSERT INTO outreach_opt_outs (numero) VALUES ('5511999990001')").run();
resetLeadsBase(db);
to:
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:
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):
`,
// 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:
`,
// 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:
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:
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
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:
reserveOutreachSendnow 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:
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:
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:
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
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; theoutreach_shared_number_overridestable from Task 1;addSharedNumberOverridefromsrc/db.mjs(test only). -
Produces:
prepareDailyQueuenever 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:
import {
addOptOut,
addSharedNumberOverride,
openDb,
saveWhatsAppVerification
} from '../src/db.mjs';
Then add two tests, near the other prepareDailyQueue tests:
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):
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):
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
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 ofregisterOutreachRoutes(new routes) - Test:
tests/outreach-routes.test.mjs
Interfaces:
-
Consumes:
isNumberSharedAcrossLeads,isSharedNumberOverridden,listSharedNumberGroups,addSharedNumberOverride,removeSharedNumberOverridefromsrc/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-manualreturns 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:
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:
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:
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:
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):
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
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(nearwithWhatsAppState) - Test:
tests/personal-backend.test.mjs
Interfaces:
-
Consumes:
listSharedNumberGroups(db)fromsrc/db.mjs(Task 1). -
Produces: every lead row/detail returned by
listLeads/getLeadgainsnumero_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:
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:
import { getWhatsAppVerification } from '../db.mjs';
to:
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):
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:
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:
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:
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:
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:
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
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 detaillinha(...)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):
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:
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:
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):
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:
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):
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
Add right after:
.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
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):
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:
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):
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:
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):
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:
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:
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):
<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>:
<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):
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):
await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]);
Change to:
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
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):
- 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:
- 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
git add README.md
git commit -m "Document shared-number blocking in the operator README"