chore: fix conversation list status

This commit is contained in:
Sojan
2025-05-04 18:07:16 -07:00
parent 5dc1735f69
commit 4e8a39f358
9 changed files with 276 additions and 149 deletions
@@ -51,29 +51,17 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
# Update call status using the unified CallStatusManager
# The CallStatusManager will determine if the call is outbound internally
# and will create an appropriate activity message
custom_message = "Call ended by #{current_user.name}"
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: call_sid,
provider: :twilio
)
status_manager.process_status_update('completed')
# CallStatusManager handles all voice call message updates
# Create an activity message noting the call has ended
Messages::MessageBuilder.new(
nil,
@conversation,
{
content: 'Call ended by agent',
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: 'completed',
ended_by: current_user.name
}
}
).perform
status_manager.process_status_update('completed', nil, false, custom_message)
# No need to create additional activity messages - the manager handles it
# Broadcast call status update on the account channel
ActionCable.server.broadcast(
@@ -143,33 +131,19 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
name: current_user.name
}
# Update call status using the unified CallStatusManager
# Update call status using the unified CallStatusManager with custom message
# The CallStatusManager will determine if the call is outbound internally
custom_message = "#{current_user.name} joined the call"
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: call_sid,
provider: :twilio
)
status_manager.process_status_update('in-progress')
status_manager.process_status_update('in-progress', nil, false, custom_message)
# Save the conversation with agent join details
@conversation.save!
# Create an activity message
Messages::MessageBuilder.new(
nil,
@conversation,
{
content: "#{current_user.name} joined the call",
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
conference_sid: conference_sid,
joined_by: current_user.name,
joined_at: Time.now.to_i
}
}
).perform
# Broadcast call status update on the account channel
ActionCable.server.broadcast(
@@ -220,20 +194,19 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
}
@conversation.save!
# Create an activity message noting the agent rejected the call
Messages::MessageBuilder.new(
nil,
@conversation,
{
content: "#{current_user.name} declined to answer",
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
rejected_by: current_user.name,
rejected_at: Time.now.to_i
}
}
).perform
# Update call status and create activity message through the unified manager
custom_message = "#{current_user.name} declined to answer"
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: call_sid,
provider: :twilio
)
status_manager.create_activity_message(custom_message, {
call_sid: call_sid,
rejected_by: current_user.name,
rejected_at: Time.now.to_i
})
render json: {
status: 'success',
@@ -1546,8 +1546,65 @@ export default {
{ immediate: true }
);
// This function was removed as it's no longer needed
// Add watcher for call status changes
watch(
() => store.state.calls.activeCall?.status,
(newStatus) => {
if (newStatus === 'ended' || newStatus === 'completed' || newStatus === 'missed') {
console.log('Call status changed to:', newStatus);
stopDurationTimer();
stopRingtone();
isCallActive.value = false;
// Update app state
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
// Emit event
emit('callEnded');
// Clear store state
store.dispatch('calls/clearActiveCall');
store.dispatch('calls/clearIncomingCall');
}
}
);
// Add specific watcher for outbound call status
watch(
() => store.state.calls.activeCall,
(newCall) => {
// Check if this is an outbound call
const isOutboundCall = newCall && newCall.isOutbound === true;
if (isOutboundCall) {
console.log('Outbound call status:', newCall?.status);
// Handle outbound call status changes
if (newCall?.status === 'ended' || newCall?.status === 'completed' || newCall?.status === 'missed') {
console.log('Outbound call ended with status:', newCall.status);
stopDurationTimer();
stopRingtone();
isCallActive.value = false;
// Update app state
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
// Emit event
emit('callEnded');
// Clear store state
store.dispatch('calls/clearActiveCall');
store.dispatch('calls/clearIncomingCall');
}
}
},
{ deep: true } // Watch for nested changes in the call object
);
return {
isCallActive,
callDuration,
+29 -2
View File
@@ -232,13 +232,40 @@ class ActionCableConnector extends BaseActionCableConnector {
status: data.status,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
timestamp: data.timestamp || Date.now()
};
// Update store with call status change
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
// For non-terminal statuses, update the active call
if (!['ended', 'missed', 'completed'].includes(data.status)) {
// For terminal statuses, clear the active call to close the widget
if (['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'].includes(data.status)) {
console.log(`ActionCable: Call status changed to terminal status: ${data.status}`);
// Clear active call for terminal statuses
this.app.$store.dispatch('calls/clearActiveCall');
// Ensure window.app.$data exists before modifying it
if (window.app && window.app.$data) {
console.log('ActionCable: Hiding call widget');
window.app.$data.showCallWidget = false;
}
// Update conversation list to show current status
if (data.conversation_id) {
console.log(`ActionCable: Updating conversation last activity for conversation ${data.conversation_id}`);
this.app.$store.dispatch('updateConversationLastActivity', {
conversationId: data.conversation_id,
lastActivityAt: new Date().toISOString(),
});
// Also ensure that the conversation gets refreshed
this.app.$store.dispatch('fetchConversation', {
id: data.conversation_id
});
}
} else {
// Update active call for non-terminal statuses
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
}
};
@@ -29,7 +29,8 @@ export default {
const transcription = ref('');
const durationTimer = ref(null);
const isCallActive = computed(
() => callStatus.value && callStatus.value !== 'completed'
() => callStatus.value &&
!['completed', 'ended', 'missed', 'failed', 'busy', 'no-answer'].includes(callStatus.value)
);
const callStatusText = computed(() => {
@@ -77,16 +78,24 @@ export default {
const updateCallStatus = status => {
callStatus.value = status;
if (status === 'in-progress') {
// Log the status update to help with debugging
console.log(`CallManager: Updating call status to ${status}`);
if (status === 'in-progress' || status === 'in_progress') {
startDurationTimer();
} else if (
status === 'completed' ||
status === 'failed' ||
status === 'busy' ||
status === 'no-answer'
['completed', 'ended', 'missed', 'failed', 'busy', 'no-answer', 'no_answer'].includes(status)
) {
console.log(`CallManager: Call ended with status ${status}`);
stopDurationTimer();
emit('callEnded');
// Forcefully hide the call widget after a short delay
setTimeout(() => {
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
}, 2000);
}
};
@@ -271,8 +280,8 @@ export default {
<template>
<div
v-show="isCallActive && callStatus"
v-if="isCallActive && callStatus"
v-show="callStatus"
v-if="callStatus"
class="relative p-4 mb-4 border border-solid rounded-md bg-n-slate-1 border-n-slate-4 flex flex-col gap-2"
>
<div class="flex items-center justify-between">
@@ -13,10 +13,36 @@ const getters = {
const actions = {
// This action will handle both message updates and direct call status changes
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
// Check if this is the active call
const isActiveCall = callSid === state.activeCall?.callSid;
const isOutboundCall = state.activeCall?.isOutbound === true;
// If this is the active call and it has ended or was missed, close the widget
if (callSid === state.activeCall?.callSid &&
if (isActiveCall &&
(status === 'ended' || status === 'missed' || status === 'completed')) {
console.log('Call status changed to:', status, 'isOutbound:', isOutboundCall);
// Clear the active call
dispatch('clearActiveCall');
// Force update app state to hide widget
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
// Emit event to notify components
if (window.app) {
window.app.$emit('callEnded');
}
// For outbound calls, also clear any pending state
if (isOutboundCall) {
// Additional cleanup for outbound calls
if (window.globalCallStatus) {
window.globalCallStatus.active = false;
window.globalCallStatus.incoming = false;
}
}
}
},
+2 -1
View File
@@ -232,7 +232,8 @@ class Conversation < ApplicationRecord
def allowed_keys?
(
previous_changes.keys.intersect?(list_of_keys) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
(previous_changes['additional_attributes'].present? &&
(previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language call_status call_duration])))
)
end
+89 -31
View File
@@ -64,18 +64,12 @@ module Voice
# Provider-specific message templates for different call statuses
# These messages must EXACTLY match what the UI expects to show
# "In-progress" messages have been removed as they don't provide value
PROVIDER_MESSAGES = {
twilio: {
# Outgoing calls
'initiated' => { outbound: 'Call started…', inbound: 'Incoming call…' },
'ringing' => { outbound: 'Call started…', inbound: 'Incoming call…' },
'in-progress' => {
outbound: { first: 'Call in progress…', next: 'Call in progress…' },
inbound: { first: 'Call in progress…', next: 'Call in progress…' }
},
'active' => { outbound: 'Call in progress…', inbound: 'Call in progress…' },
# Status messages (only for terminal statuses)
'completed' => { outbound: 'Call ended', inbound: 'Call ended' },
'busy' => { outbound: 'Line busy', inbound: 'Missed call' }, # Show as missed for inbound
'busy' => { outbound: 'Line busy', inbound: 'Missed call' },
'failed' => { outbound: 'Call failed', inbound: 'Missed call' },
'no-answer' => { outbound: 'No answer', inbound: 'Missed call' },
'canceled' => { outbound: 'Call canceled', inbound: 'Missed call' },
@@ -86,6 +80,11 @@ module Voice
# Create a custom activity message
# This provides a clean migration path from MessageUpdateService
def create_activity_message(content, additional_attributes = {})
return nil if content.blank?
Rails.logger.info("📝 [CallStatusManager] Creating activity message: '#{content}'")
# Create message
Messages::MessageBuilder.new(
nil,
conversation,
@@ -105,8 +104,9 @@ module Voice
# @param status [String] The status from the provider (e.g., 'completed', 'ringing')
# @param duration [Integer, nil] The call duration in seconds (if available)
# @param is_first_response [Boolean] Whether this is the first status update for this status
# @param custom_message [String, nil] Optional custom activity message to create
# @return [Boolean] Whether the update was processed successfully
def process_status_update(status, duration = nil, is_first_response = false)
def process_status_update(status, duration = nil, is_first_response = false, custom_message = nil)
# Normalize status using the STATUS_MAPPING if present
normalized_status = STATUS_MAPPING[status] || status
@@ -145,8 +145,23 @@ module Voice
# Update conversation and message status in a database transaction
result = update_status(normalized_status, duration)
# Create an activity message with provider-specific text if status was updated
create_provider_activity_message(normalized_status, is_first_response) if result
# Create activity message based on the provided custom message or use default provider message
if result
if custom_message.present?
# Use the custom message provided
Rails.logger.info("📝 [CallStatusManager] Creating custom activity message: '#{custom_message}'")
create_activity_message(custom_message, { call_sid: call_sid, call_status: normalized_status })
elsif should_create_activity_message?(normalized_status)
# For outbound calls in initiated/ringing stage, only add message if explicitly requested
if is_outbound? && ['initiated', 'ringing'].include?(normalized_status) && !is_first_response
Rails.logger.info("📝 [CallStatusManager] Skipping default activity message for outbound call status: '#{normalized_status}'")
else
# Use default provider message
Rails.logger.info("📝 [CallStatusManager] Creating default activity message for status: '#{normalized_status}'")
create_provider_activity_message(normalized_status, is_first_response)
end
end
end
result
end
@@ -208,6 +223,9 @@ module Voice
# Generate provider-specific activity messages (e.g., for Twilio)
def create_provider_activity_message(status, is_first_response = false)
# Skip activity messages for non-terminal states
return nil unless call_ended?(status)
provider_key = provider&.to_sym
call_direction = is_outbound? ? :outbound : :inbound
@@ -217,11 +235,7 @@ module Voice
if provider_key && PROVIDER_MESSAGES.key?(provider_key)
messages = PROVIDER_MESSAGES[provider_key]
if status == 'in-progress' && messages.dig(status, call_direction).is_a?(Hash)
message_type = is_first_response ? :first : :next
provider_message = messages.dig(status, call_direction, message_type)
message = provider_message if provider_message
elsif messages.dig(status, call_direction)
if messages.dig(status, call_direction)
message = messages.dig(status, call_direction)
end
end
@@ -251,11 +265,19 @@ module Voice
current_status = conversation.additional_attributes['call_status']
# Only update if status is changing or we're forcing an update
# Exception: Don't create duplicate "completed" status updates for the same conversation
if current_status == internal_status
Rails.logger.info("🔄 [CallStatusManager] Status unchanged: '#{internal_status}'")
return true
end
# Don't process multiple call ending events - once a call is in a terminal state, keep it there
# This prevents duplicate "Call ended" messages
if current_status.present? && call_ended?(current_status) && call_ended?(internal_status)
Rails.logger.info("🔄 [CallStatusManager] Call already in terminal state '#{current_status}', not changing to '#{internal_status}'")
return true
end
# Log the status transition
Rails.logger.info("🔄 [CallStatusManager] Status transition: '#{current_status}' -> '#{internal_status}'")
@@ -266,11 +288,36 @@ module Voice
# Update message content_attributes
update_message_status(internal_status, duration)
# Create activity message for status change if it's a significant change
create_status_activity_message(internal_status) if should_create_activity_message?(internal_status)
# Only create activity messages for status changes that warrant them
# For terminal states, we'll create exactly one appropriate activity message
# For outbound calls in initiated/ringing states, we'll suppress standard messages
# Track status transitions in conversation metadata to prevent duplicate activity messages
status_from = conversation.additional_attributes['call_status'] || 'none'
status_to = internal_status
transition = "#{status_from}#{status_to}"
# Store which transitions we've already handled with activity messages
conversation.additional_attributes['status_transitions'] ||= {}
transition_handled = conversation.additional_attributes['status_transitions'][transition]
# Mark this transition as handled
conversation.additional_attributes['status_transitions'][transition] = true
# Only create an activity message for the first occurrence of any status transition
# and only for terminal states or custom messages
create_should_activity = should_create_activity_message?(internal_status) &&
!(is_outbound? && ['initiated', 'ringing'].include?(internal_status)) &&
!transition_handled
# Create activity message for status change if needed
create_status_activity_message(internal_status) if create_should_activity
# Broadcast status change notification
broadcast_status_change(internal_status)
# No need for additional broadcast - status change already broadcasts
# conversation.updated events for terminal call states
end
true
@@ -319,8 +366,8 @@ module Voice
conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i
end
# Save the conversation
conversation.save!
# Save the conversation - force timestamp update to trigger UI refresh
conversation.update!(last_activity_at: Time.current)
end
def update_message_status(status, duration)
@@ -386,9 +433,9 @@ module Voice
end
def should_create_activity_message?(status)
# Only create activity messages for significant state changes
# Avoid creating too many messages for intermediate states
call_ended?(status) || status == 'in-progress'
# Only create activity messages for terminal call states
# "in-progress" messages are removed as they don't provide much value
call_ended?(status)
end
def create_status_activity_message(status)
@@ -421,14 +468,9 @@ module Voice
'Call ended'
end
end
elsif %w[in-progress active].include?(status)
'Call in progress…'
elsif status == 'ringing'
is_outbound? ? 'Call started…' : 'Incoming call…'
elsif status == 'initiated'
is_outbound? ? 'Call started…' : 'Incoming call…'
else
"Call status: #{status}"
# No intermediate status messages for in-progress or ringing
nil
end
# Use the public create_activity_message method
@@ -462,7 +504,23 @@ module Voice
}
}
)
# Also broadcast a conversation.updated event for terminal statuses
# This ensures the conversation list gets refreshed when a call ends
if call_ended?(status)
Rails.logger.info("📢 [CallStatusManager] Broadcasting conversation.updated for completed call")
ActionCable.server.broadcast(
"account_#{conversation.account_id}",
{
event_name: 'conversation.updated',
data: conversation.push_event_data
}
)
end
end
# NOTE: This method is no longer used - conversation updates are handled
# directly in the broadcast_status_change method for terminal call states.
end
end
@@ -32,9 +32,6 @@ module Voice
Rails.logger.warn("🎧 UNKNOWN CONFERENCE EVENT: #{event}")
end
# Create activity message for the event
create_activity_message
# Save conversation changes
conversation.save!
end
@@ -124,11 +121,11 @@ module Voice
elsif current_status == 'ringing'
# Call never connected
Rails.logger.info('📞 MARKING RINGING CALL AS MISSED')
call_status_manager.process_status_update('missed')
call_status_manager.process_status_update('missed', nil, false, 'Missed call')
else
# Default to completed status
Rails.logger.info('📞 MARKING CALL AS COMPLETED (DEFAULT)')
call_status_manager.process_status_update('completed')
call_status_manager.process_status_update('completed', nil, false, 'Call ended')
end
end
@@ -141,7 +138,7 @@ module Voice
end
Rails.logger.info('📞 MARKING ACTIVE CALL AS COMPLETED')
call_status_manager.process_status_update('completed', duration)
call_status_manager.process_status_update('completed', duration, false, 'Call ended')
end
# Helper methods for participant handling
@@ -183,7 +180,8 @@ module Voice
Rails.logger.info('📞 UPDATING RINGING CALL TO CONNECTED (agent joined)')
# Always use in-progress to be consistent with status mapping
call_status_manager.process_status_update('in-progress', nil, true)
# Pass event context to create appropriate activity message
call_status_manager.process_status_update('in-progress', nil, true, 'Agent joined the call')
end
def handle_caller_join
@@ -195,7 +193,9 @@ module Voice
Rails.logger.info('📞 UPDATING RINGING OUTBOUND CALL TO CONNECTED (caller joined)')
# Always use in-progress to be consistent with status mapping
call_status_manager.process_status_update('in-progress', nil, true)
# Only create activity message for inbound calls where caller joining is significant
custom_message = call_status_manager.is_outbound? ? nil : 'Caller joined the call'
call_status_manager.process_status_update('in-progress', nil, true, custom_message)
end
def handle_generic_participant_join
@@ -241,22 +241,34 @@ module Voice
return unless caller_participant? && ringing_call? && !participant_has_joined?(AGENT)
Rails.logger.info('📞 MARKING AS MISSED (caller left during ringing, no agent joined)')
call_status_manager.process_status_update('missed')
call_status_manager.process_status_update('missed', nil, false, 'Missed call')
end
# Everyone left check
def check_if_everyone_left
call_active = conversation.additional_attributes['call_status'] == 'active'
call_in_progress = %w[active in-progress].include?(conversation.additional_attributes['call_status'])
conference_active = conversation.additional_attributes['conference_status'] != 'ended'
return unless all_participants_left? && conference_active && call_active
# If the caller has left but the call is still active, mark it as completed
if caller_participant? && call_in_progress
# Calculate duration if possible
duration = nil
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at']
Rails.logger.info('📞 MARKING CALL AS COMPLETED (caller left during active call)')
call_status_manager.process_status_update('completed', duration, false, 'Caller left the call')
return
end
# Old code path for when everyone has left
return unless all_participants_left? && conference_active && call_in_progress
# Calculate duration if possible
duration = nil
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at']
Rails.logger.info('📞 MARKING CALL AS COMPLETED (all participants left)')
call_status_manager.process_status_update('completed', duration)
call_status_manager.process_status_update('completed', duration, false, 'Call ended')
end
# Participant tracking methods
@@ -297,42 +309,7 @@ module Voice
!participants.values.any? { |p| p['status'] == 'joined' }
end
# Activity message creation
def create_activity_message
content = activity_message_content_for_event
# Only create message if we have content to show
call_status_manager.create_activity_message(content) if content.present?
end
def activity_message_content_for_event
case event
when CONFERENCE_START
# Don't show this message to avoid redundancy with call status messages
nil
when CONFERENCE_END
# Don't show this message to avoid redundancy with call status messages
nil
when PARTICIPANT_JOIN
if agent_participant?
"Agent joined the call"
elsif caller_participant?
# Only for inbound calls - creates "Caller joined the call" message
# For outbound calls, we don't need this as we show "Call connected" instead
call_status_manager.is_outbound? ? nil : "Caller joined the call"
else
nil # Don't show for generic participants
end
when PARTICIPANT_LEAVE
if agent_participant?
"Agent left the call"
elsif caller_participant?
"Caller left the call"
else
nil # Don't show for generic participants
end
else
nil # Don't show unknown events
end
end
# Activity messages are now handled by the call_status_manager through the
# process_status_update method, which takes a custom_message parameter.
end
end
+4 -5
View File
@@ -115,11 +115,10 @@ module Voice
provider: :twilio # Specify the provider for accurate messaging
)
# Process first status update and create activity message
status_manager.process_status_update('initiated', nil, true)
# Additional custom message for outgoing calls
status_manager.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}")
# Process first status update with a custom message instead of default
# This creates only one activity message
custom_message = "Outgoing call to #{contact.name || contact.phone_number}"
status_manager.process_status_update('initiated', nil, true, custom_message)
end
def broadcast_to_agent