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:

  1. First call today — countCallsTodayForTargetPhone(targetPhoneNumber, campaignId) must equal 1. More than one call today to that number in that campaign cancels the double tap.

  2. Attempts remain — target.attempts < campaign.maxAttempts. When maxAttempts is 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:

  1. 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.

  2. incrementCallTargetRuns(campaignId) — the retry is a new in-flight call, and the broadcaster’s cap check must see it.

  3. step.sendEvent('double-tap', { name: 'call-target/call.requested', … }).

  4. 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 (newState === "DNC")

Contact: leadStatus = connected, lifecycleStage = other, stopCalling = true, nextAction = "Do_Not_Call". Registry: isBlocked = true, updatedAt = now()

Uncallable disconnection reason

Registry: isBlocked = true, updatedAt = now(). Contact fields are updated only when a disposition is present. Reasons: invalid_destination, telephony_provider_unavailable, error_retell, error_no_audio_received, marked_as_spam, telephony_provider_permission_denied, error_unknown

Disposition Amazon_Seller

Contact: leadStatus = connected, heatStatus = hot, lifecycleStage = marketingqualifiedlead

Anything else

Contact: leadStatus = attempted_to_contact, heatStatus = idle, lifecycleStage = opportunity

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.