Skip to content

Unanswered Messages

The unanswered messages feature identifies contacts whose conversations need attention. It supports two directions: Needs Reply (contact messaged you, no response yet) and Needs Follow-Up (you messaged the contact, no response yet).

pub enum UnansweredDirection {
NeedsReply, // Last message was inbound — you need to reply
NeedsFollowUp, // Last message was outbound — contact hasn't responded
}

Serializes as "needs_reply" and "needs_follow_up".

pub struct UnansweredContact {
pub contact_id: Uuid,
pub first_name: String,
pub last_name: String,
pub direction: UnansweredDirection,
pub last_message_id: Uuid,
pub phone_number_id: Option<Uuid>,
pub phone_label: Option<String>,
pub last_message_at: String, // Formatted in org timezone
pub last_message_body: Option<String>,
pub contact_phone: Option<String>,
}
pub struct UnansweredContactsFilter {
pub phone_number_id: Option<Uuid>,
pub direction: Option<UnansweredDirection>,
pub time_range: Option<TimeRange>,
}
pub struct UnansweredContactsResponse {
pub contacts: Vec<UnansweredContact>,
pub count: u32,
pub needs_reply_count: u32, // Before direction/time filtering
pub needs_follow_up_count: u32, // Before direction/time filtering
}

The needs_reply_count and needs_follow_up_count fields reflect totals before direction and time-range filtering. The UI uses these for badge counts regardless of the active sub-filter.

GET /api/messages/unanswered

Returns all unanswered contacts for the caller’s organization with no query parameters — the UI applies direction and time-range filtering client-side.

Permission: Requires Contact::Collection::List (all org contacts) or Contact::Collection::ListAssigned (assigned contacts only). Returns 403 if neither is granted.

Response: UnansweredContactsResponse

get_unanswered_contacts in src/mods/messaging/services/get_unanswered_contacts_service.rs.

The service uses a PostgreSQL DISTINCT ON (contact_id) query ordered by created_at DESC to get the most recent message per contact in a single pass. The last message’s direction determines NeedsReply vs NeedsFollowUp.

SELECT DISTINCT ON (contact_id)
id, contact_id, phone_number_id, direction, body, created_at
FROM message
WHERE organization_id = $1
AND contact_id IS NOT NULL
AND direction IN ('inbound', 'outbound')
ORDER BY contact_id, created_at DESC

After the query, the service:

  1. Computes unfiltered needs_reply_count and needs_follow_up_count
  2. Applies optional direction and time-range post-filters
  3. Sorts remaining entries by most recent first
  4. Batch-fetches contact details, primary phone numbers, and org phone labels
  5. Assembles the response with formatted timestamps in the org’s timezone

Member scoping: Users with Contact::Collection::List see all org contacts. Users with only ListAssigned see contacts assigned to them via the contact_user join table.

m20260326_120000_message_add_org_contact_created_index adds a partial index for query performance:

CREATE INDEX idx_message_org_contact_created
ON message (organization_id, contact_id, created_at DESC)
WHERE contact_id IS NOT NULL;

Run just migrate to apply.

get_unanswered_contacts is registered as an assistant tool so users can ask “show me unanswered messages” in the chat.

Input parameters:

ParameterTypeDefaultDescription
phone_number_idOption<String>NoneFilter by org phone line UUID
time_rangeString"all_time"today, yesterday, last_7_days, last_30_days, last_90_days, all_time
directionOption<String>Noneneeds_reply or needs_follow_up

The tool response includes a deep_link per contact pointing to the messaging conversation view.

Permission: Requires Message::Collection::List.

MessagingContactList in src/mods/messaging/components/ adds two filter tabs:

  • Needs Reply — contacts with unanswered inbound messages
  • Needs Follow-Up — contacts with unanswered outbound messages

Each shows a count badge. Direction and time-range sub-filters (Today, Week, Month, All) refine the view. An empty state displays when no contacts match.

The dashboard’s unanswered count stat links to /messaging?filter=unanswered. A global signal SHOW_UNANSWERED_FILTER activates the filter on mount:

pub static SHOW_UNANSWERED_FILTER: GlobalSignal<bool> = GlobalSignal::new(|| false);

Extracted to src/shared/utils/:

FunctionPurpose
contact_display_name(first, last)Joins name parts, trims whitespace
contact_display_name_or_unknown(first, last)Falls back to "Unknown Contact"
format_response_time(secs)Formats seconds as 5m 30s, 2h 15m, etc.
rate_color_class(rate)Returns Tailwind color class based on response rate threshold
FilePurpose
src/mods/messaging/types/unanswered_contact_type.rsUnansweredContact, UnansweredDirection, filter, response types
src/mods/messaging/services/get_unanswered_contacts_service.rsDISTINCT ON query + permission scoping
src/mods/messaging/api/get_unanswered_contacts_api.rsGET /api/messages/unanswered server function
src/mods/assistant/tools/messages/ai_get_unanswered_contacts_tool.rsAI assistant tool registration
src/mods/messaging/components/messaging_contact_list_component.rsFilter chips + unanswered contact list UI
src/mods/dashboard/components/messaging_response_insights_component.rsClickable unanswered count on dashboard
src/shared/utils/contact_display_name, format_response_time, rate_color_class
migration/src/m20260326_120000_*.rsPartial index migration