From fe70436dbb761e1b9c478f886a1fc7a1eebb049c Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 10:59:29 -0300 Subject: [PATCH] Add shared-number detection and override data layer - Add migration v19: creates outreach_shared_number_overrides table - Implement 7 new data-layer functions: - leadsSharingNumber: find leads sharing a phone number variant - isNumberSharedAcrossLeads: detect if number is shared by 2+ leads - getSharedNumberOverride: retrieve override entry for a number - isSharedNumberOverridden: check if number has an override - addSharedNumberOverride: create or update override with reason - removeSharedNumberOverride: delete override record - listSharedNumberGroups: group leads by shared numbers, reflect override state - Add 4 comprehensive tests covering migration, detection, override crud, and grouping - Update version assertions from 18 to 19 - Fix v17 migration test to properly clean up v19 table for schema reset Co-Authored-By: Claude Sonnet 5 --- src/db.mjs | 76 +++++++++++++++++++++++++++++++++++++++++++++++ tests/db.test.mjs | 71 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/db.mjs b/src/db.mjs index caccb3b..b1f7f6e 100644 --- a/src/db.mjs +++ b/src/db.mjs @@ -643,6 +643,16 @@ const MIGRATIONS = [ 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')) + ); ` ]; @@ -946,6 +956,72 @@ export function removeOptOut(db, numero) { }); } +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) { const numero = normalizePhone(result?.numero); if (!numero) throw new Error('numero obrigatorio'); diff --git a/tests/db.test.mjs b/tests/db.test.mjs index 358ebfd..c48d1cd 100644 --- a/tests/db.test.mjs +++ b/tests/db.test.mjs @@ -58,7 +58,7 @@ test('openDb cria esquema e aplica migrations uma unica vez', () => { ]) { assert.ok(tables.includes(table), `tabela ${table} ausente`); } - assert.equal(version, 18); + assert.equal(version, 19); for (const table of ['capture_runs', 'extractions']) { const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name); assert.ok(columns.includes('owner_token')); @@ -69,6 +69,68 @@ test('openDb cria esquema e aplica migrations uma unica vez', () => { db.close(); }); +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(); +}); + 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'); @@ -94,7 +156,7 @@ test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => { db = null; db = openDb(dbPath); - assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18); + assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 19); assert.deepEqual({ ...db.prepare('SELECT dedup_key, empresa FROM leads').get() }, { dedup_key: 'legacy-v14', empresa: 'Empresa Legada' @@ -152,6 +214,7 @@ test('v17 corrige trigger v16 ja aplicada sem bloquear agendamento legitimo', () db.exec(` DELETE FROM schema_migrations WHERE version > 16; + DROP TABLE IF EXISTS outreach_shared_number_overrides; DROP TRIGGER outreach_campaign_queue_payload_immutable; CREATE TRIGGER outreach_campaign_queue_payload_immutable BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue @@ -186,7 +249,7 @@ test('v17 corrige trigger v16 ja aplicada sem bloquear agendamento legitimo', () assert.doesNotThrow(() => db.prepare( "UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?" ).run('2026-07-30T09:30:00.000Z', queueId)); - assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18); + assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 19); } finally { db.close(); } @@ -688,6 +751,7 @@ test('reset apaga outreach antes de leads e preserva opt-outs', () => { db.prepare("INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (1, 1, 'respondeu')").run(); db.prepare("INSERT INTO outreach_appointments (lead_id, campaign_id, kind) VALUES (1, 1, 'demonstracao')").run(); 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); assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0); assert.equal(db.prepare('SELECT COUNT(*) total FROM leads').get().total, 0); @@ -702,6 +766,7 @@ test('reset apaga outreach antes de leads e preserva opt-outs', () => { } assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaigns').get().total, 1); assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_opt_outs').get().total, 1); + assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_shared_number_overrides').get().total, 1); db.close(); });