diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 05507fc89..437933e5f 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']); const { t } = useI18n(); const STATUS_COMPLETED = 'completed'; +const STATUS_PROCESSING = 'processing'; const { formatMessage } = useMessageFormatter(); @@ -68,9 +69,15 @@ const campaignStatus = computed(() => { : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); } - return props.status === STATUS_COMPLETED - ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED') - : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); + if (props.status === STATUS_COMPLETED) { + return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED'); + } + + if (props.status === STATUS_PROCESSING) { + return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING'); + } + + return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); }); const inboxName = computed(() => props.inbox?.name || ''); diff --git a/app/javascript/dashboard/i18n/locale/en/campaign.json b/app/javascript/dashboard/i18n/locale/en/campaign.json index 10366e79e..78db922d1 100644 --- a/app/javascript/dashboard/i18n/locale/en/campaign.json +++ b/app/javascript/dashboard/i18n/locale/en/campaign.json @@ -88,6 +88,7 @@ }, "CARD": { "STATUS": { + "PROCESSING": "Processing", "COMPLETED": "Completed", "SCHEDULED": "Scheduled" }, @@ -146,6 +147,7 @@ }, "CARD": { "STATUS": { + "PROCESSING": "Processing", "COMPLETED": "Completed", "SCHEDULED": "Scheduled" }, diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 2927181c6..1d4da7712 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -47,7 +47,7 @@ class Campaign < ApplicationRecord 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 } + enum campaign_status: { active: 0, completed: 1, processing: 2 } has_many :conversations, dependent: :nullify, autosave: true @@ -56,13 +56,27 @@ class Campaign < ApplicationRecord def trigger! return unless one_off? - return if completed? + return unless feature_enabled? + return unless mark_processing! execute_campaign end private + def feature_enabled? + inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign) + end + + def mark_processing! + # Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends. + with_lock do + next if completed? || processing? + + processing! + end + end + def execute_campaign case inbox.inbox_type when 'Twilio SMS' @@ -70,7 +84,7 @@ class Campaign < ApplicationRecord when 'Sms' Sms::OneoffSmsCampaignService.new(campaign: self).perform when 'Whatsapp' - Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign) + Whatsapp::OneoffCampaignService.new(campaign: self).perform end end diff --git a/app/services/sms/oneoff_sms_campaign_service.rb b/app/services/sms/oneoff_sms_campaign_service.rb index b27ef9a41..598042829 100644 --- a/app/services/sms/oneoff_sms_campaign_service.rb +++ b/app/services/sms/oneoff_sms_campaign_service.rb @@ -5,12 +5,10 @@ class Sms::OneoffSmsCampaignService raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off? raise 'Completed Campaign' if campaign.completed? - # marks campaign completed so that other jobs won't pick it up - campaign.completed! - audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id') audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title) process_audience(audience_labels) + campaign.completed! end private diff --git a/app/services/twilio/oneoff_sms_campaign_service.rb b/app/services/twilio/oneoff_sms_campaign_service.rb index 391b043cc..59cbd449e 100644 --- a/app/services/twilio/oneoff_sms_campaign_service.rb +++ b/app/services/twilio/oneoff_sms_campaign_service.rb @@ -5,12 +5,10 @@ class Twilio::OneoffSmsCampaignService raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off? raise 'Completed Campaign' if campaign.completed? - # marks campaign completed so that other jobs won't pick it up - campaign.completed! - audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id') audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title) process_audience(audience_labels) + campaign.completed! end private diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index 7826f71fd..594ee6fd4 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -3,9 +3,8 @@ class Whatsapp::OneoffCampaignService def perform validate_campaign! - # marks campaign completed so that other jobs won't pick it up - campaign.completed! process_audience(extract_audience_labels) + campaign.completed! end private diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb index 6ccb41916..141abdcdd 100644 --- a/spec/jobs/trigger_scheduled_items_job_spec.rb +++ b/spec/jobs/trigger_scheduled_items_job_spec.rb @@ -40,5 +40,13 @@ RSpec.describe TriggerScheduledItemsJob do expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once described_class.perform_now end + + it 'does not trigger campaigns that are already processing' do + create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing) + + expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later) + + described_class.perform_now + end end end diff --git a/spec/models/campaign_spec.rb b/spec/models/campaign_spec.rb index 480f187f3..2be6bd588 100644 --- a/spec/models/campaign_spec.rb +++ b/spec/models/campaign_spec.rb @@ -83,6 +83,38 @@ RSpec.describe Campaign do campaign.save! campaign.trigger! end + + it 'marks the campaign as processing before triggering the service' do + campaign.save! + sms_service = double + + expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service) + expect(sms_service).to receive(:perform) do + expect(campaign.reload.processing?).to be true + end + + campaign.trigger! + end + + it 'does not trigger a processing campaign again' do + campaign.save! + campaign.processing! + + expect(Twilio::OneoffSmsCampaignService).not_to receive(:new) + + campaign.trigger! + end + + it 'keeps the campaign processing when triggering fails' do + campaign.save! + sms_service = double + + expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service) + expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error') + + expect { campaign.trigger! }.to raise_error(StandardError, 'provider error') + expect(campaign.reload.processing?).to be true + end end context 'when SMS campaign' do @@ -107,6 +139,22 @@ RSpec.describe Campaign do end end + context 'when WhatsApp campaign feature is disabled' do + let(:account) { create(:account) } + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) + end + let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) } + + it 'does not mark the campaign as processing' do + expect(Whatsapp::OneoffCampaignService).not_to receive(:new) + + campaign.trigger! + + expect(campaign.reload.active?).to be true + end + end + context 'when Website campaign' do let(:campaign) { build(:campaign) } diff --git a/spec/services/sms/oneoff_sms_campaign_service_spec.rb b/spec/services/sms/oneoff_sms_campaign_service_spec.rb index b1f48b0b9..4d3f4574a 100644 --- a/spec/services/sms/oneoff_sms_campaign_service_spec.rb +++ b/spec/services/sms/oneoff_sms_campaign_service_spec.rb @@ -45,6 +45,19 @@ describe Sms::OneoffSmsCampaignService do expect(campaign.reload.completed?).to be true end + it 'marks the campaign completed after processing the audience' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(sms_channel).to receive(:send_text_message) do + expect(campaign.reload.completed?).to be false + end + + sms_campaign_service.perform + + expect(campaign.reload.completed?).to be true + end + it 'uses liquid template service to process campaign message' do contact = create(:contact, :with_phone_number, account: account) contact.update_labels([label1.title]) diff --git a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb index 90636a439..d5f4c3e9a 100644 --- a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb +++ b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb @@ -61,6 +61,24 @@ describe Twilio::OneoffSmsCampaignService do expect(campaign.reload.completed?).to be true end + it 'marks the campaign completed after processing the audience' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(twilio_messages).to receive(:create).with( + body: campaign.message, + messaging_service_sid: twilio_sms.messaging_service_sid, + to: contact.phone_number, + status_callback: 'http://localhost:3000/twilio/delivery_status' + ) do + expect(campaign.reload.completed?).to be false + end + + sms_campaign_service.perform + + expect(campaign.reload.completed?).to be true + end + it 'uses liquid template service to process campaign message' do contact = create(:contact, :with_phone_number, account: account) contact.update_labels([label1.title]) diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index 00d7fdd3b..ccae8d52f 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -82,6 +82,19 @@ describe Whatsapp::OneoffCampaignService do expect(campaign.reload.completed?).to be true end + it 'marks the campaign completed after processing the audience' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(whatsapp_channel).to receive(:send_template) do + expect(campaign.reload.completed?).to be false + end + + described_class.new(campaign: campaign).perform + + expect(campaign.reload.completed?).to be true + end + it 'processes contacts with matching labels' do contact_with_label1, contact_with_label2, contact_with_both_labels = create_list(:contact, 3, :with_phone_number, account: account)