DND (Do Not Disturb) Contact List
Automatically stops calling people who ask not to be contacted, and lets customers manage a per-customer block list so those numbers are never dialed by campaigns or test calls.
Glossary
- DND: "Do Not Disturb" a phone number that must not be called for a given customer.
- Contact analysis: the post-call step where an LLM reads the transcript and extracts structured info. (overview)
- Normalized phone: the digits-only form of a number (e.g.
+91 98765-43210→919876543210) used for matching so different formats match the same entry.- Source: how a DND entry was added
analysis(auto),manual(customer), oradmin.
Architecture Overview
How a contact ends up on the list and how calls are blocked:
How It Works (Step-by-Step)
1. Detection (Analysis service, Python)
- Triggered when a call's analysis runs. In the combined analysis prompt there is a dedicated "TASK 3 Do-Not-Contact Detection" that returns
dnd_detection: { wants_no_further_contact, dnd_reason }. - File:
backend/Analysis/src/pipeline/conversation_analysis.py(_build_dnd_detection_schema,_build_combined_system_prompt). backend/Analysis/src/pipeline/run.pymerges the result into the features written toContact.analysisaswants_no_further_contact(+dnd_reason).- Runs on every analyzed call regardless of the customer's chosen schema. Defaults to
falsewhen the AI doesn't run (too few messages / failure). - It only flags a clear opt-out ("don't call me", "remove me", "stop calling"). Plain "not interested" is NOT enough.
2. Auto-add (Client backend)
- Triggered by the Analysis service calling
POST /analysis-ready. - File:
backend/client_backend/src/controllers/webhook.controller.ts(analysisReady). Ifanalysis.wants_no_further_contact === true, it callsdndContactService.autoAddFromAnalysis(...)asynchronously (a DND write failure never fails the hook). - Result: a
DndContactwithsource: "analysis", scoped to the contact'scustomerUID.
3. Manual add / view (Customer)
- UI:
app/src/pages/customer/DndContactsPage.tsx→dndService→POST /dnd-contacts. - Service:
dndContactService.manualAdd(...)validates + normalizes the phone and creates asource: "manual"entry owned by the customer.
4. Enforcement campaign calls
- File:
backend/client_backend/src/services/calling.service.ts(fillCallSlots). Before dialing each contact it callsdndContactService.isOnDndList(customerUID, phone). - On match: contact
status→skipped,lastError→"Skipped: contact on DND list", and the loopcontinues (no retry, no billing, no dispatch). - Fail-safe: if the DND lookup throws, the contact is skipped for that cycle rather than risk calling a DND number.
5. Enforcement test calls
- File:
backend/client_backend/src/controllers/agent.controller.ts(testAgent,testAgentFromHeader). Checks DND before creating a Contact. - On match:
403 { code: "CONTACT_ON_DND" }. UI surfaces this in the test-call dialog (AgentsPage.tsx).
File Reference
| File | Role | Key Functions |
|---|---|---|
Analysis/src/pipeline/conversation_analysis.py | Adds DND detection to the analysis LLM call | _build_dnd_detection_schema, _build_combined_system_prompt |
Analysis/src/pipeline/run.py | Merges detection into Contact.analysis | run_analysis |
client_backend/src/models/DndContact.ts | Mongoose schema + IDndContact | |
client_backend/src/services/dndContact.service.ts | Business logic + matching | isOnDndList, autoAddFromAnalysis, manualAdd, list, updateManual, removeManual |
client_backend/src/controllers/dndContact.controller.ts | API layer for customer CRUD | listDndContacts, addDndContact, updateDndContact, deleteDndContact |
client_backend/src/routes/dndContact.routes.ts | Routes, gated by dnd_list feature | |
client_backend/src/controllers/webhook.controller.ts | Auto-add on analysis-ready | analysisReady |
client_backend/src/services/calling.service.ts | Campaign skip | fillCallSlots |
client_backend/src/controllers/agent.controller.ts | Test-call block | testAgent, testAgentFromHeader |
app/src/pages/customer/DndContactsPage.tsx | Customer DND list UI | |
app/src/services/dnd.service.ts | Frontend API client | list, add, update, remove |
Data Model
Collection: DndContact. Scoped per customer (customerUID).
Key index: unique compound { customerUID: 1, normalizedPhone: 1 } prevents duplicates per customer and makes auto-add idempotent.
API Endpoints
All under verifyJwt + requireFeatureAccess("dnd_list"). All scoped to the calling customer (req.userId).
List DND contacts
- Method:
GETPath:/dnd-contacts - Query:
page(default 1),limit(default 20, max 100),search(phone) - Response:
{ success, items: [...], total, page, limit }. Each item includes server-resolvedcampaignName,agentName, andcanModify(true only for the caller's ownmanualentries).
Add DND contact (manual)
- Method:
POSTPath:/dnd-contacts - Body:
{ "phone": "9876543210", "name": "optional", "reason": "optional" } - Response:
201with the created record.400for invalid phone,409if already on the list.
Edit DND contact (own manual only)
- Method:
PATCHPath:/dnd-contacts/:id - Body:
{ "reason": "...", "name": "..." }(phone/source/timestamps are immutable) - Response:
200.403if the entry isn't amanualentry created by the caller.
Remove DND contact (own manual only)
- Method:
DELETEPath:/dnd-contacts/:id - Response:
200.403if not the caller's ownmanualentry.
There is intentionally no endpoint to edit/remove
analysisoradminentries they are tamper-resistant by design.
Access Control (tiered)
The feature is gated by the dnd_list feature string through the standard two-layer model:
- Superadmin adds
dnd_listto an Organization's plan (shows as "Do Not Disturb List" inOrgManagementPage). - Admin assigns
dnd_listto a customer (CustomerManagementPage). - Customer then sees the "Do Not Disturb" nav item and pages.
Superadmin bypasses all checks; Admin passes after the org-plan check. See AUTH-SYSTEM.md.
How It Decides (examples)
wants_no_further_contact is set true only for a clear opt-out:
| Caller says | Detected? | Why |
|---|---|---|
| "Please don't call me again." | ✅ true | Explicit opt-out |
| "Remove my number from your list." | ✅ true | Explicit opt-out |
| "Stop calling me / put me on DND." | ✅ true | Explicit opt-out |
| "I'm not interested right now." | ❌ false | No request to stop contact |
| "Call me next week instead." | ❌ false | Wants contact, just later |
| "Who gave you my number?" (annoyed) | ❌ false | Complaint, not an opt-out request |
How to Test (End-to-End)
Prerequisites: dev stack running (Docker), customer has the dnd_list feature, an agent + a verified test number.
- Auto-add via call: place a test call, and during the call say "don't call me again". After analysis completes, open Do Not Disturb the number appears with source Auto (call) and a reason.
- Skip on campaign: create a 1-contact campaign for that number, run it. The contact shows skipped with reason "On DND list" (and a DND badge in History).
- Block on test call: try a test call to that number → blocked with "This contact is on your Do Not Disturb list and cannot be called."
- Manual add: click Add Contact, enter a number → appears with source Manual and is editable/removable (only by you).
- Verify in DB:
db.DndContact.find({ customerUID: "<uid>" })one document per number, unique on{customerUID, normalizedPhone}.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Contact not auto-added after a clear opt-out | Analysis didn't run (too few messages) or intent wasn't explicit | Confirm Contact.analysis.wants_no_further_contact; detection only fires on clear opt-outs |
| Campaign called a DND number anyway | Number format mismatch | Both storage and matching normalize via normalizePhone; confirm the stored normalizedPhone matches the dialed number |
| Restart picker shows nothing / "Restart 0 selected" | DND-skipped contacts have status: skipped + callStatus: null | Fixed: campaign.controller.ts includes them in the skipped bucket and restart filter |
| Test call blocked but should be allowed | Number is on the customer's DND list | Remove it (manual entries only) or check why it was auto-added |
Invalid feature: "dnd_list" on save | Backend running stale build without the feature | Ensure the backend is on a branch/build where dnd_list is in AVAILABLE_FEATURES |
Design Notes ("why")
- Per-customer scope: one customer's DND never blocks another.
orgUIDis stored only for admin visibility. - Detection is built-in, not schema-driven: it works even if a customer never added a DND field to their analysis schema, and adds no extra LLM call (folded into the existing combined analysis call).
- Tamper-resistant: customers cannot remove auto/admin entries, so people who opted out stay protected. Only self-created manual entries are editable/removable.
- Idempotent auto-add: the unique index + upsert means repeat detections update origin metadata but never duplicate an entry or lose the earliest
addedAt.