Add design spec for shared-number detection in outreach
Detects when a WhatsApp number is shared by multiple leads (e.g. an accountant's phone reused across client CNPJs) and blocks automatic and manual outreach to all of them until an operator reviews and releases the number.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# Shared Number Detection Design
|
||||
|
||||
## Goal
|
||||
|
||||
Stop automatic and manual outreach from sending multiple messages to a single WhatsApp number that is shared by more than one lead in the base (the common case: an accountant's phone registered as the contact for several client CNPJs). Surface the sharing to the operator so it can be reviewed and, if it turns out not to be a third party, explicitly released.
|
||||
|
||||
## Background
|
||||
|
||||
`leads.dedup_key` is derived from CNPJ + phone + company + city, so two different companies (different CNPJs) that share the same registered phone number produce two distinct lead rows. Neither the campaign queue (`src/outreach/campaign-queue.mjs`) nor manual send (`reserveOutreachSend` in `src/db.mjs`) currently checks for this: each lead is deduplicated against its own send history and the opt-out list, not against other leads' phone numbers. This let three separate leads pointing at the same accounting-office number each queue and send a message.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Work only in `MAQUINA COMERCIAL PESSOAL`.
|
||||
- A number counts as shared when it appears on 2+ leads in the base, regardless of whether the CNPJs differ.
|
||||
- Brazilian mobile variants with and without the ninth digit represent the same number for this check (reuse the existing equivalence rules from `src/phone.mjs`, already used for opt-out and WhatsApp verification).
|
||||
- When a number is shared, block sending to **every** lead that shares it (not just the extras) — both from the automatic campaign queue and from manual send — until an operator explicitly reviews and releases that number.
|
||||
- Do not silently auto-resolve; the operator decides per number.
|
||||
|
||||
## Data Model
|
||||
|
||||
New table `outreach_shared_number_overrides`, same shape as `outreach_opt_outs`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE outreach_shared_number_overrides (
|
||||
numero TEXT PRIMARY KEY,
|
||||
motivo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
`numero` stores one normalized representative of the equivalence group (same convention as `addOptOut`: `equivalentPhoneNumbers(numero)[0]`). Presence of a row means the operator reviewed that number and decided sending is allowed despite the sharing.
|
||||
|
||||
No new column is added to `leads`. Sharing is computed on demand by comparing `leads.whatsapp` values through the existing equivalence rules — the base is personal-scale (thousands of rows, one operator), so a generated/indexed column is not justified.
|
||||
|
||||
## Shared Logic
|
||||
|
||||
The 8/9-digit equivalence expansion currently lives twice as SQL-fragment builders inside `src/outreach/campaign-queue.mjs` (`normalizedSql`, `equivalentSql`). Move them to `src/phone.mjs` so they can be reused by the campaign queue, the manual-send path, and the treatment list query without duplicating the expression.
|
||||
|
||||
Add to `src/db.mjs` (co-located with `isOptedOut`/`addOptOut`, which follow the same pattern):
|
||||
|
||||
- `leadsSharingNumber(db, numero)` — returns `{id, empresa, cnpj}` rows for every lead whose phone is equivalent to `numero`.
|
||||
- `isNumberSharedAcrossLeads(db, numero)` — `leadsSharingNumber(...).length >= 2`.
|
||||
- `isSharedNumberOverridden(db, numero)` / `addSharedNumberOverride(db, numero, motivo)` / `removeSharedNumberOverride(db, numero)` — same shape as the opt-out functions.
|
||||
- `listSharedNumberGroups(db)` — scans leads with a non-empty phone, buckets them by equivalence group (same technique already used by `listConfirmedWhatsAppNumbers`), keeps groups with 2+ leads, and attaches override status/reason. Powers both the review screen and the treatment-list badge.
|
||||
|
||||
## Blocking Points
|
||||
|
||||
**Campaign queue** (`src/outreach/campaign-queue.mjs`, `candidateQuery`): add one more `WHERE` condition using the moved `equivalentSql` helper — a lead is a candidate only if fewer than 2 leads share its equivalent number, or an override row exists for that number. This keeps the exclusion inside the existing single SQL statement instead of post-filtering in JS.
|
||||
|
||||
**Manual send** (`reserveOutreachSend` in `src/db.mjs`): after the existing opt-out check, reject with the same `statusCode = 409` pattern if `isNumberSharedAcrossLeads(...)` is true and no override exists.
|
||||
|
||||
**Manual send route** (`src/server/routes/outreach.mjs`, `POST /api/outreach/enviar-manual`): mirror the existing fast-fail opt-out check (line ~85) with an equivalent shared-number check, returning a clear 409 (`"numero compartilhado por varios leads - revise em Configuracoes antes de enviar"`) before even attempting `whatsapp.sendText`. `reserveOutreachSend`'s check remains the authoritative guard against races; the route check just gives a faster, clearer failure.
|
||||
|
||||
## Review Screen
|
||||
|
||||
New section in **Configurações**, next to the existing opt-out list, listing groups from `listSharedNumberGroups`: the number and the companies/leads that share it. Each group has a **Liberar** action (with a required reason) that calls `addSharedNumberOverride`, and, once liberated, a **Bloquear de novo** action that calls `removeSharedNumberOverride`. Same admin-only permission pattern as the opt-out endpoints.
|
||||
|
||||
New routes in `src/server/routes/outreach.mjs`:
|
||||
- `GET /api/outreach/shared-numbers` → `{ groups: listSharedNumberGroups(db) }`
|
||||
- `POST /api/outreach/shared-numbers/:numero/liberar` → admin only, body `{ motivo }`
|
||||
- `DELETE /api/outreach/shared-numbers/:numero/liberar` → admin only
|
||||
|
||||
## Treatment List Indicator
|
||||
|
||||
`listLeads` and `getLead` in `src/treatment/queries.mjs` add a `numero_compartilhado` (boolean) and `leads_com_mesmo_numero` (count) field per row, computed the same way as the campaign-queue condition. The treatment table (`public/js/tratamento.js`) shows a warning badge next to the existing WhatsApp status badge when `numero_compartilhado` is true — e.g. "Número compartilhado (3 leads)" — softened to a neutral badge when the number has been liberated via override.
|
||||
|
||||
## Testing
|
||||
|
||||
Automated regressions must prove:
|
||||
|
||||
- Two leads with the exact same digits are both blocked from the campaign queue and from manual send.
|
||||
- Two leads whose phones are 8/9-digit equivalents of each other are treated as the same shared number.
|
||||
- A single lead with a unique number is never blocked.
|
||||
- An override releases sending for every lead sharing that number, in both the queue and manual send.
|
||||
- Removing an override re-blocks the number.
|
||||
- The review screen endpoint lists correct groups and reflects override state.
|
||||
- The treatment list marks shared numbers and reflects override state.
|
||||
|
||||
Run focused tests after each change, then the complete Node test suite.
|
||||
Reference in New Issue
Block a user