From 0032d604c20112c0e490afc22108ede7b2fa6ada Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Sat, 16 Aug 2025 13:19:20 +0530 Subject: [PATCH] feat: add campaign analytics backend changes --- .../api/v1/accounts/campaigns_controller.rb | 11 ++++ app/models/campaign.rb | 14 ++++- app/models/campaign_message.rb | 62 +++++++++++++++++++ app/policies/campaign_policy.rb | 8 +++ .../whatsapp/incoming_message_base_service.rb | 47 +++++++++++++- .../whatsapp/oneoff_campaign_service.rb | 46 ++++++++++---- .../analytics_contacts.json.jbuilder | 21 +++++++ .../campaigns/analytics_metrics.json.jbuilder | 5 ++ config/routes.rb | 7 ++- ...20250816044639_create_campaign_messages.rb | 21 +++++++ db/schema.rb | 23 ++++++- enterprise/app/models/captain/document.rb | 2 + spec/factories/campaign_messages.rb | 13 ++++ 13 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 app/models/campaign_message.rb create mode 100644 app/views/api/v1/accounts/campaigns/analytics_contacts.json.jbuilder create mode 100644 app/views/api/v1/accounts/campaigns/analytics_metrics.json.jbuilder create mode 100644 db/migrate/20250816044639_create_campaign_messages.rb create mode 100644 spec/factories/campaign_messages.rb diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index 2a1650e53..511bc66ea 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -21,6 +21,17 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController head :ok end + def analytics_metrics; end + + def analytics_contacts + campaign_messages = @campaign.campaign_messages.includes(:contact) + campaign_messages = campaign_messages.by_status(params[:status]) if params[:status].present? + + page = params[:page] || 1 + per_page = params[:per_page] || 25 + @paginated_messages = campaign_messages.page(page).per(per_page) + end + private def campaign diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 2927181c6..03405ed7d 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -10,7 +10,7 @@ # enabled :boolean default(TRUE) # message :text not null # scheduled_at :datetime -# template_params :jsonb +# template_params :jsonb not null # title :string not null # trigger_only_during_business_hours :boolean default(FALSE) # trigger_rules :jsonb @@ -45,6 +45,8 @@ class Campaign < ApplicationRecord belongs_to :inbox belongs_to :sender, class_name: 'User', optional: true + has_many :campaign_messages, dependent: :destroy + enum campaign_type: { ongoing: 0, one_off: 1 } # TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc. enum campaign_status: { active: 0, completed: 1 } @@ -54,6 +56,16 @@ class Campaign < ApplicationRecord before_validation :ensure_correct_campaign_attributes after_commit :set_display_id, unless: :display_id? + def analytics_metrics + { + total: campaign_messages.count, + sent: campaign_messages.sent.count, + delivered: campaign_messages.delivered.count, + read: campaign_messages.read.count, + failed: campaign_messages.failed.count + } + end + def trigger! return unless one_off? return if completed? diff --git a/app/models/campaign_message.rb b/app/models/campaign_message.rb new file mode 100644 index 000000000..0de59fe94 --- /dev/null +++ b/app/models/campaign_message.rb @@ -0,0 +1,62 @@ +# == Schema Information +# +# Table name: campaign_messages +# +# id :bigint not null, primary key +# delivered_at :datetime +# error_code :string +# error_description :text +# read_at :datetime +# sent_at :datetime +# status :string default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# campaign_id :bigint not null +# contact_id :bigint not null +# message_id :string +# +# Indexes +# +# index_campaign_messages_on_campaign_id (campaign_id) +# index_campaign_messages_on_campaign_id_and_status (campaign_id,status) +# index_campaign_messages_on_contact_id (contact_id) +# index_campaign_messages_on_message_id (message_id) UNIQUE +# index_campaign_messages_on_status (status) +# +# Foreign Keys +# +# fk_rails_... (campaign_id => campaigns.id) +# fk_rails_... (contact_id => contacts.id) +# +class CampaignMessage < ApplicationRecord + belongs_to :campaign + belongs_to :contact + + STATUS_TYPES = %w[pending sent delivered read failed].freeze + + validates :status, presence: true, inclusion: { in: STATUS_TYPES } + validates :message_id, uniqueness: true, allow_nil: true + + scope :by_status, ->(status) { where(status: status) } + scope :pending, -> { by_status('pending') } + scope :sent, -> { by_status('sent') } + scope :delivered, -> { by_status('delivered') } + scope :read, -> { by_status('read') } + scope :failed, -> { by_status('failed') } + + def failed? + status == 'failed' + end + + def delivered? + status == 'delivered' + end + + def read? + status == 'read' + end + + def sent? + status == 'sent' + end +end diff --git a/app/policies/campaign_policy.rb b/app/policies/campaign_policy.rb index b210fdd99..98de3ca6c 100644 --- a/app/policies/campaign_policy.rb +++ b/app/policies/campaign_policy.rb @@ -18,4 +18,12 @@ class CampaignPolicy < ApplicationPolicy def destroy? @account_user.administrator? end + + def analytics_metrics? + @account_user.administrator? + end + + def analytics_contacts? + @account_user.administrator? + end end diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 0aed8dba0..93d36eeee 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -38,9 +38,17 @@ class Whatsapp::IncomingMessageBaseService end def process_statuses - return unless find_message_by_source_id(@processed_params[:statuses].first[:id]) + status = @processed_params[:statuses].first + whatsapp_message_id = status[:id] - update_message_with_status(@message, @processed_params[:statuses].first) + # Try to find regular message first + message_found = find_message_by_source_id(whatsapp_message_id) + + # Update regular message if found + update_message_with_status(@message, status) if message_found + + # Always try to update campaign message (whether regular message exists or not) + update_campaign_message_with_status(whatsapp_message_id, status) rescue ArgumentError => e Rails.logger.error "Error while processing whatsapp status update #{e.message}" end @@ -191,4 +199,39 @@ class Whatsapp::IncomingMessageBaseService formatted_phone_number = TelephoneNumber.parse(phone_number).international_number @contact.name == phone_number || @contact.name == formatted_phone_number end + + def update_campaign_message_with_status(whatsapp_message_id, status) + campaign_message = CampaignMessage.find_by(message_id: whatsapp_message_id) + return unless campaign_message + + update_attributes = build_campaign_message_attributes(status) + campaign_message.update!(update_attributes) + end + + def build_campaign_message_attributes(status) + status_value = status[:status] + attributes = { status: status_value } + + add_timestamp_to_attributes(attributes, status_value) + add_error_to_attributes(attributes, status) if status_value == 'failed' + + attributes + end + + def add_timestamp_to_attributes(attributes, status_value) + timestamp_field = case status_value + when 'delivered' then :delivered_at + when 'read' then :read_at + end + + attributes[timestamp_field] = Time.current if timestamp_field + end + + def add_error_to_attributes(attributes, status) + return if status[:errors].blank? + + error = status[:errors]&.first + attributes[:error_code] = error[:code] + attributes[:error_description] = "#{error[:code]}: #{error[:title]}" + end end diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index de2713ac0..72a5f8e13 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -45,19 +45,12 @@ class Whatsapp::OneoffCampaignService end def process_contact(contact) - Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})" + campaign_message = create_campaign_message(contact) - if contact.phone_number.blank? - Rails.logger.info "Skipping contact #{contact.name} - no phone number" - return - end + return handle_contact_validation_errors(campaign_message, contact) unless contact_valid?(contact) - if campaign.template_params.blank? - Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign" - return - end - - send_whatsapp_template_message(to: contact.phone_number) + whatsapp_message_id = send_whatsapp_template_message(to: contact.phone_number) + update_campaign_message_status(campaign_message, whatsapp_message_id) end def process_audience(audience_labels) @@ -69,6 +62,35 @@ class Whatsapp::OneoffCampaignService Rails.logger.info "Campaign #{campaign.id} processing completed" end + def contact_valid?(contact) + contact.phone_number.present? && campaign.template_params.present? + end + + def handle_contact_validation_errors(campaign_message, contact) + if contact.phone_number.blank? + campaign_message.update!(status: 'failed', error_code: 'missing_phone_number', + error_description: 'Contact has no phone number') + elsif campaign.template_params.blank? + campaign_message.update!(status: 'failed', error_code: 'missing_template_params', + error_description: 'No template_params found for WhatsApp campaign') + end + end + + def update_campaign_message_status(campaign_message, whatsapp_message_id) + if whatsapp_message_id + campaign_message.update!(message_id: whatsapp_message_id, status: 'sent', sent_at: Time.current) + else + campaign_message.update!(status: 'failed', error_description: 'Failed to send WhatsApp template message') + end + end + + def create_campaign_message(contact) + campaign.campaign_messages.create!( + contact: contact, + status: 'pending' + ) + end + def send_whatsapp_template_message(to:) processor = Whatsapp::TemplateProcessorService.new( channel: channel, @@ -77,7 +99,7 @@ class Whatsapp::OneoffCampaignService name, namespace, lang_code, processed_parameters = processor.call - return if name.blank? + return nil if name.blank? channel.send_template(to, { name: name, diff --git a/app/views/api/v1/accounts/campaigns/analytics_contacts.json.jbuilder b/app/views/api/v1/accounts/campaigns/analytics_contacts.json.jbuilder new file mode 100644 index 000000000..4c40a5645 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/analytics_contacts.json.jbuilder @@ -0,0 +1,21 @@ +json.data @paginated_messages do |campaign_message| + json.contact do + json.id campaign_message.contact.id + json.name campaign_message.contact.name + json.phone_number campaign_message.contact.phone_number + end + json.status campaign_message.status + json.error_code campaign_message.error_code + json.error_description campaign_message.error_description + json.sent_at campaign_message.sent_at + json.delivered_at campaign_message.delivered_at + json.read_at campaign_message.read_at + json.created_at campaign_message.created_at +end + +json.meta do + json.total @paginated_messages.total_count + json.page @paginated_messages.current_page + json.per_page @paginated_messages.limit_value + json.total_pages @paginated_messages.total_pages +end diff --git a/app/views/api/v1/accounts/campaigns/analytics_metrics.json.jbuilder b/app/views/api/v1/accounts/campaigns/analytics_metrics.json.jbuilder new file mode 100644 index 000000000..a53b7d160 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/analytics_metrics.json.jbuilder @@ -0,0 +1,5 @@ +json.total @campaign.analytics_metrics[:total] +json.sent @campaign.analytics_metrics[:sent] +json.delivered @campaign.analytics_metrics[:delivered] +json.read @campaign.analytics_metrics[:read] +json.failed @campaign.analytics_metrics[:failed] diff --git a/config/routes.rb b/config/routes.rb index 10749062e..e42a8f04e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,7 +97,12 @@ Rails.application.routes.draw do end resources :sla_policies, only: [:index, :create, :show, :update, :destroy] resources :custom_roles, only: [:index, :create, :show, :update, :destroy] - resources :campaigns, only: [:index, :create, :show, :update, :destroy] + resources :campaigns, only: [:index, :create, :show, :update, :destroy] do + member do + get 'analytics/metrics', action: :analytics_metrics + get 'analytics/contacts', action: :analytics_contacts + end + end resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] namespace :channels do resource :twilio_channel, only: [:create] diff --git a/db/migrate/20250816044639_create_campaign_messages.rb b/db/migrate/20250816044639_create_campaign_messages.rb new file mode 100644 index 000000000..93a6d6f34 --- /dev/null +++ b/db/migrate/20250816044639_create_campaign_messages.rb @@ -0,0 +1,21 @@ +class CreateCampaignMessages < ActiveRecord::Migration[7.1] + def change + create_table :campaign_messages do |t| + t.references :campaign, null: false, foreign_key: true + t.references :contact, null: false, foreign_key: true + t.string :message_id + t.string :status, default: 'pending', null: false, index: true + t.string :error_code + t.text :error_description + t.datetime :sent_at + t.datetime :delivered_at + t.datetime :read_at + + t.timestamps + end + + add_index :campaign_messages, [:campaign_id, :status] + add_index :campaign_messages, :contact_id unless index_exists?(:campaign_messages, :contact_id) + add_index :campaign_messages, :message_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f44c373c..0d5634b99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do +ActiveRecord::Schema[7.1].define(version: 2025_08_16_044639) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -248,6 +248,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do t.index ["account_id"], name: "index_automation_rules_on_account_id" end + create_table "campaign_messages", force: :cascade do |t| + t.bigint "campaign_id", null: false + t.bigint "contact_id", null: false + t.string "message_id" + t.string "status", default: "pending", null: false + t.string "error_code" + t.text "error_description" + t.datetime "sent_at" + t.datetime "delivered_at" + t.datetime "read_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["campaign_id", "status"], name: "index_campaign_messages_on_campaign_id_and_status" + t.index ["campaign_id"], name: "index_campaign_messages_on_campaign_id" + t.index ["contact_id"], name: "index_campaign_messages_on_contact_id" + t.index ["message_id"], name: "index_campaign_messages_on_message_id", unique: true + t.index ["status"], name: "index_campaign_messages_on_status" + end + create_table "campaigns", force: :cascade do |t| t.integer "display_id", null: false t.string "title", null: false @@ -1202,6 +1221,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "campaign_messages", "campaigns" + add_foreign_key "campaign_messages", "contacts" add_foreign_key "inboxes", "portals" create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). on("accounts"). diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb index d2a02f5b5..deb5075bd 100644 --- a/enterprise/app/models/captain/document.rb +++ b/enterprise/app/models/captain/document.rb @@ -6,6 +6,7 @@ # content :text # external_link :string not null # name :string +# source_type :string default("url") # status :integer default("in_progress"), not null # created_at :datetime not null # updated_at :datetime not null @@ -17,6 +18,7 @@ # index_captain_documents_on_account_id (account_id) # index_captain_documents_on_assistant_id (assistant_id) # index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE +# index_captain_documents_on_source_type (source_type) # index_captain_documents_on_status (status) # class Captain::Document < ApplicationRecord diff --git a/spec/factories/campaign_messages.rb b/spec/factories/campaign_messages.rb new file mode 100644 index 000000000..2cc922de7 --- /dev/null +++ b/spec/factories/campaign_messages.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :campaign_message do + campaign { nil } + contact { nil } + message_id { 'wamid.HBgMOTE5NzQ1Nzg2MjU3FQIAERgSRUVGNDIzQ0UxODZERUJCMDg0AA==' } + status { 'pending' } + error_code { 'MyString' } + error_description { 'MyText' } + sent_at { '2025-08-16 10:17:25' } + delivered_at { '2025-08-16 10:17:25' } + read_at { '2025-08-16 10:17:25' } + end +end