Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e39c52de1 | ||
|
|
0032d604c2 |
@@ -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
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
@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
@@ -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
@@ -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").
|
||||
|
||||
@@ -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