## Summary One-off SMS and WhatsApp campaigns now show a `Processing` state while the audience send is in progress. The campaign moves to `Completed` after processing finishes, and already-processing campaigns are skipped by the scheduler to avoid duplicate sends. ## Closes - [CW-6037: feat: Introduce an in-progress status for campaigns](https://linear.app/chatwoot/issue/CW-6037/feat-introduce-an-in-progress-status-for-campaigns) ## Screenshot SMS campaign card showing the new `Processing` status. <img width="3840" height="2160" alt="framed-campaign-processing-status" src="https://github.com/user-attachments/assets/de7913b5-65fb-4121-9034-24a568eb0382" /> ## What changed - Added `processing` as a campaign status. - Mark one-off campaigns as `processing` under a row lock before the send service runs. - Complete SMS, Twilio SMS, and WhatsApp one-off campaigns after audience processing finishes. - Keep campaigns in `processing` if an unexpected service error escapes, so the scheduler does not automatically resend the audience. - Added the `Processing` label for SMS and WhatsApp campaign cards. ## Known operational behavior If a worker is interrupted or an unexpected service error escapes after a campaign is marked `processing`, the campaign can remain in `processing`. This is intentional for now to avoid automatic full-audience resends. Installation admins can decide whether to mark the campaign completed or restart it manually from the Rails console after checking what was sent. ## How to test - Create a one-off SMS or WhatsApp campaign scheduled for now. - Run the scheduled job or trigger the campaign job. - Confirm the campaign card shows `Processing` while the audience is being processed. For small audiences, refresh during processing or use a larger audience so the state is observable. - Confirm the campaign moves to `Completed` after audience processing finishes. - Confirm an already-processing campaign is not enqueued again by the scheduled job.
145 lines
3.5 KiB
Vue
145 lines
3.5 KiB
Vue
<script setup>
|
|
import { computed } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
|
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
|
|
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
|
import Button from 'dashboard/components-next/button/Button.vue';
|
|
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
|
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
|
|
|
const props = defineProps({
|
|
title: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
message: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
isLiveChatType: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
isEnabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
status: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
sender: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
inbox: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
scheduledAt: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['edit', 'delete']);
|
|
|
|
const { t } = useI18n();
|
|
|
|
const STATUS_COMPLETED = 'completed';
|
|
const STATUS_PROCESSING = 'processing';
|
|
|
|
const { formatMessage } = useMessageFormatter();
|
|
|
|
const isActive = computed(() =>
|
|
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
|
|
);
|
|
|
|
const statusTextColor = computed(() => ({
|
|
'text-n-teal-11': isActive.value,
|
|
'text-n-slate-12': !isActive.value,
|
|
}));
|
|
|
|
const campaignStatus = computed(() => {
|
|
if (props.isLiveChatType) {
|
|
return props.isEnabled
|
|
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
|
|
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
|
}
|
|
|
|
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 || '');
|
|
|
|
const inboxIcon = computed(() => {
|
|
const { medium, channel_type: type } = props.inbox;
|
|
return getInboxIconByType(type, medium);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<CardLayout layout="row">
|
|
<div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2">
|
|
<div class="flex justify-between gap-3 w-fit">
|
|
<span
|
|
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
|
>
|
|
{{ title }}
|
|
</span>
|
|
<span
|
|
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
|
:class="statusTextColor"
|
|
>
|
|
{{ campaignStatus }}
|
|
</span>
|
|
</div>
|
|
<div
|
|
v-dompurify-html="formatMessage(message, false, false, false)"
|
|
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
|
/>
|
|
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
|
<LiveChatCampaignDetails
|
|
v-if="isLiveChatType"
|
|
:sender="sender"
|
|
:inbox-name="inboxName"
|
|
:inbox-icon="inboxIcon"
|
|
/>
|
|
<SMSCampaignDetails
|
|
v-else
|
|
:inbox-name="inboxName"
|
|
:inbox-icon="inboxIcon"
|
|
:scheduled-at="scheduledAt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-end w-20 gap-2">
|
|
<Button
|
|
v-if="isLiveChatType"
|
|
variant="faded"
|
|
size="sm"
|
|
color="slate"
|
|
icon="i-lucide-sliders-vertical"
|
|
@click="emit('edit')"
|
|
/>
|
|
<Button
|
|
variant="faded"
|
|
color="ruby"
|
|
size="sm"
|
|
icon="i-lucide-trash"
|
|
@click="emit('delete')"
|
|
/>
|
|
</div>
|
|
</CardLayout>
|
|
</template>
|