Skip to content

Call

The call module manages call records — inbound and outbound — from creation through post-call details with transcriptions, recordings, and analysis results. Users can place outbound calls directly from the app using the Twilio Voice SDK.

pub struct Call {
pub id: Uuid,
pub from_to_number: String, // "+15551234567 -> +15559876543"
pub received_on: String, // Friendly name + number
pub call_sid: String, // Twilio call SID
pub has_recording: bool,
pub created_at: String, // Context-aware friendly format
pub created_at_full: String, // Full datetime for tooltips
pub duration_secs: Option<i32>,
pub status: CallStatus,
pub direction: CallDirection,
pub contact_name: Option<String>, // Linked contact display name
pub contact_id: Option<String>, // Contact UUID for routing
}

received_on is derived at query time by joining the phone_number record:

  • If the phone has a friendly_name: "{friendly_name} ({number})"
  • If no friendly name is set: the raw E.164 number
  • If the phone record is missing: "Unknown phone"

contact_name and contact_id are batch-loaded from the contact table using a single query per page. The API collects unique contact_id values from the page results, fetches them in one query, and builds a HashMap<Uuid, String> for O(1) lookups. This avoids N+1 queries regardless of page size.

Call status is modeled as a typed enum (not a raw string):

pub enum CallStatus {
InProgress, // Call is actively connected
Completed, // Call ended and recording processed
Unknown, // Catch-all for future Twilio status values
}

Status is stored in the DB as a kebab-case string ("in-progress", "completed") and parsed at query time using call.status.parse::<CallStatus>().unwrap_or(CallStatus::Unknown).

pub enum CallDirection {
Inbound, // Incoming call received by the platform
Outbound, // Outgoing call initiated from the platform
}

Stored as a kebab-case string ("inbound", "outbound") in the database.

Extends Call with transcription, analysis, and cross-entity navigation fields:

pub struct CallDetails {
pub id: Uuid,
pub from_to_number: String,
pub call_sid: String,
pub has_recording: bool,
pub created_at: String, // datetime-local format: "2026-03-28T15:00"
pub transcription: Option<String>,
pub contact_name: Option<String>, // Linked contact display name
pub contact_id: Option<String>, // Contact UUID for client-side routing
pub analysis_groups: Vec<CallAnalysisGroup>,
}

contact_name and contact_id are resolved server-side using a select_only query for the contact’s first and last name, scoped to the session’s organization. If the call has no linked contact or the contact was deleted, both fields are None.

Controls the filter popover state for the call list view:

pub struct CallFilter {
pub search: String,
pub direction: Option<CallDirection>,
pub phone_number_id: Option<Uuid>,
pub has_recording: Option<bool>,
pub min_duration_secs: Option<i32>,
pub time_range: TimeRange,
pub tag_ids: Vec<Uuid>, // Filter by linked contact's tags
}

active_filter_count() returns the number of non-default filters for badge display. Each filter contributes at most 1 to the count — tag_ids counts as active when non-empty.

Each call can have multiple analysis groups (one per analyzer), each with multiple runs:

pub struct CallAnalysisGroup {
pub analyzer_id: Uuid,
pub analyzer_name: String, // e.g. "Sentiment Analyzer"
pub runs: Vec<CallAnalysisRun>,
}
pub struct CallAnalysisRun {
pub id: Uuid,
pub analysis_text: String, // e.g. "No compliance issues found."
pub created_at: String,
}

Previous analysis runs are preserved when re-running — new runs are appended.

Both transcription text and analysis results render as markdown in the call details view using the shared Markdown component. This supports headings, lists, bold, and other formatting that AI-generated analysis commonly produces. See Contact Notes — Markdown Component for the full component API.

MethodPathDescription
GET/api/calls?time_range&search&direction&phone_number_id&has_recording&min_duration_secs&tag_ids&page&page_sizeList calls with filtering and pagination
GET/api/calls/:idFull details with transcription, analysis, and contact link
POST/api/calls/:id/rerunRe-run all active analyzers against the transcription
GET/api/calls/:id/recordingServe the MP3 recording file
POST/api/calls/initiateValidate and resolve caller ID for an outbound call
POST/api/twilio/voice/transferCold transfer active call via Twilio redirect

All endpoints require an authenticated Session. Calls are scoped via a join on phone_number.organization_id.

The Call resource defines these permissions in the ABAC system:

