diff --git a/src/db.mjs b/src/db.mjs index 620f745..09adaec 100644 --- a/src/db.mjs +++ b/src/db.mjs @@ -929,8 +929,14 @@ export function updateMessageTemplate(db, produto, { mensagem, ativo }) { return db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates WHERE produto = ?').get(produto); } +// Usa canonicalPhoneVariants (e nao equivalentPhoneNumbers): a relacao +// precisa ser simetrica, senao um opt-out registrado numa forma do numero +// (ex.: com o nono digito) pode nao bater contra a outra forma do mesmo +// numero (sem o nono digito) quando o local nao comeca com 6-9 — deixando o +// bloqueio de opt-out furavel dependendo de qual forma foi usada em cada +// ponta. Mesmo raciocinio de leadsSharingNumber/addSharedNumberOverride. export function isOptedOut(db, numero) { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; const placeholders = numbers.map(() => '?').join(', '); return Boolean(db.prepare(`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders})`).get(...numbers)); @@ -941,7 +947,7 @@ export function listOptOuts(db) { } export function addOptOut(db, numero, motivo = '') { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) throw new Error('numero obrigatorio'); return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); @@ -953,7 +959,7 @@ export function addOptOut(db, numero, motivo = '') { } export function removeOptOut(db, numero) { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); diff --git a/src/outreach/campaign-queue.mjs b/src/outreach/campaign-queue.mjs index df7eddc..c3d0ad8 100644 --- a/src/outreach/campaign-queue.mjs +++ b/src/outreach/campaign-queue.mjs @@ -33,9 +33,11 @@ function candidateQuery(confirmed) { // See task-3-report.md for the full investigation and evidence. const leadNumberExpr = normalizedSql('raw.whatsapp'); const leadEquivalentExpr = equivalentSql('raw.whatsapp'); - // Chave canonica + a outra forma dela: par simetrico usado so na deteccao de - // numero compartilhado (opt-out/verificacao/inbound seguem a expansao - // unidirecional de equivalentPhoneNumbers). + // Chave canonica + a outra forma dela: par simetrico usado na deteccao de + // numero compartilhado E no opt-out (a asimetria de equivalentPhoneNumbers + // podia deixar um opt-out registrado numa forma do numero nao bloquear a + // outra forma do mesmo numero). Verificacao/inbound continuam na expansao + // unidirecional de equivalentPhoneNumbers. const leadCanonicalExpr = canonicalKeySql('raw.whatsapp'); const leadCanonicalVariantExpr = canonicalVariantSql('raw.whatsapp'); const leadNumber = 'candidates.lead_number'; @@ -83,7 +85,7 @@ function candidateQuery(confirmed) { ) AND NOT EXISTS ( SELECT 1 FROM outreach_opt_outs opt_out - WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent}) + WHERE opt_out.numero IN (${leadCanonical}, ${leadCanonicalVariant}) ) AND ( NOT EXISTS ( diff --git a/src/outreach/campaign-worker.mjs b/src/outreach/campaign-worker.mjs index 20d12a4..d6d737d 100644 --- a/src/outreach/campaign-worker.mjs +++ b/src/outreach/campaign-worker.mjs @@ -4,6 +4,7 @@ import { } from '../phone.mjs'; import { isNumberSharedAcrossLeads, + isOptedOut, isSharedNumberOverridden, setSetting } from '../db.mjs'; @@ -86,9 +87,11 @@ export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) { let numbers; try { numbers = requireConfirmedNumber(db, row.numero); - if (db.prepare( - `SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders(numbers)}) LIMIT 1` - ).get(...numbers)) { + // isOptedOut usa a chave canonica simetrica (nao a expansao de + // equivalentPhoneNumbers de `numbers` acima, que e assimetrica e podia + // deixar passar um numero bloqueado dependendo de qual forma foi usada + // para registrar o opt-out). + if (isOptedOut(db, row.numero)) { throw permanentPolicyConflict('numero bloqueado por opt-out', 'opt_out'); } if (db.prepare( diff --git a/tests/campaign-queue.test.mjs b/tests/campaign-queue.test.mjs index 87e6308..b1ec47f 100644 --- a/tests/campaign-queue.test.mjs +++ b/tests/campaign-queue.test.mjs @@ -624,3 +624,31 @@ test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila' assert.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false); db.close(); }); + +// Opt-out registrado na outra forma de um par 12/13 digitos assimetrico +// precisa excluir o lead da fila nos dois sentidos (mesma logica de chave +// canonica ja usada para numero compartilhado nesta consulta). +test('opt-out na outra forma de um par 12/13 digitos assimetrico exclui o lead da fila', async () => { + for (const [leadNumero, optOutNumero] of [ + ['557533334444', '5575933334444'], + ['5575933334444', '557533334444'] + ]) { + const { db, campaign } = setupCampaign(); + const lead = insertLead(db, { key: 'assim-optout', whatsapp: leadNumero, score: 100 }); + confirmed(db, leadNumero); + addOptOut(db, optOutNumero, 'pediu para nao receber mais'); + + await prepareDailyQueue({ + db, + whatsapp: noSendWhatsapp(async () => { throw new Error('numero possui cache'); }), + campaignId: campaign.id, + now: new Date('2026-07-29T14:00:00-03:00') + }); + assert.equal( + listCampaignQueue(db, campaign.id).some((row) => row.lead_id === lead), + false, + `${leadNumero} / opt-out em ${optOutNumero}` + ); + db.close(); + } +}); diff --git a/tests/campaign-worker.test.mjs b/tests/campaign-worker.test.mjs index f38f2fc..adc1373 100644 --- a/tests/campaign-worker.test.mjs +++ b/tests/campaign-worker.test.mjs @@ -588,6 +588,51 @@ test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', } }); +// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam +// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma +// do numero podia nao bloquear o envio quando a fila tinha a outra forma. +test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => { + const { runDueCampaignQueue } = await workerModule(); + for (const [queued, optedOutForm] 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-optout', 'Lead na fila', ?, 'restaurantes', 1)` + ).run(queued).lastInsertRowid); + 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); + addOptOut(db, optedOutForm, 'pediu para nao receber mais'); + + const whatsapp = whatsappFake(); + const summary = await runDueCampaignQueue({ + db, + whatsapp, + now: new Date('2026-07-29T12:00:00.000Z'), + log: () => {} + }); + + assert.equal(summary.enviados, 0, 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: 'opt_out' }, + 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 [ diff --git a/tests/personal-backend.test.mjs b/tests/personal-backend.test.mjs index 2bfcc19..5f1a25f 100644 --- a/tests/personal-backend.test.mjs +++ b/tests/personal-backend.test.mjs @@ -69,6 +69,35 @@ test('opt-out bloqueia e remove as formas equivalentes com e sem nono digito', a db.close(); }); +// equivalentPhoneNumbers nao e simetrica para um par 12/13 digitos fora da +// faixa 6-9 (ver phone.mjs). addOptOut/isOptedOut usavam essa expansao +// diretamente: bloquear numa forma do numero podia nao bloquear a outra forma +// do mesmo numero, deixando passar um envio para quem pediu para nao receber. +test('opt-out bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => { + const { openDb, addOptOut, isOptedOut, removeOptOut, reserveOutreachSend } = await import('../src/db.mjs'); + const db = openDb(':memory:'); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) + VALUES ('assim-optout', 'Empresa Assimetrica', '557533334444', 1)` + ).run(); + + addOptOut(db, '5575933334444', 'pedido do contato'); + for (const numero of ['557533334444', '5575933334444']) { + assert.equal(isOptedOut(db, numero), true, numero); + assert.throws( + () => reserveOutreachSend(db, { numero, mensagem: 'oi' }), + (error) => error.statusCode === 409, + numero + ); + } + + assert.equal(removeOptOut(db, '557533334444'), true); + for (const numero of ['557533334444', '5575933334444']) { + assert.equal(isOptedOut(db, numero), false, numero); + } + db.close(); +}); + test('listagem e detalhe derivam o estado da verificacao equivalente mais recente', async () => { const { openDb, saveWhatsAppVerification } = await import('../src/db.mjs'); const { getLead, listLeads } = await import('../src/treatment/queries.mjs');