Fix final-review findings: last-mile shared-number recheck, symmetric canonical key, per-row treatment query, JS/SQL agreement test

The last-mile validator in campaign-worker.mjs revalidated opt-out/inbound/
dedupe right before sending but never rechecked shared-number state, so
re-blocking a released number after scheduling didn't stop the send.

equivalentPhoneNumbers isn't symmetric for all 8/9-digit pairs, which made
isNumberSharedAcrossLeads and listSharedNumberGroups disagree for a 12-digit
local outside the 6-9 range vs its 13-digit counterpart: one side of the pair
could be enforcement-blocked while invisible and unreleasable in the review
screen. A new canonicalPhoneKey/canonicalPhoneVariants pair in phone.mjs
(plus SQL mirrors) makes the matching relation symmetric by construction.

listSharedNumberGroups did a full leads-table scan on every listLeads/getLead
call, the app's primary paginated screen. Replaced with per-row correlated
subqueries against idx_leads_whatsapp so cost tracks the page, not the table.

normalizedSql/equivalentSql (SQL) duplicated equivalentPhoneNumbers (JS) with
no test proving they agreed; moved into phone.mjs alongside their canonical
counterparts and added tests/phone.test.mjs asserting agreement across a
shared number set, including the asymmetric case.
This commit is contained in:
Gustavo
2026-07-31 20:31:31 -03:00
parent cb03597435
commit afa350de5e
10 changed files with 573 additions and 52 deletions
+31
View File
@@ -576,6 +576,37 @@ test('numero compartilhado por mais de um lead fica fora da fila ate liberacao m
db.close();
});
// 557533334444 vs 5575933334444: equivalentPhoneNumbers so relaciona o segundo
// com o primeiro. Os dois precisam ficar fora da fila.
test('par 12/13 digitos assimetrico fica fora da fila nos dois sentidos', async () => {
const { db, campaign } = setupCampaign();
const doze = insertLead(db, { key: 'assim-12', whatsapp: '557533334444', score: 100 });
const treze = insertLead(db, { key: 'assim-13', whatsapp: '5575933334444', score: 99 });
const solo = insertLead(db, { key: 'assim-solo', whatsapp: '5575992220000', score: 80 });
for (const numero of ['557533334444', '5575933334444', '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')
});
assert.deepEqual(listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id), [solo]);
addSharedNumberOverride(db, '557533334444', 'contabilidade confirmada');
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')
});
assert.deepEqual(
listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id).sort((a, b) => a - b),
[doze, treze, 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 });
+94 -1
View File
@@ -6,8 +6,10 @@ import path from 'node:path';
import { Worker } from 'node:worker_threads';
import {
addOptOut,
addSharedNumberOverride,
openDb,
recoverPendingOutreach,
removeSharedNumberOverride,
reserveOutreachSend,
saveWhatsAppVerification,
setSetting
@@ -448,7 +450,11 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu
['envio ativo do mesmo produto', 'duplicate_product_send', ({ db, campaign, item }) => db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')`
).run(item.leadId, campaign.produto, item.numero)]
).run(item.leadId, campaign.produto, item.numero)],
['numero virou compartilhado', 'shared_number', ({ db, item }) => db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-compartilhado', 'Outro lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(item.numero)]
];
for (const [name, cancelReason, mutate] of cases) {
@@ -495,6 +501,93 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu
db.close();
});
// Cenario real do operador: numero compartilhado liberado em Configuracoes,
// item entra na fila e e agendado, operador reconsidera e clica "Bloquear de
// novo". Sem a revalidacao de ultima milha o envio ja agendado sairia mesmo
// assim, porque a checagem de compartilhamento so acontecia na selecao.
test('libera, agenda e rebloqueia numero compartilhado cancela o envio ja agendado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [name, reblock, expected] of [
['override mantido', false, { tentados: 1, enviados: 1, erros: 0, pulados: 0 }],
['override removido antes do envio', true, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }]
]) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 600 });
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-600-b', 'Cliente da mesma contabilidade', ?, 'restaurantes', 1)`
).run(item.numero);
addSharedNumberOverride(db, item.numero, 'contabilidade confirmada');
if (reblock) assert.equal(removeSharedNumberOverride(db, item.numero), true, name);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, expected, name);
assert.equal(whatsapp.providerCalls, reblock ? 0 : 1, name);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
reblock ? { status: 'cancelled', cancel_reason: 'shared_number' } : { status: 'sent', cancel_reason: null },
name
);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, reblock ? 0 : 1, name);
db.close();
}
});
// Par assimetrico: 557533334444 (local '33334444', fora da faixa 6-9) e
// 5575933334444. equivalentPhoneNumbers so relaciona o segundo com o primeiro,
// entao antes da chave canonica o lead de 12 digitos passava batido aqui.
test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, outro] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-fila', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-outro', 'Lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(outro);
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'shared_number' },
queued
);
db.close();
}
});
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [outcome, queueStatus, sendStatus] of [
+42
View File
@@ -131,6 +131,48 @@ test('listSharedNumberGroups agrupa por equivalencia e reflete override', () =>
db.close();
});
// equivalentPhoneNumbers nao e simetrica: 5575933334444 expande para
// 557533334444 (remove o nono digito sem condicao), mas 557533334444 nao volta
// para 5575933334444 (o local '33334444' esta fora da faixa 6-9). O par
// precisa ser detectado, agrupado e bloqueado nos dois sentidos.
test('par 12/13 digitos assimetrico compartilha numero nos dois sentidos', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'assim-a', empresa: 'Empresa A', whatsapp: '557533334444' }));
insert(sampleLead({ dedup_key: 'assim-b', empresa: 'Empresa B', whatsapp: '5575933334444' }));
insert(sampleLead({ dedup_key: 'assim-solo', empresa: 'Empresa Solo', whatsapp: '5511999990001' }));
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(database.isNumberSharedAcrossLeads(db, numero), true, numero);
assert.deepEqual(
database.leadsSharingNumber(db, numero).map((lead) => lead.empresa).sort(),
['Empresa A', 'Empresa B'],
numero
);
}
assert.equal(database.isNumberSharedAcrossLeads(db, '5511999990001'), false);
const groups = database.listSharedNumberGroups(db);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].leads.map((lead) => lead.empresa).sort(), ['Empresa A', 'Empresa B']);
// Envio manual bloqueado pelas duas formas, e liberado pelas duas assim que o
// operador revisa o grupo unico da tela de Configuracoes.
for (const numero of ['557533334444', '5575933334444']) {
assert.throws(
() => reserveOutreachSend(db, { numero, mensagem: 'oi' }),
(error) => error.statusCode === 409,
numero
);
}
database.addSharedNumberOverride(db, groups[0].numero, 'contabilidade confirmada');
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(database.isSharedNumberOverridden(db, numero), true, numero);
assert.ok(reserveOutreachSend(db, { numero, mensagem: 'oi' }) > 0, numero);
}
db.close();
});
test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-'));
const dbPath = path.join(tempDir, 'legacy.sqlite');
+112
View File
@@ -175,3 +175,115 @@ test('listagem e detalhe marcam numero compartilhado por mais de um lead', async
assert.equal(refreshed.numero_compartilhado_liberado, true);
db.close();
});
// Par assimetrico de equivalentPhoneNumbers (557533334444 / 5575933334444):
// os dois lados precisam aparecer marcados na tela de tratamento.
test('listagem marca os dois lados de um par 12/13 digitos assimetrico', async () => {
const { openDb } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
const doze = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-12', 'Empresa 12 digitos', '557533334444', 1)`
).run().lastInsertRowid);
const treze = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-13', 'Empresa 13 digitos', '5575933334444', 1)`
).run().lastInsertRowid);
const rows = new Map(listLeads(db).rows.map((row) => [row.id, row]));
for (const id of [doze, treze]) {
assert.equal(rows.get(id).numero_compartilhado, true, String(id));
assert.equal(rows.get(id).numero_compartilhado_total, 2, String(id));
assert.equal(getLead(db, id).numero_compartilhado, true, String(id));
}
db.close();
});
// A versao anterior carregava TODOS os leads com telefone em JS a cada
// listagem. Agora o estado sai de subconsultas correlacionadas por linha: o
// plano precisa usar idx_leads_whatsapp e a listagem paginada nao pode custar
// proporcional ao tamanho da tabela.
test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira', async () => {
const { openDb } = await import('../src/db.mjs');
const { listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
db.exec(`
WITH RECURSIVE sequence(value) AS (
VALUES(1) UNION ALL SELECT value + 1 FROM sequence WHERE value < 60000
)
INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
SELECT 'ruido-' || value, 'Ruido ' || value, '5511' || printf('%08d', value), 1, 4, 0
FROM sequence;
`);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-compartilhado-a', 'Compartilhado A', '5575999091780', 1, 1, 100)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-compartilhado-b', 'Compartilhado B', '557599091780', 1, 1, 99)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-unico', 'Unico', '5575992220000', 1, 1, 98)`
).run();
// Conta TODAS as linhas que chegam ao JS durante a listagem: a implementacao
// antiga trazia os 60 mil leads com telefone para montar o indice em memoria.
const trace = { sql: null, args: null, rowsRead: 0 };
const tracedDb = new Proxy(db, {
get(target, property) {
if (property !== 'prepare') {
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
}
return (sql) => {
const statement = target.prepare(sql);
if (sql.includes('numero_compartilhado_total_raw')) trace.sql = sql;
return new Proxy(statement, {
get(stmt, name) {
if (name === 'all') {
return (...args) => {
const rows = stmt.all(...args);
trace.rowsRead += rows.length;
if (sql.includes('numero_compartilhado_total_raw')) trace.args = args;
return rows;
};
}
const value = stmt[name];
return typeof value === 'function' ? value.bind(stmt) : value;
}
});
};
}
});
const startedAt = performance.now();
const rows = listLeads(tracedDb, { pageSize: 3 }).rows;
const elapsedMs = performance.now() - startedAt;
assert.deepEqual(
rows.map((row) => [row.empresa, row.numero_compartilhado, row.numero_compartilhado_total]),
[
['Compartilhado A', true, 2],
['Compartilhado B', true, 2],
['Unico', false, 0]
]
);
assert.ok(
trace.rowsRead <= 10,
`listagem trouxe ${trace.rowsRead} linhas para o JS com pageSize 3 (base de 60003 leads)`
);
assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`);
// Prova estrutural na consulta real: a subconsulta correlacionada procura os
// irmaos pelo indice do telefone, e a pagina e limitada no SQL.
assert.ok(trace.sql, 'consulta real de listagem nao foi observada');
assert.match(trace.sql, /LIMIT \? OFFSET \?/);
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
.map((row) => row.detail).join('\n');
assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan);
assert.doesNotMatch(plan, /SCAN sharing/i, plan);
db.close();
});
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import {
canonicalKeySql,
canonicalPhoneKey,
canonicalPhoneVariants,
canonicalVariantSql,
equivalentPhoneNumbers,
equivalentSql,
normalizePhone,
normalizedSql
} from '../src/phone.mjs';
// A regra 8/9 digitos existe duas vezes: em JS (equivalentPhoneNumbers /
// canonicalPhoneKey, usadas pelo envio manual e pela revalidacao de ultima
// milha) e em SQL (equivalentSql / canonicalKeySql, usadas pela selecao de
// candidatos da campanha e pela listagem de tratamento). Se alguem editar uma
// sem a outra, o bloqueio automatico e o manual passam a discordar em silencio.
// Estes testes rodam o mesmo conjunto de numeros pelos dois caminhos.
const NUMBERS = [
'5575999091780', // celular BR de 13 digitos com nono digito
'557599091780', // mesma linha em 8 digitos (local comeca com 9)
'5571993335451', // outro celular de 13 digitos
'557193335451', // fixo/8 digitos que comeca com 9 -> tem par
'557133334444', // fixo de 12 digitos, local comeca com 3 (sem par direto)
'5575933334444', // par assimetrico do numero abaixo
'557533334444', // 12 digitos, local '33334444' fora da faixa 6-9
'551140028922', // 12 digitos, local comeca com 4
'557561234567', // 12 digitos, local comeca com 6 (limite inferior da faixa)
'557551234567', // 12 digitos, local comeca com 5 (logo abaixo da faixa)
'557591234567', // 12 digitos, local comeca com 9 (limite superior da faixa)
'5511987654321', // 13 digitos comum
'11987654321', // sem DDI 55
'5575', // curto demais
'' // vazio
];
function sqlDb() {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE amostra (bruto TEXT)');
return db;
}
function sqlValues(db, numero) {
db.prepare('DELETE FROM amostra').run();
db.prepare('INSERT INTO amostra (bruto) VALUES (?)').run(numero);
return db.prepare(
`SELECT ${normalizedSql('bruto')} AS normalizado,
${equivalentSql('bruto')} AS equivalente,
${canonicalKeySql('bruto')} AS canonico,
${canonicalVariantSql('bruto')} AS variante
FROM amostra`
).get();
}
test('normalizedSql concorda com normalizePhone', () => {
const db = sqlDb();
for (const numero of [...NUMBERS, '+55 (75) 99909-1780', '55 71 9333-5451']) {
assert.equal(sqlValues(db, numero).normalizado, normalizePhone(numero), numero);
}
db.close();
});
test('equivalentSql concorda com equivalentPhoneNumbers em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const equivalentes = equivalentPhoneNumbers(numero);
// equivalentPhoneNumbers devolve [proprio, alternativo?]; equivalentSql
// devolve apenas o "outro lado" (ou o proprio quando nao ha alternativa).
const esperado = equivalentes.length > 1 ? equivalentes[1] : normalizePhone(numero);
assert.equal(sqlValues(db, numero).equivalente, esperado, numero);
}
db.close();
});
test('canonicalKeySql concorda com canonicalPhoneKey em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
assert.equal(sqlValues(db, numero).canonico, canonicalPhoneKey(numero), numero);
}
db.close();
});
test('canonicalKeySql + canonicalVariantSql cobrem o mesmo conjunto de canonicalPhoneVariants', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const { canonico, variante } = sqlValues(db, numero);
const sqlSet = [...new Set([canonico, variante].filter(Boolean))].sort();
const jsSet = [...new Set(canonicalPhoneVariants(numero))].sort();
assert.deepEqual(sqlSet, jsSet, numero);
}
db.close();
});
test('a chave canonica e simetrica onde equivalentPhoneNumbers nao e', () => {
// Caso concreto: A tem 12 digitos com local fora da faixa 6-9, B tem 13.
assert.deepEqual(equivalentPhoneNumbers('557533334444'), ['557533334444']);
assert.deepEqual(equivalentPhoneNumbers('5575933334444'), ['5575933334444', '557533334444']);
assert.equal(canonicalPhoneKey('557533334444'), '557533334444');
assert.equal(canonicalPhoneKey('5575933334444'), '557533334444');
// E os pares 8/9 digitos ja cobertos pela suite continuam batendo entre si.
assert.equal(canonicalPhoneKey('5575999091780'), canonicalPhoneKey('557599091780'));
assert.equal(canonicalPhoneKey('5571993335451'), canonicalPhoneKey('557193335451'));
// Simetria total do conjunto de casamento nas amostras brasileiras.
for (const numero of NUMBERS) {
for (const outro of canonicalPhoneVariants(numero)) {
assert.ok(
canonicalPhoneVariants(outro).includes(normalizePhone(numero)),
`${numero} <-> ${outro}`
);
}
}
// Numeros de linhas diferentes nao podem colidir.
assert.notEqual(canonicalPhoneKey('557533334444'), canonicalPhoneKey('557133334444'));
assert.notEqual(canonicalPhoneKey('5511987654321'), canonicalPhoneKey('551140028922'));
});