LevelPermissionDescription
InstanceViewAccess any call in the org
InstanceViewAssignedAccess calls linked to the member’s assigned contacts
InstanceUpdateRe-run analysis on a call
CollectionListList all org calls
CollectionListAssignedList only calls from assigned contacts
CollectionMakeCallInitiate outbound calls and generate Twilio access tokens

The CallInstance struct carries both org_id and assigned_member_ids (loaded from contact_user):

pub struct CallInstance {
pub org_id: Uuid,
pub assigned_member_ids: Vec<Uuid>,
}

The call API endpoints enforce ABAC permissions with assigned-access scoping. Members with full permissions see all calls in the org; members with *Assigned permissions see only calls linked to their assigned contacts.

EndpointPermission Required
GET /api/callsCall:Collection:List or Call:Collection:ListAssigned
GET /api/calls/:idCall:Instance:View or Call:Instance:ViewAssigned
POST /api/calls/:id/rerunCall:Instance:View or Call:Instance:ViewAssigned
GET /api/calls/:id/recordingCall:Instance:View or Call:Instance:ViewAssigned
POST /api/calls/initiateCall:Collection:MakeCall
GET /api/twilio/access-tokenCall:Collection:MakeCall

The call list endpoint checks permissions in a two-step cascade:

  1. If the member has Call:Collection:List → return all org calls (no filter)
  2. If the member has Call:Collection:ListAssigned → filter calls to those whose contact_id appears in the contact_user table for the current member
let show_all = CallResource::has_collection_permission(&session, CallCollectionPermission::List);
if !show_all {
CallResource::has_collection_permission(&session, CallCollectionPermission::ListAssigned)
.or_forbidden("Not allowed to list calls")?;
// Filter: call.contact_id IN (SELECT contact_id FROM contact_user WHERE member_id = session.member_id)
}

Instance endpoints (get_call_details, serve_recording, rerun_call_analysis) use a shared access-check service that mirrors the Contact access control pattern:

src/mods/call/services/check_call_access_service.rs
pub async fn check_call_access(
db: &DatabaseConnection,
session: &Session,
call_id: Uuid,
action: CallInstanceAction,
) -> Result<call::Model, AppError>

The service:

  1. Loads the call by ID, joining on phone_number to verify org ownership
  2. Loads assigned_member_ids from the contact_user table via the call’s contact_id
  3. Builds a CallInstance with org_id and assigned_member_ids
  4. Delegates to CallResource::has_instance_permission() which checks:
    • View permission → grants access to any org call
    • ViewAssigned permission → grants access only if session.member_id is in assigned_member_ids

The AI assistant’s get_call_details tool also uses check_call_access, so assistants respect the same access boundaries as the UI.

The GET /api/calls?time_range endpoint accepts a TimeRange query parameter:

pub enum TimeRange {
Today,
Yesterday,
Last7Days,
Last30Days,
Last90Days,
AllTime,
Custom { start: String, end: String },
}

The API applies bounds using time_range_start() and time_range_end() helpers, filtering on call.created_at. Custom ranges expect ISO date strings ("2026-01-15").

The tag_ids query parameter filters calls by their linked contact’s tags. Pass a comma-separated list of tag UUIDs:

GET /api/calls?tag_ids=uuid1,uuid2,uuid3&time_range=Last30Days

The server builds a subquery through the contact_contact_tag junction table:

if !tag_ids.is_empty() {
let tag_contact_subquery = schemas::contact_contact_tag::Entity::find()
.select_only()
.column(schemas::contact_contact_tag::Column::ContactId)
.filter(schemas::contact_contact_tag::Column::ContactTagId.is_in(tag_ids.clone()))
.filter(
schemas::contact_contact_tag::Column::ContactTagId.in_subquery(
schemas::contact_tag::Entity::find()
.select_only()
.column(schemas::contact_tag::Column::Id)
.filter(schemas::contact_tag::Column::OrganizationId.eq(session.organization.id))
.into_query(),
),
)
.into_query();
q = q.filter(schemas::call::Column::ContactId.in_subquery(tag_contact_subquery));
}

The nested subquery provides defense-in-depth: even if a client sends another org’s tag UUID, the filter only matches tags belonging to the session’s organization.

Tag filtering combines with all other filters additively — select “Inbound” direction + a tag and you get only inbound calls from contacts with that tag.

