From bddca26ee33449f2f5cd20e3edca1491d44005a4 Mon Sep 17 00:00:00 2001
From: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Date: Sat, 30 May 2026 14:53:54 +0400
Subject: [PATCH] feat: mvp
---
app/javascript/dashboard/api/campaigns.js | 12 +
.../Campaigns/CampaignCard/CampaignCard.vue | 18 +-
.../Pages/CampaignPage/CampaignList.vue | 13 +-
.../dashboard/i18n/locale/en/campaign.json | 29 ++
.../dashboard/campaigns/campaigns.routes.js | 10 +
.../pages/WhatsAppCampaignAnalyticsPage.vue | 378 ++++++++++++++++++
.../campaigns/pages/WhatsAppCampaignsPage.vue | 10 +
app/models/campaign.rb | 1 +
app/models/channel/whatsapp.rb | 2 +
.../whatsapp/incoming_message_base_service.rb | 2 +
.../whatsapp/oneoff_campaign_service.rb | 2 +
.../whatsapp/providers/base_service.rb | 2 +
config/routes.rb | 7 +-
...260530090000_create_campaign_deliveries.rb | 36 ++
db/schema.rb | 33 +-
.../campaigns/analytics_controller.rb | 83 ++++
enterprise/app/models/campaign_delivery.rb | 80 ++++
enterprise/app/models/enterprise/campaign.rb | 7 +
.../app/models/enterprise/channel/whatsapp.rb | 10 +
.../app/models/enterprise/concerns/contact.rb | 1 +
.../whatsapp/incoming_message_base_service.rb | 10 +
.../whatsapp/oneoff_campaign_service.rb | 102 +++++
.../whatsapp/providers/base_service.rb | 37 ++
23 files changed, 880 insertions(+), 5 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignAnalyticsPage.vue
create mode 100644 db/migrate/20260530090000_create_campaign_deliveries.rb
create mode 100644 enterprise/app/controllers/api/v1/accounts/campaigns/analytics_controller.rb
create mode 100644 enterprise/app/models/campaign_delivery.rb
create mode 100644 enterprise/app/models/enterprise/campaign.rb
create mode 100644 enterprise/app/models/enterprise/channel/whatsapp.rb
create mode 100644 enterprise/app/services/enterprise/whatsapp/incoming_message_base_service.rb
create mode 100644 enterprise/app/services/enterprise/whatsapp/oneoff_campaign_service.rb
create mode 100644 enterprise/app/services/enterprise/whatsapp/providers/base_service.rb
diff --git a/app/javascript/dashboard/api/campaigns.js b/app/javascript/dashboard/api/campaigns.js
index 1c73a3360..b3d2ca3e0 100644
--- a/app/javascript/dashboard/api/campaigns.js
+++ b/app/javascript/dashboard/api/campaigns.js
@@ -1,9 +1,21 @@
+/* global axios */
+
import ApiClient from './ApiClient';
class CampaignsAPI extends ApiClient {
constructor() {
super('campaigns', { accountScoped: true });
}
+
+ analyticsMetrics(id) {
+ return axios.get(`${this.url}/${id}/analytics/metrics`);
+ }
+
+ analyticsContacts(id, { status, page } = {}) {
+ return axios.get(`${this.url}/${id}/analytics/contacts`, {
+ params: { status, page },
+ });
+ }
}
export default new CampaignsAPI();
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
index 05507fc89..bd772dce9 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
@@ -42,9 +42,13 @@ const props = defineProps({
type: Number,
default: 0,
},
+ showAnalytics: {
+ type: Boolean,
+ default: false,
+ },
});
-const emit = defineEmits(['edit', 'delete']);
+const emit = defineEmits(['edit', 'delete', 'analytics']);
const { t } = useI18n();
@@ -116,7 +120,17 @@ const inboxIcon = computed(() => {
/>
-
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/campaign.json b/app/javascript/dashboard/i18n/locale/en/campaign.json
index 10366e79e..c875d8614 100644
--- a/app/javascript/dashboard/i18n/locale/en/campaign.json
+++ b/app/javascript/dashboard/i18n/locale/en/campaign.json
@@ -145,6 +145,7 @@
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
+ "ANALYTICS": "View analytics",
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
@@ -199,6 +200,34 @@
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
+ },
+ "ANALYTICS": {
+ "TITLE": "Campaign analytics",
+ "BACK": "Back to WhatsApp campaigns",
+ "EMPTY": "No delivery records found.",
+ "EMPTY_FILTER": "No {status} delivery records found.",
+ "SUMMARY": "{delivered} of {audience} contacts received this campaign. {skipped} skipped and {failed} failed.",
+ "RATE": "{value} of audience",
+ "PAGE_INFO": "Page {current} of {total}",
+ "FILTERS": {
+ "ALL": "All"
+ },
+ "METRICS": {
+ "AUDIENCE": "Audience",
+ "SENT": "Sent",
+ "DELIVERED": "Delivered",
+ "READ": "Read",
+ "FAILED": "Failed",
+ "SKIPPED": "Skipped"
+ },
+ "TABLE": {
+ "CONTACT": "Contact",
+ "PHONE_NUMBER": "Phone number",
+ "STATUS": "Status",
+ "MESSAGE": "Message",
+ "MESSAGE_NOT_GENERATED": "Not generated",
+ "ERROR_REASON": "Reason"
+ }
}
},
"CONFIRM_DELETE": {
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js
index c5f2dbdb1..dd45196e5 100644
--- a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js
@@ -4,6 +4,7 @@ import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
+import WhatsAppCampaignAnalyticsPage from './pages/WhatsAppCampaignAnalyticsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -60,6 +61,15 @@ const campaignsRoutes = {
},
component: WhatsAppCampaignsPage,
},
+ {
+ path: 'whatsapp/:campaignId/analytics',
+ name: 'campaigns_whatsapp_analytics',
+ meta: {
+ ...meta,
+ featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
+ },
+ component: WhatsAppCampaignAnalyticsPage,
+ },
],
},
],
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignAnalyticsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignAnalyticsPage.vue
new file mode 100644
index 000000000..81b1b4cb5
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignAnalyticsPage.vue
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TITLE') }}
+
+
+ {{ campaign?.title || `#${campaignId}` }}
+
+
+ {{ campaign.inbox.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ state.metrics?.[item.key] || 0 }}
+
+
+ {{
+ t('CAMPAIGN.WHATSAPP.ANALYTICS.RATE', {
+ value: metricRate(item.key),
+ })
+ }}
+
+
+
+
+
+ {{ insightSummary }}
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.CONTACT') }}
+ |
+
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.PHONE_NUMBER') }}
+ |
+
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.STATUS') }}
+ |
+
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.MESSAGE') }}
+ |
+
+ {{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.ERROR_REASON') }}
+ |
+
+
+
+
+ |
+
+ |
+
+
+ |
+ {{ emptyStateMessage }}
+ |
+
+
+
+ |
+ {{ delivery.contact.name || '-' }}
+ |
+
+ {{ delivery.contact.phone_number || '-' }}
+ |
+
+
+ {{ statusLabel(delivery.status) }}
+
+ |
+
+ {{ messageContent(delivery) }}
+ |
+
+ {{ errorReason(delivery) }}
+ |
+
+
+
+
+
+
+
+
+ {{
+ t('CAMPAIGN.WHATSAPP.ANALYTICS.PAGE_INFO', {
+ current: state.meta.current_page || 1,
+ total: state.meta.total_pages || 1,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue
index 96aa21b5a..5576b3aab 100644
--- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue
@@ -10,9 +10,11 @@ import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
+import { useRouter } from 'vue-router';
const { t } = useI18n();
const getters = useStoreGetters();
+const router = useRouter();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
@@ -34,6 +36,13 @@ const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
+
+const handleAnalytics = campaign => {
+ router.push({
+ name: 'campaigns_whatsapp_analytics',
+ params: { campaignId: campaign.id },
+ });
+};
@@ -59,6 +68,7 @@ const handleDelete = campaign => {
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
+ @analytics="handleAnalytics"
/>
true, :compatibility => 1).
on("accounts").
diff --git a/enterprise/app/controllers/api/v1/accounts/campaigns/analytics_controller.rb b/enterprise/app/controllers/api/v1/accounts/campaigns/analytics_controller.rb
new file mode 100644
index 000000000..f582e1481
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/campaigns/analytics_controller.rb
@@ -0,0 +1,83 @@
+class Api::V1::Accounts::Campaigns::AnalyticsController < Api::V1::Accounts::BaseController
+ RESULTS_PER_PAGE = 25
+
+ before_action :campaign
+ before_action :authorize_campaign
+ before_action :ensure_whatsapp_campaign_analytics_enabled!
+
+ def metrics
+ render json: delivery_metrics
+ end
+
+ def contacts
+ deliveries = filtered_deliveries.includes(:contact).page(current_page).per(RESULTS_PER_PAGE)
+
+ render json: {
+ payload: deliveries.map { |delivery| delivery_payload(delivery) },
+ meta: {
+ current_page: deliveries.current_page,
+ total_pages: deliveries.total_pages,
+ total_count: deliveries.total_count
+ }
+ }
+ end
+
+ private
+
+ def campaign
+ @campaign ||= Current.account.campaigns.find_by!(display_id: params[:campaign_id])
+ end
+
+ def ensure_whatsapp_campaign_analytics_enabled!
+ return if @campaign.one_off? && @campaign.inbox.inbox_type == 'Whatsapp' && Current.account.feature_enabled?(:whatsapp_campaign)
+
+ raise Pundit::NotAuthorizedError
+ end
+
+ def authorize_campaign
+ authorize @campaign, :show?
+ end
+
+ def delivery_metrics
+ metric_deliveries = @campaign.campaign_deliveries
+ counts = metric_deliveries.group(:status).count
+
+ {
+ audience: metric_deliveries.count,
+ sent: metric_deliveries.where.not(source_id: nil).count,
+ delivered: counts['delivered'].to_i + counts['read'].to_i,
+ read: counts['read'].to_i,
+ failed: counts['failed'].to_i,
+ skipped: counts['skipped'].to_i
+ }
+ end
+
+ def filtered_deliveries
+ return deliveries unless CampaignDelivery.statuses.key?(params[:status])
+
+ deliveries.where(status: params[:status])
+ end
+
+ def deliveries
+ @deliveries ||= @campaign.campaign_deliveries.order(created_at: :desc)
+ end
+
+ def delivery_payload(delivery)
+ {
+ contact: {
+ id: delivery.contact.id,
+ name: delivery.contact.name,
+ phone_number: delivery.contact.phone_number
+ },
+ status: delivery.status,
+ message_content: delivery.message_content,
+ error_code: delivery.error_code,
+ error_title: delivery.error_title,
+ error_message: delivery.error_message
+ }
+ end
+
+ def current_page
+ params[:page].presence || 1
+ end
+end
diff --git a/enterprise/app/models/campaign_delivery.rb b/enterprise/app/models/campaign_delivery.rb
new file mode 100644
index 000000000..33da26eea
--- /dev/null
+++ b/enterprise/app/models/campaign_delivery.rb
@@ -0,0 +1,80 @@
+class CampaignDelivery < ApplicationRecord
+ belongs_to :account
+ belongs_to :campaign
+ belongs_to :contact
+ belongs_to :inbox
+
+ enum status: {
+ queued: 0,
+ skipped: 1,
+ sent: 2,
+ delivered: 3,
+ read: 4,
+ failed: 5
+ }
+
+ validates :contact_id, uniqueness: { scope: :campaign_id }
+ validates :source_id, uniqueness: true, allow_blank: true
+
+ def mark_sent!(source_id)
+ update!(
+ source_id: source_id,
+ status: :sent,
+ sent_at: Time.current,
+ error_code: nil,
+ error_title: nil,
+ error_message: nil
+ )
+ end
+
+ def mark_skipped!(message)
+ update!(status: :skipped, error_message: message)
+ end
+
+ def mark_failed!(error = {})
+ update!(
+ status: :failed,
+ failed_at: event_time(error[:timestamp]),
+ error_code: error[:code],
+ error_title: error[:title],
+ error_message: error[:message]
+ )
+ end
+
+ def update_from_whatsapp_status!(status)
+ normalized_status = status[:status].to_s
+ return unless %w[delivered read failed].include?(normalized_status)
+ return if status_downgrade?(normalized_status)
+
+ return mark_failed!(whatsapp_error(status)) if normalized_status == 'failed'
+
+ update!(
+ status: normalized_status,
+ "#{normalized_status}_at": event_time(status[:timestamp])
+ )
+ end
+
+ private
+
+ def status_downgrade?(new_status)
+ return false if new_status == 'failed'
+
+ self.class.statuses[new_status] < self.class.statuses[status]
+ end
+
+ def whatsapp_error(status)
+ error = status[:errors]&.first || {}
+ {
+ code: error[:code],
+ title: error[:title],
+ message: error[:message] || error[:error_data]&.dig(:details),
+ timestamp: status[:timestamp]
+ }
+ end
+
+ def event_time(timestamp)
+ return Time.current if timestamp.blank?
+
+ Time.zone.at(timestamp.to_i)
+ end
+end
diff --git a/enterprise/app/models/enterprise/campaign.rb b/enterprise/app/models/enterprise/campaign.rb
new file mode 100644
index 000000000..7112ba658
--- /dev/null
+++ b/enterprise/app/models/enterprise/campaign.rb
@@ -0,0 +1,7 @@
+module Enterprise::Campaign
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :campaign_deliveries, dependent: :destroy
+ end
+end
diff --git a/enterprise/app/models/enterprise/channel/whatsapp.rb b/enterprise/app/models/enterprise/channel/whatsapp.rb
new file mode 100644
index 000000000..e809ad6a7
--- /dev/null
+++ b/enterprise/app/models/enterprise/channel/whatsapp.rb
@@ -0,0 +1,10 @@
+module Enterprise::Channel::Whatsapp
+ attr_reader :last_provider_error
+
+ def send_template(...)
+ provider = provider_service
+ response = provider.send_template(...)
+ @last_provider_error = provider.last_error
+ response
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb
index 84661c991..5d7878792 100644
--- a/enterprise/app/models/enterprise/concerns/contact.rb
+++ b/enterprise/app/models/enterprise/concerns/contact.rb
@@ -2,6 +2,7 @@ module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true, counter_cache: true
+ has_many :campaign_deliveries, dependent: :destroy_async
after_commit :associate_company_from_email,
on: [:create, :update],
diff --git a/enterprise/app/services/enterprise/whatsapp/incoming_message_base_service.rb b/enterprise/app/services/enterprise/whatsapp/incoming_message_base_service.rb
new file mode 100644
index 000000000..8a8ea83a6
--- /dev/null
+++ b/enterprise/app/services/enterprise/whatsapp/incoming_message_base_service.rb
@@ -0,0 +1,10 @@
+module Enterprise::Whatsapp::IncomingMessageBaseService
+ private
+
+ def process_statuses
+ status = @processed_params[:statuses].first
+ CampaignDelivery.find_by(source_id: status[:id])&.update_from_whatsapp_status!(status)
+
+ super
+ end
+end
diff --git a/enterprise/app/services/enterprise/whatsapp/oneoff_campaign_service.rb b/enterprise/app/services/enterprise/whatsapp/oneoff_campaign_service.rb
new file mode 100644
index 000000000..fd12c5e34
--- /dev/null
+++ b/enterprise/app/services/enterprise/whatsapp/oneoff_campaign_service.rb
@@ -0,0 +1,102 @@
+module Enterprise::Whatsapp::OneoffCampaignService
+ def perform
+ validate_campaign!
+ deliveries = create_deliveries(extract_audience_labels)
+
+ # marks campaign completed so that other jobs won't pick it up
+ campaign.completed!
+ process_deliveries(deliveries)
+ end
+
+ private
+
+ def process_delivery(delivery)
+ contact = delivery.contact
+ Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
+
+ if contact.phone_number.blank?
+ Rails.logger.info "Skipping contact #{contact.name} - no phone number"
+ delivery.mark_skipped!('Phone number is missing')
+ return
+ end
+
+ if campaign.template_params.blank?
+ Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
+ delivery.mark_skipped!('Template parameters are missing')
+ return
+ end
+
+ processed_template_params = process_liquid_template_params(contact)
+ if processed_template_params.nil?
+ delivery.mark_skipped!('Template parameters could not be resolved')
+ return
+ end
+
+ delivery.update!(message_content: rendered_message_content(contact))
+
+ send_whatsapp_template_message(delivery: delivery, to: contact.phone_number, template_params: processed_template_params)
+ end
+
+ def create_deliveries(audience_labels)
+ contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
+ Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
+
+ contacts.find_each.map do |contact|
+ campaign.campaign_deliveries.find_or_create_by!(contact: contact) do |delivery|
+ delivery.account = campaign.account
+ delivery.inbox = campaign.inbox
+ end
+ end
+ end
+
+ def process_deliveries(deliveries)
+ deliveries.each { |delivery| process_delivery(delivery) }
+
+ Rails.logger.info "Campaign #{campaign.id} processing completed"
+ end
+
+ def rendered_message_content(contact)
+ Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact).call(campaign.message)
+ end
+
+ def send_whatsapp_template_message(delivery:, to:, template_params:)
+ processor = Whatsapp::TemplateProcessorService.new(
+ channel: channel,
+ template_params: template_params
+ )
+
+ name, namespace, lang_code, processed_parameters = processor.call
+
+ if name.blank?
+ delivery.mark_skipped!('Template name could not be resolved')
+ return
+ end
+
+ source_id = channel.send_template(to, template_info(name, namespace, lang_code, processed_parameters), nil)
+
+ update_delivery_from_provider_response(delivery, source_id)
+ rescue StandardError => e
+ Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
+ Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
+ delivery.mark_failed!(message: e.message)
+ # continue processing remaining contacts
+ nil
+ end
+
+ def template_info(name, namespace, lang_code, processed_parameters)
+ {
+ name: name,
+ namespace: namespace,
+ lang_code: lang_code,
+ parameters: processed_parameters
+ }
+ end
+
+ def update_delivery_from_provider_response(delivery, source_id)
+ if source_id.present?
+ delivery.mark_sent!(source_id)
+ else
+ delivery.mark_failed!(channel.last_provider_error || { message: 'WhatsApp provider did not return a message id' })
+ end
+ end
+end
diff --git a/enterprise/app/services/enterprise/whatsapp/providers/base_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/base_service.rb
new file mode 100644
index 000000000..944eb8575
--- /dev/null
+++ b/enterprise/app/services/enterprise/whatsapp/providers/base_service.rb
@@ -0,0 +1,37 @@
+module Enterprise::Whatsapp::Providers::BaseService
+ attr_reader :last_error
+
+ def process_response(response, message)
+ parsed_response = response.parsed_response
+ if response.success? && parsed_response['error'].blank?
+ @last_error = nil
+ parsed_response['messages'].first['id']
+ else
+ handle_error(response, message)
+ nil
+ end
+ end
+
+ def handle_error(response, message)
+ Rails.logger.error response.body
+ @last_error = parsed_error(response)
+ return if message.blank?
+
+ error_message = @last_error[:message]
+ return if error_message.blank?
+
+ message.external_error = error_message
+ message.status = :failed
+ message.save!
+ end
+
+ def parsed_error(response)
+ parsed_response = response.parsed_response
+ error = parsed_response.is_a?(Hash) ? parsed_response['error'] || {} : {}
+ {
+ code: error['code'],
+ title: error['title'],
+ message: error['error_user_msg'] || error['message']
+ }
+ end
+end