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:
Muhsin Keloth
2026-04-13 20:28:09 +04:00
committed by GitHub
parent 722e68eecb
commit f422c83c26
8 changed files with 124 additions and 0 deletions

View File

@@ -450,3 +450,4 @@ class Message < ApplicationRecord
end
Message.prepend_mod_with('Message')
Message.include_mod_with('Concerns::Message')

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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? }

View File

@@ -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

View File

@@ -0,0 +1,7 @@
module Enterprise::Concerns::Message
extend ActiveSupport::Concern
included do
has_one :call, dependent: :nullify
end
end