callEndedAnalysis
- File
-
lib/workflows/functions/call-ended-analysis.ts - Event
-
call-target/call.ended - Config
-
retries: 0,concurrency: { limit: 20 },rateLimit: { key: "event.data.callId", limit: 1, period: "24h" }
The rate limit keyed on callId guarantees that a call is post-processed at most once — so a duplicate
webhook cannot trigger a second double tap or a second analysis.
Flow
flowchart TD
A["call-target/call.ended"] --> B{"disconnectionReason in\nDOUBLE_TAP_REASONS?"}
B -->|Yes| C["check-double-tap-eligibility"]
C --> D{"First call today\nfor this number\nin this campaign?"}
D -->|No| F["Fall through to analysis"]
D -->|Yes| E{"attempts < maxAttempts?"}
E -->|No| F
E -->|Yes| G["step.sleep 1 minute"]
G --> H["incrementCallTargetRuns"]
H --> I["send call-target/call.requested"]
I --> J["Return doubleTapScheduled"]
B -->|No| K["update-contact-disposition"]
F --> K
K --> L{"disconnectionReason in\nBLACKLISTED_AI_ANALYSIS_REASONS?"}
L -->|Yes| M["Return — analysis skipped"]
L -->|No| N["run-ai-analysis"]
Step 1 — double tap
Applies when disconnectionReason is in DOUBLE_TAP_REASONS: voicemail_reached,
machine_detected, dial_no_answer, user_declined.
check-double-tap-eligibility requires both guards:
-
First call today —
countCallsTodayForTargetPhone(targetPhoneNumber, campaignId)must equal1. More than one call today to that number in that campaign cancels the double tap. -
Attempts remain —
target.attempts < campaign.maxAttempts. WhenmaxAttemptsis null this guard does not apply.
Both reads come from existing repository methods, so the check adds no extra queries beyond the two it needs.
When allowed:
-
step.sleep('double-tap-wait', '1 minute')— long enough that the callee has put the phone down, short enough to still feel like the same attempt. -
incrementCallTargetRuns(campaignId)— the retry is a new in-flight call, and the broadcaster’s cap check must see it. -
step.sendEvent('double-tap', { name: 'call-target/call.requested', … }). -
Return
{ doubleTapScheduled: true, disconnectionReason }— AI analysis is skipped, because the outcome will be analysed after the retry.
When not allowed, execution falls through to the remaining steps rather than returning early. The call still deserves a contact update and, if eligible, an analysis.
Step 2 — contact and registry updates
Runs when a disposition and a subscriptionId are present. Resolves the contact through the phone
registry (TargetPhoneNumbers.contactId for that number in that workspace).
| Outcome | Changes |
|---|---|
DNC ( |
Contact: |
Uncallable disconnection reason |
Registry: |
Disposition |
Contact: |
Anything else |
Contact: |
The step also runs when there is no disposition, as long as the disconnection reason is uncallable
and a subscriptionId is present. TargetPhoneNumbers.updatedAt has no @updatedAt, so the block
write sets it explicitly.
Blocking the registry row is what makes a Do Not Call request, or an uncallable number, permanent
across every campaign, present and future. get_and_lease_due_targets excludes blocked registry
rows so the cron tick never leases them.
Returns { contactUpdated, phoneBlocked, disposition, newState } for observability. A call with no
matching registry row or contact simply reports contactUpdated: false.
Step 3 — analysis skip
If disconnectionReason is in BLACKLISTED_AI_ANALYSIS_REASONS the function returns:
{ "callId": "uuid", "analysisSaved": false, "analysisError": "Call was not connected to the target — analysis skipped" }
voicemail_reached, dial_busy, dial_no_answer, dial_failed,
telephony_provider_unavailable, user_declined, invalid_destination,
telephony_provider_permission_denied
Four of these also appear in DOUBLE_TAP_REASONS, so an eligible double tap returns before reaching
here — but an ineligible one falls through and is caught by this check.
Step 4 — AI analysis
run-ai-analysis calls CallAnalysisService.runAndSave(callId, campaignId).
Transient infrastructure errors are swallowed and returned as data:
const isTransient =
msg.includes('EBUSY') ||
msg.includes('ENOTFOUND') ||
msg.includes('Cannot connect to API') ||
msg.includes('Insufficient funds')
Anything else re-throws, so a genuine bug reaches Sentry instead of being silently recorded as an unanalysed call.
Returns { callId, analysisSaved, analysisError }.
See Call analysis for agent selection and prompt composition.