From f422c83c26f70efb66ab56cde554da4aaa03234b Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 13 Apr 2026 20:28:09 +0400 Subject: [PATCH] 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> --- app/models/message.rb | 1 + db/migrate/20260408170902_create_calls.rb | 34 ++++++++++++ db/schema.rb | 24 ++++++++ enterprise/app/models/call.rb | 55 +++++++++++++++++++ .../app/models/enterprise/concerns/account.rb | 1 + .../enterprise/concerns/conversation.rb | 1 + .../app/models/enterprise/concerns/inbox.rb | 1 + .../app/models/enterprise/concerns/message.rb | 7 +++ 8 files changed, 124 insertions(+) create mode 100644 db/migrate/20260408170902_create_calls.rb create mode 100644 enterprise/app/models/call.rb create mode 100644 enterprise/app/models/enterprise/concerns/message.rb diff --git a/app/models/message.rb b/app/models/message.rb index 730d3e025..ccbb250c3 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -450,3 +450,4 @@ class Message < ApplicationRecord end Message.prepend_mod_with('Message') +Message.include_mod_with('Concerns::Message') diff --git a/db/migrate/20260408170902_create_calls.rb b/db/migrate/20260408170902_create_calls.rb new file mode 100644 index 000000000..3b073b4df --- /dev/null +++ b/db/migrate/20260408170902_create_calls.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0067f36ff..360bddb69 100644 --- a/db/schema.rb +++ b/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 diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb new file mode 100644 index 000000000..9864a4167 --- /dev/null +++ b/enterprise/app/models/call.rb @@ -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 diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 01693ac79..427b5245b 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -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 diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb index 057a30d1d..0f7595e0d 100644 --- a/enterprise/app/models/enterprise/concerns/conversation.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -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? } diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb index 0de61db23..bdcd0fd63 100644 --- a/enterprise/app/models/enterprise/concerns/inbox.rb +++ b/enterprise/app/models/enterprise/concerns/inbox.rb @@ -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 diff --git a/enterprise/app/models/enterprise/concerns/message.rb b/enterprise/app/models/enterprise/concerns/message.rb new file mode 100644 index 000000000..cfdea430b --- /dev/null +++ b/enterprise/app/models/enterprise/concerns/message.rb @@ -0,0 +1,7 @@ +module Enterprise::Concerns::Message + extend ActiveSupport::Concern + + included do + has_one :call, dependent: :nullify + end +end