feat: Add unified Call model for voice calling (#14026)
Adds a Call model to track voice call state across providers (Twilio, WhatsApp). This replaces storing call data in conversation.additional_attributes and provides a foundation for call analytics multi-call-per-conversation support, and future voice providers. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -450,3 +450,4 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
Message.prepend_mod_with('Message')
|
||||
Message.include_mod_with('Concerns::Message')
|
||||
|
||||
34
db/migrate/20260408170902_create_calls.rb
Normal file
34
db/migrate/20260408170902_create_calls.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class CreateCalls < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :calls do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :inbox_id, null: false
|
||||
t.bigint :conversation_id, null: false
|
||||
t.bigint :contact_id, null: false
|
||||
t.bigint :message_id
|
||||
t.bigint :accepted_by_agent_id
|
||||
t.string :provider_call_id, null: false
|
||||
t.integer :provider, null: false, default: 0
|
||||
t.integer :direction, null: false
|
||||
t.string :status, null: false, default: 'ringing'
|
||||
t.datetime :started_at
|
||||
t.integer :duration_seconds
|
||||
t.string :end_reason
|
||||
t.jsonb :meta, default: {}
|
||||
t.text :transcript
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_call_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_call_indexes
|
||||
add_index :calls, [:provider, :provider_call_id], unique: true
|
||||
add_index :calls, [:account_id, :conversation_id]
|
||||
add_index :calls, [:account_id, :contact_id]
|
||||
add_index :calls, :message_id
|
||||
end
|
||||
end
|
||||
24
db/schema.rb
24
db/schema.rb
@@ -261,6 +261,30 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
|
||||
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||
end
|
||||
|
||||
create_table "calls", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "contact_id", null: false
|
||||
t.bigint "message_id"
|
||||
t.bigint "accepted_by_agent_id"
|
||||
t.string "provider_call_id", null: false
|
||||
t.integer "provider", default: 0, null: false
|
||||
t.integer "direction", null: false
|
||||
t.string "status", default: "ringing", null: false
|
||||
t.datetime "started_at"
|
||||
t.integer "duration_seconds"
|
||||
t.string "end_reason"
|
||||
t.jsonb "meta", default: {}
|
||||
t.text "transcript"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
|
||||
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
|
||||
t.index ["message_id"], name: "index_calls_on_message_id"
|
||||
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
||||
end
|
||||
|
||||
create_table "campaigns", force: :cascade do |t|
|
||||
t.integer "display_id", null: false
|
||||
t.string "title", null: false
|
||||
|
||||
55
enterprise/app/models/call.rb
Normal file
55
enterprise/app/models/call.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: calls
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# direction :integer not null
|
||||
# duration_seconds :integer
|
||||
# end_reason :string
|
||||
# meta :jsonb
|
||||
# provider :integer default("twilio"), not null
|
||||
# started_at :datetime
|
||||
# status :string default("ringing"), not null
|
||||
# transcript :text
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# accepted_by_agent_id :bigint
|
||||
# account_id :bigint not null
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# message_id :bigint
|
||||
# provider_call_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||
# index_calls_on_message_id (message_id)
|
||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||
#
|
||||
class Call < ApplicationRecord
|
||||
# All valid call statuses
|
||||
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :contact
|
||||
belongs_to :message, optional: true
|
||||
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||
|
||||
has_one_attached :recording
|
||||
|
||||
validates :provider_call_id, presence: true
|
||||
validates :provider, presence: true
|
||||
validates :direction, presence: true
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
end
|
||||
@@ -17,6 +17,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
has_many :calls, dependent: :destroy_async
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Conversation
|
||||
belongs_to :sla_policy, optional: true
|
||||
has_one :applied_sla, dependent: :destroy_async
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
|
||||
@@ -7,5 +7,6 @@ module Enterprise::Concerns::Inbox
|
||||
through: :captain_inbox,
|
||||
class_name: 'Captain::Assistant'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy
|
||||
has_many :calls, dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
|
||||
7
enterprise/app/models/enterprise/concerns/message.rb
Normal file
7
enterprise/app/models/enterprise/concerns/message.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Concerns::Message
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :call, dependent: :nullify
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user