The call details view renders a clickable contact name link when the call has a linked contact. Clicking navigates to the ContactDetailsView.

The server resolves contact info using a lightweight select_only query:

let (contact_id, contact_name) = if let Some(cid) = call.contact_id {
let name = schemas::contact::Entity::find_by_id(cid)
.select_only()
.column(schemas::contact::Column::FirstName)
.column(schemas::contact::Column::LastName)
.filter(schemas::contact::Column::OrganizationId.eq(session.organization.id))
.into_tuple::<(String, String)>()
.one(&db)
.await?;
match name {
Some((first, last)) => (Some(cid.to_string()), Some(contact_display_name(&first, &last))),
None => (None, None),
}
} else {
(None, None)
};

If the contact was deleted or belongs to another org, the UI falls back to showing the phone number.

The recording endpoint (serve_recording_api) reads the MP3 file from disk using tokio::fs and returns it with Content-Type: audio/mpeg. Files are stored as {call_sid}.mp3.

Outbound calling uses the Twilio Voice SDK v2.x running in the browser. The SDK is loaded lazily on the first call attempt.

User clicks Call → initiate_call_api() → resolves caller ID
→ Twilio Device.connect({ To, CallerId, ContactId })
→ Twilio hits TwiML webhook (twilio_voice_outbound)
→ TwiML: <Dial> with <Recording track="both">
→ Call connects, recording starts
→ Call ends → same post-call pipeline as inbound

POST /api/calls/initiate validates the member’s phone permissions and returns the caller ID to use:

// Request
pub struct InitiateCallRequest {
pub contact_phone: String,
pub contact_id: Option<Uuid>,
pub phone_number_id: Option<Uuid>, // Explicit caller ID selection
}
// Response
pub struct InitiateCallResponse {
pub caller_id: String, // E.164 number to use as caller ID
pub contact_id: Option<Uuid>,
}

If phone_number_id is provided, the server validates it against the member’s allowed phones. Otherwise, it auto-selects a number — preferring one with voice_webhook_active, then falling back to the first available.

The OutboundCallContext manages the device state globally:

pub enum OutboundCallState {
Idle, // Device not initialized
Initializing, // Loading SDK + generating token
Ready, // Device ready, waiting for a call
Connecting, // Call ringing
Connected, // Call in progress
Error(String), // Something went wrong
}
pub struct ActiveCall {
pub to_number: String,
pub caller_id: String,
pub contact_id: Option<Uuid>,
pub contact_name: Option<String>,
pub started_at: String, // RFC 3339
}

The device stays in Ready state between calls — no re-initialization needed for subsequent calls. Token refresh is handled automatically via the tokenWillExpire event.

When Twilio connects the outbound call, it hits POST /api/twilio/voice/outbound. The handler:

  1. Parses To, CallerId, and ContactId from the form body
  2. Spawns a background task to create the call record (direction: "outbound")
  3. Returns TwiML with <Recording track="both"> and <Dial> to the destination number

The call record and post-call pipeline (recording, transcription, analysis) follow the same path as inbound calls.

The DialPadPanel component provides:

  • Number entry — keypad grid with DTMF digits
  • Contact autocomplete — searches contacts as you type (debounced, 300ms)
  • Caller ID selector — dropdown when the member has multiple assigned phone numbers
  • In-call controls — mute toggle, DTMF keypad, and hang up
  • Call from contact sidebar — clicking the phone icon on a contact auto-opens the dial pad with the number pre-filled
FunctionDescription
initialize_device()Loads Twilio SDK, generates access token, creates Device
make_call()Validates via initiate_call_api, then calls Device.connect()
hangup_call()Disconnects active call, resets state to Ready
toggle_mute()Toggles mute on the active call
send_dtmf()Sends a DTMF digit (validates input to prevent injection)

GET /api/twilio/access-token returns a short-lived JWT for the Twilio Client SDK. On first request, it auto-provisions:

  1. A TwiML Application pointing to the outbound voice webhook URL
  2. An API Key pair for signing tokens

Both are persisted in organization_twilio_settings so subsequent requests skip provisioning.

