Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e39c52de1 | ||
|
|
0032d604c2 |
@@ -21,6 +21,17 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def campaign
|
def campaign
|
||||||
|
|||||||
+13
-1
@@ -10,7 +10,7 @@
|
|||||||
# enabled :boolean default(TRUE)
|
# enabled :boolean default(TRUE)
|
||||||
# message :text not null
|
# message :text not null
|
||||||
# scheduled_at :datetime
|
# scheduled_at :datetime
|
||||||
# template_params :jsonb
|
# template_params :jsonb not null
|
||||||
# title :string not null
|
# title :string not null
|
||||||
# trigger_only_during_business_hours :boolean default(FALSE)
|
# trigger_only_during_business_hours :boolean default(FALSE)
|
||||||
# trigger_rules :jsonb
|
# trigger_rules :jsonb
|
||||||
@@ -45,6 +45,8 @@ class Campaign < ApplicationRecord
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
belongs_to :sender, class_name: 'User', optional: true
|
belongs_to :sender, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
has_many :campaign_messages, dependent: :destroy
|
||||||
|
|
||||||
enum campaign_type: { ongoing: 0, one_off: 1 }
|
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.
|
# 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 }
|
enum campaign_status: { active: 0, completed: 1 }
|
||||||
@@ -54,6 +56,16 @@ class Campaign < ApplicationRecord
|
|||||||
before_validation :ensure_correct_campaign_attributes
|
before_validation :ensure_correct_campaign_attributes
|
||||||
after_commit :set_display_id, unless: :display_id?
|
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!
|
def trigger!
|
||||||
return unless one_off?
|
return unless one_off?
|
||||||
return if completed?
|
return if completed?
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,4 +18,12 @@ class CampaignPolicy < ApplicationPolicy
|
|||||||
def destroy?
|
def destroy?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def analytics_metrics?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def analytics_contacts?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -38,9 +38,17 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_statuses
|
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
|
rescue ArgumentError => e
|
||||||
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
|
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
|
||||||
end
|
end
|
||||||
@@ -191,4 +199,39 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -45,19 +45,12 @@ class Whatsapp::OneoffCampaignService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_contact(contact)
|
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?
|
return handle_contact_validation_errors(campaign_message, contact) unless contact_valid?(contact)
|
||||||
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if campaign.template_params.blank?
|
whatsapp_message_id = send_whatsapp_template_message(to: contact.phone_number)
|
||||||
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
|
update_campaign_message_status(campaign_message, whatsapp_message_id)
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
send_whatsapp_template_message(to: contact.phone_number)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_audience(audience_labels)
|
def process_audience(audience_labels)
|
||||||
@@ -69,6 +62,35 @@ class Whatsapp::OneoffCampaignService
|
|||||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||||
end
|
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:)
|
def send_whatsapp_template_message(to:)
|
||||||
processor = Whatsapp::TemplateProcessorService.new(
|
processor = Whatsapp::TemplateProcessorService.new(
|
||||||
channel: channel,
|
channel: channel,
|
||||||
@@ -77,7 +99,7 @@ class Whatsapp::OneoffCampaignService
|
|||||||
|
|
||||||
name, namespace, lang_code, processed_parameters = processor.call
|
name, namespace, lang_code, processed_parameters = processor.call
|
||||||
|
|
||||||
return if name.blank?
|
return nil if name.blank?
|
||||||
|
|
||||||
channel.send_template(to, {
|
channel.send_template(to, {
|
||||||
name: name,
|
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
@@ -97,7 +97,12 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
|
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
|
||||||
resources :custom_roles, 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]
|
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
|
||||||
namespace :channels do
|
namespace :channels do
|
||||||
resource :twilio_channel, only: [:create]
|
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
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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"
|
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||||
end
|
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|
|
create_table "campaigns", force: :cascade do |t|
|
||||||
t.integer "display_id", null: false
|
t.integer "display_id", null: false
|
||||||
t.string "title", 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_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "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"
|
add_foreign_key "inboxes", "portals"
|
||||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||||
on("accounts").
|
on("accounts").
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user