chore(voice): drop non-UI changes from feat/whatsapp-call-ui
Reset BE/migration/spec files to base so this PR diffs as a pure UI change. The deleted migration and the BE-side comment/edits belong on the BE PR chain (feat/whatsapp-call-incoming-pipeline) rather than the UI PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
25609d88b2
commit
6b45b97d9c
@@ -4,10 +4,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
||||
conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).where(contact_id: @contact.id)
|
||||
# Optional inbox scoping so callers (e.g. the WhatsApp call button) can
|
||||
# find the latest open conversation in a specific inbox even when the
|
||||
# 20-row cap below would otherwise cut it off.
|
||||
conversations = conversations.where(inbox_id: params[:inbox_id]) if params[:inbox_id].present?
|
||||
|
||||
# Apply permission-based filtering using the existing service
|
||||
conversations = Conversations::PermissionFilterService.new(
|
||||
|
||||
@@ -46,8 +46,7 @@ class Base::SendOnChannelService
|
||||
def invalid_message?
|
||||
# private notes aren't send to the channels
|
||||
# we should also avoid the case of message loops, when outgoing messages are created from channel
|
||||
# voice_call bubbles are call status indicators, not deliverable messages — skip the send pipeline
|
||||
# otherwise it falls through to "Template not found" / 24-hour-window errors
|
||||
# voice_call bubbles are call status indicators, not deliverable messages
|
||||
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
|
||||
end
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
class CleanupLegacyChannelVoiceInboxes < ActiveRecord::Migration[7.1]
|
||||
# Inboxes whose channel_type column still says 'Channel::Voice' became
|
||||
# orphans after 20260326120001_drop_channel_voice.rb dropped both the
|
||||
# channel_voice table and the model class. The polymorphic
|
||||
# `belongs_to :channel` lookup on those rows fails to constantize
|
||||
# `Channel::Voice`, crashing the inbox serializer with
|
||||
# `uninitialized constant Channel::Voice`.
|
||||
#
|
||||
# Delete the orphan inboxes and their dependents via raw SQL so we
|
||||
# bypass the polymorphic load that would crash inside Rails callbacks.
|
||||
def up
|
||||
legacy_ids = ActiveRecord::Base.connection
|
||||
.exec_query("SELECT id FROM inboxes WHERE channel_type = 'Channel::Voice'")
|
||||
.rows.flatten
|
||||
|
||||
return if legacy_ids.empty?
|
||||
|
||||
say_with_time "Cleaning up #{legacy_ids.size} legacy Channel::Voice inbox(es): #{legacy_ids.inspect}" do
|
||||
delete_dependents(legacy_ids)
|
||||
execute("DELETE FROM inboxes WHERE id IN (#{legacy_ids.join(',')})")
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Tables with an inbox_id FK that need clearing before the inbox row is removed.
|
||||
# Order matters where one table FKs another (messages → conversations).
|
||||
DEPENDENT_TABLES = %w[
|
||||
messages
|
||||
conversations
|
||||
contact_inboxes
|
||||
inbox_members
|
||||
agent_bot_inboxes
|
||||
campaigns
|
||||
webhooks
|
||||
integrations_hooks
|
||||
inbox_assignment_policies
|
||||
].freeze
|
||||
|
||||
def delete_dependents(inbox_ids)
|
||||
in_clause = inbox_ids.join(',')
|
||||
DEPENDENT_TABLES.each do |table|
|
||||
next unless ActiveRecord::Base.connection.table_exists?(table)
|
||||
next unless ActiveRecord::Base.connection.column_exists?(table, :inbox_id)
|
||||
|
||||
execute("DELETE FROM #{table} WHERE inbox_id IN (#{in_clause})")
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_02_090000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -59,7 +59,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
# Twilio voice also exposes voice_enabled? but uses a different initiation path.
|
||||
def ensure_calling_enabled
|
||||
channel = @conversation.inbox.channel
|
||||
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
|
||||
@@ -98,7 +97,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
'uploaded'
|
||||
end
|
||||
|
||||
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
|
||||
def create_outbound_call
|
||||
contact_phone = @conversation.contact.phone_number.delete('+')
|
||||
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
|
||||
|
||||
@@ -7,12 +7,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
}.freeze
|
||||
|
||||
before_action :set_inbox!
|
||||
# Twilio's recording webhook fetches the audio file with the channel's
|
||||
# auth credentials, so accepting an unsigned POST would let an attacker
|
||||
# who guesses (or is leaked) a ConferenceSid coerce credential-bearing
|
||||
# requests to an arbitrary host. Verify the signature before doing
|
||||
# anything with the payload.
|
||||
before_action :verify_twilio_signature!, only: :recording_status
|
||||
|
||||
def status
|
||||
Voice::StatusUpdateService.new(
|
||||
@@ -192,18 +186,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
call.update!(twilio_conference_sid: sid)
|
||||
end
|
||||
|
||||
def verify_twilio_signature!
|
||||
signature = request.headers['X-Twilio-Signature'].to_s
|
||||
auth_token = inbox_channel.auth_token.to_s
|
||||
return head :forbidden if signature.blank? || auth_token.blank?
|
||||
|
||||
validator = Twilio::Security::RequestValidator.new(auth_token)
|
||||
payload = request.request_method.to_s.upcase == 'POST' ? request.request_parameters : request.query_parameters
|
||||
return if validator.validate(request.original_url, payload, signature)
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
phone_number = "+#{digits}"
|
||||
|
||||
@@ -8,14 +8,8 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
# Surface the 409 collision to a second agent who clicks accept, but DON'T
|
||||
# claim accepted_by_agent here — the actual claim happens when Twilio's
|
||||
# participant-join webhook fires for this agent's leg (Voice::Conference::Manager).
|
||||
# If we claimed up-front and the browser's joinClientCall failed (device init
|
||||
# error, tab close, network drop), the call would stay ringing-but-claimed
|
||||
# and every other agent would 409 with no recovery path.
|
||||
def mark_agent_joined(user:)
|
||||
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
|
||||
claim_call!(user)
|
||||
assign_conversation!(user)
|
||||
end
|
||||
|
||||
@@ -31,6 +25,13 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
|
||||
private
|
||||
|
||||
def claim_call!(user)
|
||||
call.with_lock do
|
||||
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
|
||||
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
|
||||
end
|
||||
end
|
||||
|
||||
def claimed_by_other_agent?(user)
|
||||
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
|
||||
end
|
||||
|
||||
@@ -71,10 +71,6 @@ class Whatsapp::IncomingCallService
|
||||
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
|
||||
end
|
||||
|
||||
def inbound_offer?(payload)
|
||||
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
|
||||
end
|
||||
|
||||
def create_inbound_call(payload)
|
||||
sdp_offer = payload.dig(:session, :sdp)
|
||||
call = Voice::InboundCallBuilder.perform!(
|
||||
|
||||
@@ -36,23 +36,12 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
end
|
||||
|
||||
describe '#mark_agent_joined' do
|
||||
it 'assigns the conversation to the agent without claiming the call (claim defers to participant-join webhook)' do
|
||||
it 'sets accepted_by_agent on the Call' do
|
||||
agent = create(:user, account: account)
|
||||
create(:inbox_member, inbox: channel.inbox, user: agent)
|
||||
|
||||
service.mark_agent_joined(user: agent)
|
||||
|
||||
expect(call.reload.accepted_by_agent_id).to be_nil
|
||||
expect(conversation.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'raises CallAlreadyAccepted when another agent has already claimed the call' do
|
||||
first_agent = create(:user, account: account)
|
||||
second_agent = create(:user, account: account)
|
||||
call.update!(accepted_by_agent: first_agent)
|
||||
|
||||
expect { service.mark_agent_joined(user: second_agent) }
|
||||
.to raise_error(CustomExceptions::CallAlreadyAccepted)
|
||||
expect(call.reload.accepted_by_agent_id).to eq(agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user