Twilio stream start
→ Call record created (direction: "inbound", status: "in-progress")
→ Live AI session active
→ Call ends → recording callback → post-call pipeline
User clicks Call in dial pad
→ initiate_call_api resolves caller ID
→ Twilio Device.connect()
→ TwiML webhook creates call record (direction: "outbound", status: "in-progress")
→ Call connects with dual-channel recording
→ Call ends → recording callback → same post-call pipeline
Twilio fires recording callback → process_twilio_recording()
→ status = "completed" + duration_secs saved
→ Recording downloaded
→ Transcription (async)
→ Call record updated with recording_url + transcription
→ Contact enrichment
→ Analyzer execution
→ Task extraction
→ Email report

Status transitions to "completed" as soon as the recording webhook fires — typically within a second or two of the call ending. UI reflects the completed status immediately rather than waiting 30+ seconds for the full pipeline to finish.

The POST /api/twilio/voice/transfer?phone_number=...&caller_id=... endpoint redirects an active call to a new phone number. The transfer is cold: Twilio fetches new TwiML, the existing media stream disconnects, and the call connects to the new number with the original Loquent number as caller ID.

Implementation uses redirect_call() util (Twilio REST API POST /Calls/{CallSid}.json with Url and Method params).

created_at uses context-aware formatting via format_friendly_datetime() (server-only, in src/shared/utils/date/). The format adapts based on recency:

RecencyFormatExample
TodayTime only10:30 AM
This weekWeekday + timeMon 10:30 AM
This yearMonth + day + timeJan 15, 10:30 AM
OlderMonth + day + yearJan 15, 2024

The full datetime (created_at_full) displays as a tooltip on hover. Both values are computed server-side using the organization’s timezone — no client-side formatting needed.

Two shared utilities handle date formatting on the client:

format_datetime_short — compact display for list cards and table cells. Parses both datetime-local format ("2026-03-28T15:00") and timezone-aware format ("2026-04-13 22:00:32 -04:00"). Midnight times render as date-only ("Mar 28"); others include time ("Mar 28, 3:00 PM").

format_relative_time — bidirectional relative labels. Returns past labels ("8h ago", "3d ago") or future labels ("in 45m", "in 3d") based on the sign of the time difference. Parses datetime-local format for compatibility with DateTimePicker values.

The Contact column replaces the previous Call Route column. Display depends on whether the call has a linked contact:

  • With contact: The contact name renders as a clickable link to the contact detail view, with the phone number in small text below. The link uses stop_propagation() to prevent triggering the row’s click handler.
  • Without contact: The phone number displays as plain text.
ColumnSourceExample
Contactcontact_name / from_to_numberJane Doe or +15551234567 -> +15559876543
Received Onreceived_on (derived from phone_number.friendly_name)Front Desk (+15559876543)

All call endpoints enforce ABAC permissions. See Permission Enforcement above for the full table. The check_call_access service centralizes instance-level checks across detail, recording, rerun, and AI assistant endpoints.

Both call views enforce permissions using AccessDenied guards:

  • Call list (CallListView) — checks Call:Collection:List or Call:Collection:ListAssigned. Users without either permission see an AccessDenied card.
  • Call details (CallDetailsView) — checks Call:Instance:View or Call:Instance:ViewAssigned. Users without either permission see an AccessDenied card with a back-navigation link.

The Calls sidebar link is gated behind Call:Collection:List or Call:Collection:ListAssigned. Users without call permissions don’t see the link at all.

The Dial pad button (DialPadButton) is hidden when the user lacks Call:Collection:MakeCall. The call button in the contact sidebar is also hidden without MakeCall.

The rerun-analysis button uses CallInstancePermission::Update — see Permission-Aware UI for the pattern.

When a rerun fails, the error is surfaced as a user-visible toast via toaster.mutation_error() instead of a silent server log.

The module includes two independent PLAYING_SID: GlobalSignal<Option<String>> signals — one in the list view and one in the detail view — to track which recording is currently playing.

src/mods/call/
├── api/ # List, details, rerun, recording, initiate endpoints
├── components/ # Call list, call details, dial pad, call controls
├── constants/ # Recording serve path
├── context/ # OutboundCallContext — global calling state + JS interop
├── types/ # Call, CallDetails, CallDirection, OutboundCallState, ActiveCall
└── views/ # List and detail page views
  • Twilio — creates call records, triggers post-call pipeline
  • Analyzer — runs analysis against transcriptions
  • Contact — linked via contact_id, enriched post-call
  • Report — generates email reports from call data
  • Settings — phone number restrictions per member (caller ID access)
  • Phonereceived_on is derived from the phone number’s friendly_name