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:
+20
-9
@@ -5,7 +5,12 @@ import { paths } from './env.mjs';
|
||||
import { segmentLabel } from './scoring.mjs';
|
||||
import { cleanText } from './lead-engine.mjs';
|
||||
import { hashPassword } from './auth.mjs';
|
||||
import { equivalentPhoneNumbers, normalizePhone } from './phone.mjs';
|
||||
import {
|
||||
canonicalPhoneKey,
|
||||
canonicalPhoneVariants,
|
||||
equivalentPhoneNumbers,
|
||||
normalizePhone
|
||||
} from './phone.mjs';
|
||||
|
||||
const MIGRATIONS = [
|
||||
// v1 — esquema inicial
|
||||
@@ -956,8 +961,12 @@ export function removeOptOut(db, numero) {
|
||||
});
|
||||
}
|
||||
|
||||
// Deteccao de numero compartilhado usa canonicalPhoneVariants (e nao
|
||||
// equivalentPhoneNumbers): a relacao precisa ser simetrica para que os dois
|
||||
// lados de um par equivalente sejam bloqueados e aparecam no mesmo grupo de
|
||||
// revisao.
|
||||
export function leadsSharingNumber(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(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);
|
||||
@@ -968,7 +977,7 @@ export function isNumberSharedAcrossLeads(db, numero) {
|
||||
}
|
||||
|
||||
export function getSharedNumberOverride(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return null;
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
return db.prepare(
|
||||
@@ -981,7 +990,7 @@ export function isSharedNumberOverridden(db, numero) {
|
||||
}
|
||||
|
||||
export function addSharedNumberOverride(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(', ');
|
||||
@@ -995,7 +1004,7 @@ export function addSharedNumberOverride(db, numero, motivo = '') {
|
||||
}
|
||||
|
||||
export function removeSharedNumberOverride(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return false;
|
||||
return transaction(db, () => {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
@@ -1007,10 +1016,12 @@ 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: [] });
|
||||
// Agrupar pela chave canonica (e nao pelo conjunto de equivalentes) mantem
|
||||
// as duas formas do mesmo numero no mesmo balde mesmo quando a expansao de
|
||||
// equivalentPhoneNumbers e assimetrica.
|
||||
const key = canonicalPhoneKey(row.whatsapp);
|
||||
if (!key) continue;
|
||||
if (!groups.has(key)) groups.set(key, { numero: normalizePhone(row.whatsapp), leads: [] });
|
||||
groups.get(key).leads.push({ id: row.id, empresa: row.empresa, cnpj: row.cnpj });
|
||||
}
|
||||
return [...groups.values()]
|
||||
|
||||
@@ -2,28 +2,20 @@ import {
|
||||
isOptedOut,
|
||||
saveWhatsAppVerification
|
||||
} from '../db.mjs';
|
||||
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
|
||||
import {
|
||||
canonicalKeySql,
|
||||
canonicalVariantSql,
|
||||
equivalentPhoneNumbers,
|
||||
equivalentSql,
|
||||
normalizePhone,
|
||||
normalizedSql
|
||||
} from '../phone.mjs';
|
||||
import { getCampaign } from './campaign-store.mjs';
|
||||
import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs';
|
||||
|
||||
const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'";
|
||||
const PENDING_QUEUE_STATUSES = "'preview','approved','scheduled','reserved'";
|
||||
|
||||
function normalizedSql(column) {
|
||||
return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`;
|
||||
}
|
||||
|
||||
function equivalentSql(column) {
|
||||
const number = normalizedSql(column);
|
||||
return `CASE
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
|
||||
THEN substr(${number}, 1, 4) || substr(${number}, 6)
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9'
|
||||
THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5)
|
||||
ELSE ${number}
|
||||
END`;
|
||||
}
|
||||
|
||||
function candidateQuery(confirmed) {
|
||||
// lead_number/lead_equivalent are named columns computed in the derived
|
||||
// `candidates` subquery below, purely as a readability device so the rest of
|
||||
@@ -41,8 +33,15 @@ 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).
|
||||
const leadCanonicalExpr = canonicalKeySql('raw.whatsapp');
|
||||
const leadCanonicalVariantExpr = canonicalVariantSql('raw.whatsapp');
|
||||
const leadNumber = 'candidates.lead_number';
|
||||
const leadEquivalent = 'candidates.lead_equivalent';
|
||||
const leadCanonical = 'candidates.lead_canonical';
|
||||
const leadCanonicalVariant = 'candidates.lead_canonical_variant';
|
||||
const verificationFilter = confirmed
|
||||
? "verification.status = 'confirmado'"
|
||||
: "(verification.status IS NULL OR verification.status = 'erro_temporario')";
|
||||
@@ -55,7 +54,9 @@ function candidateQuery(confirmed) {
|
||||
FROM (
|
||||
SELECT raw.*,
|
||||
${leadNumberExpr} AS lead_number,
|
||||
${leadEquivalentExpr} AS lead_equivalent
|
||||
${leadEquivalentExpr} AS lead_equivalent,
|
||||
${leadCanonicalExpr} AS lead_canonical,
|
||||
${leadCanonicalVariantExpr} AS lead_canonical_variant
|
||||
FROM leads raw
|
||||
WHERE raw.segmento = ?
|
||||
) AS candidates
|
||||
@@ -87,12 +88,12 @@ function candidateQuery(confirmed) {
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM leads other
|
||||
WHERE other.whatsapp IN (${leadNumber}, ${leadEquivalent})
|
||||
WHERE other.whatsapp IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||
AND other.id <> candidates.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM outreach_shared_number_overrides ov
|
||||
WHERE ov.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
WHERE ov.numero IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
|
||||
@@ -2,7 +2,11 @@ import {
|
||||
equivalentPhoneNumbers,
|
||||
normalizePhone
|
||||
} from '../phone.mjs';
|
||||
import { setSetting } from '../db.mjs';
|
||||
import {
|
||||
isNumberSharedAcrossLeads,
|
||||
isSharedNumberOverridden,
|
||||
setSetting
|
||||
} from '../db.mjs';
|
||||
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
||||
|
||||
const ACTIVE_SEND_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido'";
|
||||
@@ -103,6 +107,16 @@ export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) {
|
||||
).get(row.lead_id, row.produto)) {
|
||||
throw permanentPolicyConflict('envio ativo para o mesmo lead e produto', 'duplicate_product_send');
|
||||
}
|
||||
// Ultima milha: a selecao de candidatos avaliou o compartilhamento la
|
||||
// atras. Entre o agendamento e o envio o operador pode ter rebloqueado o
|
||||
// numero em Configuracoes, ou uma nova captura pode ter criado um segundo
|
||||
// lead com o mesmo telefone.
|
||||
if (isNumberSharedAcrossLeads(db, row.numero) && !isSharedNumberOverridden(db, row.numero)) {
|
||||
throw permanentPolicyConflict(
|
||||
'numero compartilhado por multiplos leads (revise em Configuracoes antes de enviar)',
|
||||
'shared_number'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error.permanentCampaignPolicy) throw error;
|
||||
const cancelled = db.prepare(
|
||||
|
||||
@@ -12,3 +12,80 @@ export function equivalentPhoneNumbers(value = '') {
|
||||
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
|
||||
return [...new Set(numbers)];
|
||||
}
|
||||
|
||||
// equivalentPhoneNumbers NAO e simetrica: ela so gera a variante de 8 digitos a
|
||||
// partir de um celular de 9 digitos quando o local comeca com 6-9. Um numero
|
||||
// como 557533334444 (local '33334444') nao volta para 5575933334444, embora o
|
||||
// caminho inverso exista — o que fazia um lado do par ser bloqueado e o outro
|
||||
// nao, e escondia o grupo da tela de revisao.
|
||||
//
|
||||
// canonicalPhoneKey resolve isso reduzindo SEMPRE o celular brasileiro de 13
|
||||
// digitos com nono digito para a forma de 12 digitos (mesma regra de remocao ja
|
||||
// usada por equivalentPhoneNumbers). Dois numeros pertencem a mesma linha
|
||||
// quando compartilham a chave canonica — uma relacao simetrica por construcao.
|
||||
export function canonicalPhoneKey(value = '') {
|
||||
const digits = normalizePhone(value);
|
||||
if (!digits.startsWith('55')) return digits;
|
||||
const prefix = digits.slice(0, 4);
|
||||
const local = digits.slice(4);
|
||||
if (local.length === 9 && local.startsWith('9')) return `${prefix}${local.slice(1)}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
// Todas as formas que um numero pode ter armazenado e que reduzem a mesma chave
|
||||
// canonica. O numero informado vem sempre primeiro (o override guarda esse
|
||||
// valor). Usado para casar contra a coluna crua `leads.whatsapp`.
|
||||
export function canonicalPhoneVariants(value = '') {
|
||||
const digits = normalizePhone(value);
|
||||
if (!digits) return [];
|
||||
const key = canonicalPhoneKey(digits);
|
||||
const variants = new Set([digits, key]);
|
||||
if (key.startsWith('55') && key.length === 12) {
|
||||
variants.add(`${key.slice(0, 4)}9${key.slice(4)}`);
|
||||
}
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// Fragmentos SQL equivalentes as funcoes JS acima. Ficam neste modulo para que
|
||||
// exista uma unica origem da regra 8/9 digitos — tests/phone.test.mjs prova que
|
||||
// as duas implementacoes concordam.
|
||||
export function normalizedSql(column) {
|
||||
return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`;
|
||||
}
|
||||
|
||||
// Espelha equivalentPhoneNumbers: alterna entre as duas variantes brasileiras.
|
||||
export function equivalentSql(column) {
|
||||
const number = normalizedSql(column);
|
||||
return `CASE
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
|
||||
THEN substr(${number}, 1, 4) || substr(${number}, 6)
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9'
|
||||
THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5)
|
||||
ELSE ${number}
|
||||
END`;
|
||||
}
|
||||
|
||||
// Espelha canonicalPhoneKey.
|
||||
export function canonicalKeySql(column) {
|
||||
const number = normalizedSql(column);
|
||||
return `CASE
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
|
||||
THEN substr(${number}, 1, 4) || substr(${number}, 6)
|
||||
ELSE ${number}
|
||||
END`;
|
||||
}
|
||||
|
||||
// A outra forma da mesma chave canonica (a de 13 digitos). Junto com
|
||||
// canonicalKeySql cobre as duas formas possiveis de armazenamento, permitindo
|
||||
// `outra.whatsapp IN (chave, variante)` — comparacao contra a coluna crua, que
|
||||
// mantem o uso de idx_leads_whatsapp.
|
||||
export function canonicalVariantSql(column) {
|
||||
const number = normalizedSql(column);
|
||||
return `CASE
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
|
||||
THEN ${number}
|
||||
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12
|
||||
THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5)
|
||||
ELSE ${number}
|
||||
END`;
|
||||
}
|
||||
|
||||
+42
-22
@@ -2,7 +2,27 @@
|
||||
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
|
||||
// define quais leads entram num relatorio/exportacao.
|
||||
|
||||
import { getWhatsAppVerification, listSharedNumberGroups } from '../db.mjs';
|
||||
import { getWhatsAppVerification } from '../db.mjs';
|
||||
import { canonicalKeySql, canonicalVariantSql, normalizedSql } from '../phone.mjs';
|
||||
|
||||
// Estado de numero compartilhado calculado por linha, em SQL, com subconsultas
|
||||
// correlacionadas: o custo acompanha o tamanho da pagina, nao o da tabela
|
||||
// (a versao anterior varria `leads` inteira a cada listagem). A comparacao e
|
||||
// feita contra a coluna crua `leads.whatsapp` para que idx_leads_whatsapp seja
|
||||
// usado, mesma forma ja provada em campaign-queue.mjs.
|
||||
function sharedNumberColumnsSql(column) {
|
||||
const numero = normalizedSql(column);
|
||||
const canonical = canonicalKeySql(column);
|
||||
const variant = canonicalVariantSql(column);
|
||||
return `CASE WHEN ${numero} = '' THEN 0 ELSE (
|
||||
SELECT COUNT(*) FROM leads sharing
|
||||
WHERE sharing.whatsapp IN (${canonical}, ${variant})
|
||||
) END AS numero_compartilhado_total_raw,
|
||||
CASE WHEN ${numero} = '' THEN 0 ELSE (
|
||||
SELECT COUNT(*) FROM outreach_shared_number_overrides ov
|
||||
WHERE ov.numero IN (${canonical}, ${variant})
|
||||
) END AS numero_compartilhado_override_raw`;
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC',
|
||||
@@ -75,25 +95,28 @@ export function listLeads(db, filtro = {}) {
|
||||
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
|
||||
exported_at, captured_at, segments.affinity AS afinidade,
|
||||
${sharedNumberColumnsSql('leads.whatsapp')}
|
||||
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));
|
||||
.map((lead) => withSharedNumberState(withWhatsAppState(db, lead)));
|
||||
|
||||
return { total, page, pageSize, rows };
|
||||
}
|
||||
|
||||
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;
|
||||
const storedLead = db.prepare(
|
||||
`SELECT leads.*, ${sharedNumberColumnsSql('leads.whatsapp')}
|
||||
FROM leads WHERE leads.id = ?`
|
||||
).get(id);
|
||||
const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead)) : null;
|
||||
if (!lead) return null;
|
||||
|
||||
const exportHistory = db.prepare(
|
||||
@@ -144,23 +167,20 @@ function withWhatsAppState(db, lead) {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
function withSharedNumberState(lead) {
|
||||
const {
|
||||
numero_compartilhado_total_raw: totalRaw,
|
||||
numero_compartilhado_override_raw: overrideRaw,
|
||||
...rest
|
||||
} = lead;
|
||||
// Compartilhado = 2+ leads na mesma linha (o proprio lead conta), mesma
|
||||
// semantica de isNumberSharedAcrossLeads.
|
||||
const shared = Number(totalRaw) >= 2;
|
||||
return {
|
||||
...lead,
|
||||
numero_compartilhado: Boolean(shared),
|
||||
numero_compartilhado_total: shared?.total || 0,
|
||||
numero_compartilhado_liberado: shared?.liberado || false
|
||||
...rest,
|
||||
numero_compartilhado: shared,
|
||||
numero_compartilhado_total: shared ? Number(totalRaw) : 0,
|
||||
numero_compartilhado_liberado: shared && Number(overrideRaw) > 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user