Compare commits

...
12 changed files with 261 additions and 17 deletions
@@ -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
+13 -1
View File
@@ -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?
+62
View File
@@ -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
+8
View File
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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]
+6 -1
View File
@@ -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]
@@ -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
+22 -1
View File
@@ -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").
+13
View File
@@ -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