Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b65f86c9 |
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
|||||||
{
|
{
|
||||||
redirect_uri: "#{base_url}/microsoft/callback",
|
redirect_uri: "#{base_url}/microsoft/callback",
|
||||||
scope: scope,
|
scope: scope,
|
||||||
state: state,
|
state: state
|
||||||
prompt: 'consent'
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if redirect_url
|
if redirect_url
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
/* global axios */
|
|
||||||
|
|
||||||
import ApiClient from './ApiClient';
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
class CampaignsAPI extends ApiClient {
|
class CampaignsAPI extends ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('campaigns', { accountScoped: true });
|
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();
|
export default new CampaignsAPI();
|
||||||
|
|||||||
@@ -42,13 +42,9 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
showAnalytics: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete', 'analytics']);
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -120,17 +116,7 @@ const inboxIcon = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end w-28 gap-2">
|
<div class="flex items-center justify-end w-20 gap-2">
|
||||||
<Button
|
|
||||||
v-if="showAnalytics"
|
|
||||||
v-tooltip.top="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
|
|
||||||
variant="faded"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-chart-no-axes-column"
|
|
||||||
:title="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
|
|
||||||
@click="emit('analytics')"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="isLiveChatType"
|
v-if="isLiveChatType"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
|
|||||||
+1
-12
@@ -1,7 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
campaigns: {
|
campaigns: {
|
||||||
@@ -14,13 +12,10 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete', 'analytics']);
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
const STATUS_COMPLETED = 'completed';
|
|
||||||
const { isEnterprise } = useConfig();
|
|
||||||
|
|
||||||
const handleEdit = campaign => emit('edit', campaign);
|
const handleEdit = campaign => emit('edit', campaign);
|
||||||
const handleDelete = campaign => emit('delete', campaign);
|
const handleDelete = campaign => emit('delete', campaign);
|
||||||
const handleAnalytics = campaign => emit('analytics', campaign);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,14 +31,8 @@ const handleAnalytics = campaign => emit('analytics', campaign);
|
|||||||
:inbox="campaign.inbox"
|
:inbox="campaign.inbox"
|
||||||
:scheduled-at="campaign.scheduled_at"
|
:scheduled-at="campaign.scheduled_at"
|
||||||
:is-live-chat-type="isLiveChatType"
|
:is-live-chat-type="isLiveChatType"
|
||||||
:show-analytics="
|
|
||||||
isEnterprise &&
|
|
||||||
campaign.inbox?.channel_type === INBOX_TYPES.WHATSAPP &&
|
|
||||||
campaign.campaign_status === STATUS_COMPLETED
|
|
||||||
"
|
|
||||||
@edit="handleEdit(campaign)"
|
@edit="handleEdit(campaign)"
|
||||||
@delete="handleDelete(campaign)"
|
@delete="handleDelete(campaign)"
|
||||||
@analytics="handleAnalytics(campaign)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -145,7 +145,6 @@
|
|||||||
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
||||||
},
|
},
|
||||||
"CARD": {
|
"CARD": {
|
||||||
"ANALYTICS": "View analytics",
|
|
||||||
"STATUS": {
|
"STATUS": {
|
||||||
"COMPLETED": "Completed",
|
"COMPLETED": "Completed",
|
||||||
"SCHEDULED": "Scheduled"
|
"SCHEDULED": "Scheduled"
|
||||||
@@ -200,38 +199,6 @@
|
|||||||
"ERROR_MESSAGE": "There was an error. Please try again."
|
"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}",
|
|
||||||
"BREADCRUMB": {
|
|
||||||
"CAMPAIGNS": "Campaigns",
|
|
||||||
"WHATSAPP": "WhatsApp"
|
|
||||||
},
|
|
||||||
"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": {
|
"CONFIRM_DELETE": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
|||||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||||
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
||||||
import WhatsAppCampaignAnalyticsPage from './pages/WhatsAppCampaignAnalyticsPage.vue';
|
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -61,15 +60,6 @@ const campaignsRoutes = {
|
|||||||
},
|
},
|
||||||
component: WhatsAppCampaignsPage,
|
component: WhatsAppCampaignsPage,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'whatsapp/:campaignId/analytics',
|
|
||||||
name: 'campaigns_whatsapp_analytics',
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
|
|
||||||
},
|
|
||||||
component: WhatsAppCampaignAnalyticsPage,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
-386
@@ -1,386 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
|
||||||
import CampaignsAPI from 'dashboard/api/campaigns';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
|
||||||
const getters = useStoreGetters();
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
metrics: null,
|
|
||||||
contacts: [],
|
|
||||||
meta: {},
|
|
||||||
status: 'all',
|
|
||||||
page: 1,
|
|
||||||
isFetchingMetrics: false,
|
|
||||||
isFetchingContacts: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const campaignId = computed(() => Number(route.params.campaignId));
|
|
||||||
const campaign = computed(() =>
|
|
||||||
getters['campaigns/getAllCampaigns'].value.find(
|
|
||||||
record => Number(record.id) === campaignId.value
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const campaignTitle = computed(
|
|
||||||
() => campaign.value?.title || `#${campaignId.value}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbItems = computed(() => [
|
|
||||||
{ label: t('CAMPAIGN.WHATSAPP.ANALYTICS.BREADCRUMB.CAMPAIGNS') },
|
|
||||||
{ label: t('CAMPAIGN.WHATSAPP.ANALYTICS.BREADCRUMB.WHATSAPP') },
|
|
||||||
{ label: campaignTitle.value },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ key: 'all', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.FILTERS.ALL') },
|
|
||||||
{ key: 'sent', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SENT') },
|
|
||||||
{
|
|
||||||
key: 'delivered',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.DELIVERED'),
|
|
||||||
},
|
|
||||||
{ key: 'read', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.READ') },
|
|
||||||
{ key: 'failed', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.FAILED') },
|
|
||||||
{ key: 'skipped', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SKIPPED') },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const metricItems = computed(() => [
|
|
||||||
{
|
|
||||||
key: 'audience',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.AUDIENCE'),
|
|
||||||
showRate: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sent',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SENT'),
|
|
||||||
showRate: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delivered',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.DELIVERED'),
|
|
||||||
showRate: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'read',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.READ'),
|
|
||||||
showRate: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'failed',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.FAILED'),
|
|
||||||
showRate: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'skipped',
|
|
||||||
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SKIPPED'),
|
|
||||||
showRate: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasNextPage = computed(() => state.page < Number(state.meta.total_pages));
|
|
||||||
const hasPreviousPage = computed(() => state.page > 1);
|
|
||||||
const audienceCount = computed(() => Number(state.metrics?.audience || 0));
|
|
||||||
|
|
||||||
const insightSummary = computed(() =>
|
|
||||||
t('CAMPAIGN.WHATSAPP.ANALYTICS.SUMMARY', {
|
|
||||||
delivered: state.metrics?.delivered || 0,
|
|
||||||
audience: audienceCount.value,
|
|
||||||
skipped: state.metrics?.skipped || 0,
|
|
||||||
failed: state.metrics?.failed || 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterLabel = option => {
|
|
||||||
const count =
|
|
||||||
option.key === 'all'
|
|
||||||
? audienceCount.value
|
|
||||||
: Number(state.metrics?.[option.key] || 0);
|
|
||||||
|
|
||||||
return `${option.label} ${count}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const metricRate = key => {
|
|
||||||
if (!audienceCount.value) return '0%';
|
|
||||||
|
|
||||||
return `${Math.round((Number(state.metrics?.[key] || 0) / audienceCount.value) * 100)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
|
||||||
state.isFetchingMetrics = true;
|
|
||||||
try {
|
|
||||||
const response = await CampaignsAPI.analyticsMetrics(campaignId.value);
|
|
||||||
state.metrics = response.data;
|
|
||||||
} finally {
|
|
||||||
state.isFetchingMetrics = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchContacts = async () => {
|
|
||||||
state.isFetchingContacts = true;
|
|
||||||
try {
|
|
||||||
const response = await CampaignsAPI.analyticsContacts(campaignId.value, {
|
|
||||||
status: state.status === 'all' ? undefined : state.status,
|
|
||||||
page: state.page,
|
|
||||||
});
|
|
||||||
state.contacts = response.data.payload;
|
|
||||||
state.meta = response.data.meta;
|
|
||||||
} finally {
|
|
||||||
state.isFetchingContacts = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setStatus = status => {
|
|
||||||
state.status = status;
|
|
||||||
state.page = 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreviousPage = () => {
|
|
||||||
if (!hasPreviousPage.value) return;
|
|
||||||
state.page -= 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNextPage = () => {
|
|
||||||
if (!hasNextPage.value) return;
|
|
||||||
state.page += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel = status =>
|
|
||||||
statusOptions.value.find(option => option.key === status)?.label || status;
|
|
||||||
|
|
||||||
const errorReason = delivery =>
|
|
||||||
delivery.error_message || delivery.error_title || delivery.error_code || '-';
|
|
||||||
|
|
||||||
const messageContent = delivery => {
|
|
||||||
if (delivery.message_content) return delivery.message_content;
|
|
||||||
if (delivery.status === 'skipped') {
|
|
||||||
return t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.MESSAGE_NOT_GENERATED');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '-';
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyStateMessage = computed(() => {
|
|
||||||
if (state.status === 'all') return t('CAMPAIGN.WHATSAPP.ANALYTICS.EMPTY');
|
|
||||||
|
|
||||||
return t('CAMPAIGN.WHATSAPP.ANALYTICS.EMPTY_FILTER', {
|
|
||||||
status: statusLabel(state.status).toLowerCase(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusBadgeClass = status =>
|
|
||||||
({
|
|
||||||
sent: 'bg-n-blue-3 text-n-blue-11',
|
|
||||||
delivered: 'bg-n-teal-3 text-n-teal-11',
|
|
||||||
read: 'bg-n-iris-3 text-n-iris-11',
|
|
||||||
failed: 'bg-n-ruby-3 text-n-ruby-11',
|
|
||||||
skipped: 'bg-n-amber-3 text-n-amber-11',
|
|
||||||
})[status] || 'bg-n-slate-3 text-n-slate-11';
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
router.push({ name: 'campaigns_whatsapp_index' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = () => {
|
|
||||||
goBack();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
store.dispatch('campaigns/get');
|
|
||||||
fetchMetrics();
|
|
||||||
fetchContacts();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [state.status, state.page],
|
|
||||||
() => fetchContacts()
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="flex h-full flex-col overflow-hidden bg-n-surface-1">
|
|
||||||
<header class="sticky top-0 z-10 px-6">
|
|
||||||
<div class="mx-auto w-full max-w-7xl">
|
|
||||||
<div class="flex w-full items-center py-7">
|
|
||||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto px-6">
|
|
||||||
<div class="mx-auto w-full max-w-7xl py-4">
|
|
||||||
<div class="mb-6 min-w-0">
|
|
||||||
<h1 class="text-heading-1 text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TITLE') }}
|
|
||||||
</h1>
|
|
||||||
<p class="mt-1 truncate text-sm text-n-slate-11">
|
|
||||||
{{ campaignTitle }}
|
|
||||||
<span
|
|
||||||
v-if="campaign?.inbox?.name"
|
|
||||||
class="before:mx-1 before:text-n-slate-10 before:content-['·']"
|
|
||||||
>
|
|
||||||
{{ campaign.inbox.name }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="state.isFetchingMetrics"
|
|
||||||
class="flex h-24 items-center justify-center text-n-slate-11"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<div v-else class="grid grid-cols-2 gap-3 md:grid-cols-6">
|
|
||||||
<div
|
|
||||||
v-for="item in metricItems"
|
|
||||||
:key="item.key"
|
|
||||||
class="rounded-lg border border-n-weak bg-n-alpha-2 p-4"
|
|
||||||
>
|
|
||||||
<div class="text-sm font-medium text-n-slate-11">
|
|
||||||
{{ item.label }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 text-2xl font-semibold text-n-slate-12">
|
|
||||||
{{ state.metrics?.[item.key] || 0 }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="item.showRate"
|
|
||||||
class="mt-1 text-xs font-medium text-n-slate-10"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('CAMPAIGN.WHATSAPP.ANALYTICS.RATE', {
|
|
||||||
value: metricRate(item.key),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="state.metrics"
|
|
||||||
class="mt-4 rounded-lg border border-n-weak bg-n-alpha-1 px-4 py-3 text-sm text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ insightSummary }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
v-for="option in statusOptions"
|
|
||||||
:key="option.key"
|
|
||||||
size="sm"
|
|
||||||
:variant="state.status === option.key ? 'solid' : 'faded'"
|
|
||||||
:color="state.status === option.key ? 'blue' : 'slate'"
|
|
||||||
:label="filterLabel(option)"
|
|
||||||
@click="setStatus(option.key)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 overflow-hidden rounded-lg border border-n-weak">
|
|
||||||
<table class="w-full table-fixed text-left text-sm">
|
|
||||||
<thead class="bg-n-alpha-2 text-xs font-medium text-n-slate-11">
|
|
||||||
<tr>
|
|
||||||
<th class="w-[15%] px-4 py-3">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.CONTACT') }}
|
|
||||||
</th>
|
|
||||||
<th class="w-[14%] px-4 py-3">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.PHONE_NUMBER') }}
|
|
||||||
</th>
|
|
||||||
<th class="w-[10%] px-4 py-3">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.STATUS') }}
|
|
||||||
</th>
|
|
||||||
<th class="w-[43%] px-4 py-3">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.MESSAGE') }}
|
|
||||||
</th>
|
|
||||||
<th class="w-[18%] px-4 py-3">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.ERROR_REASON') }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-n-weak text-n-slate-12">
|
|
||||||
<tr v-if="state.isFetchingContacts">
|
|
||||||
<td colspan="5" class="h-24 text-center text-n-slate-11">
|
|
||||||
<Spinner />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="state.contacts.length === 0">
|
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-n-slate-11">
|
|
||||||
{{ emptyStateMessage }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<template v-else>
|
|
||||||
<tr
|
|
||||||
v-for="delivery in state.contacts"
|
|
||||||
:key="delivery.contact.id"
|
|
||||||
>
|
|
||||||
<td class="break-words px-4 py-4 align-top">
|
|
||||||
{{ delivery.contact.name || '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="break-words px-4 py-4 align-top">
|
|
||||||
{{ delivery.contact.phone_number || '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4 align-top">
|
|
||||||
<span
|
|
||||||
class="inline-flex h-6 items-center rounded-md px-2 text-xs font-medium capitalize"
|
|
||||||
:class="statusBadgeClass(delivery.status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(delivery.status) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="whitespace-pre-wrap break-words px-4 py-4 align-top text-n-slate-11"
|
|
||||||
:class="{
|
|
||||||
'italic text-n-slate-10': !delivery.message_content,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ messageContent(delivery) }}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="whitespace-pre-wrap break-words px-4 py-4 align-top text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ errorReason(delivery) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-3">
|
|
||||||
<span class="text-sm text-n-slate-11">
|
|
||||||
{{
|
|
||||||
t('CAMPAIGN.WHATSAPP.ANALYTICS.PAGE_INFO', {
|
|
||||||
current: state.meta.current_page || 1,
|
|
||||||
total: state.meta.total_pages || 1,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
variant="faded"
|
|
||||||
icon="i-lucide-chevron-left"
|
|
||||||
:disabled="!hasPreviousPage"
|
|
||||||
@click="goToPreviousPage"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
variant="faded"
|
|
||||||
icon="i-lucide-chevron-right"
|
|
||||||
:disabled="!hasNextPage"
|
|
||||||
@click="goToNextPage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -10,11 +10,9 @@ import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage
|
|||||||
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
|
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
|
||||||
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
|
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
|
||||||
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
|
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const selectedCampaign = ref(null);
|
const selectedCampaign = ref(null);
|
||||||
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
|
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
|
||||||
@@ -36,13 +34,6 @@ const handleDelete = campaign => {
|
|||||||
selectedCampaign.value = campaign;
|
selectedCampaign.value = campaign;
|
||||||
confirmDeleteCampaignDialogRef.value.dialogRef.open();
|
confirmDeleteCampaignDialogRef.value.dialogRef.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAnalytics = campaign => {
|
|
||||||
router.push({
|
|
||||||
name: 'campaigns_whatsapp_analytics',
|
|
||||||
params: { campaignId: campaign.id },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -68,7 +59,6 @@ const handleAnalytics = campaign => {
|
|||||||
v-else-if="!hasNoWhatsAppCampaigns"
|
v-else-if="!hasNoWhatsAppCampaigns"
|
||||||
:campaigns="WhatsAppCampaigns"
|
:campaigns="WhatsAppCampaigns"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@analytics="handleAnalytics"
|
|
||||||
/>
|
/>
|
||||||
<WhatsAppCampaignEmptyState
|
<WhatsAppCampaignEmptyState
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -129,4 +129,3 @@ class Campaign < ApplicationRecord
|
|||||||
"NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);"
|
"NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Campaign.include_mod_with('Campaign')
|
|
||||||
|
|||||||
@@ -140,5 +140,3 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
|
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Channel::Whatsapp.prepend_mod_with('Channel::Whatsapp')
|
|
||||||
|
|||||||
@@ -215,5 +215,3 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Whatsapp::IncomingMessageBaseService.prepend_mod_with('Whatsapp::IncomingMessageBaseService')
|
|
||||||
|
|||||||
@@ -109,5 +109,3 @@ class Whatsapp::OneoffCampaignService
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Whatsapp::OneoffCampaignService.prepend_mod_with('Whatsapp::OneoffCampaignService')
|
|
||||||
|
|||||||
@@ -104,5 +104,3 @@ class Whatsapp::Providers::BaseService
|
|||||||
create_payload('list', message.outgoing_content, JSON.generate(json_hash))
|
create_payload('list', message.outgoing_content, JSON.generate(json_hash))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Whatsapp::Providers::BaseService.prepend_mod_with('Whatsapp::Providers::BaseService')
|
|
||||||
|
|||||||
+1
-6
@@ -124,12 +124,7 @@ Rails.application.routes.draw do
|
|||||||
resources :inbox_limits, only: [:create, :update, :destroy]
|
resources :inbox_limits, only: [:create, :update, :destroy]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :campaigns, only: [:index, :create, :show, :update, :destroy] do
|
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
|
||||||
if ChatwootApp.enterprise?
|
|
||||||
get 'analytics/metrics', to: 'campaigns/analytics#metrics'
|
|
||||||
get 'analytics/contacts', to: 'campaigns/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]
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
class CreateCampaignDeliveries < ActiveRecord::Migration[7.1]
|
|
||||||
def change
|
|
||||||
create_campaign_deliveries
|
|
||||||
add_campaign_delivery_indexes
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def create_campaign_deliveries
|
|
||||||
create_table :campaign_deliveries do |t|
|
|
||||||
t.references :account, null: false, foreign_key: true
|
|
||||||
t.references :campaign, null: false, foreign_key: true
|
|
||||||
t.references :contact, null: false, foreign_key: true
|
|
||||||
t.references :inbox, null: false, foreign_key: true
|
|
||||||
t.string :source_id
|
|
||||||
t.integer :status, null: false, default: 0
|
|
||||||
t.string :error_code
|
|
||||||
t.string :error_title
|
|
||||||
t.text :error_message
|
|
||||||
t.text :message_content
|
|
||||||
t.datetime :sent_at
|
|
||||||
t.datetime :delivered_at
|
|
||||||
t.datetime :read_at
|
|
||||||
t.datetime :failed_at
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_campaign_delivery_indexes
|
|
||||||
add_index :campaign_deliveries, [:account_id, :campaign_id]
|
|
||||||
add_index :campaign_deliveries, [:campaign_id, :status]
|
|
||||||
add_index :campaign_deliveries, [:campaign_id, :contact_id], unique: true
|
|
||||||
add_index :campaign_deliveries, :source_id, unique: true, where: 'source_id IS NOT NULL'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+1
-32
@@ -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: 2026_05_30_090000) do
|
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) 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"
|
||||||
@@ -285,33 +285,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_30_090000) do
|
|||||||
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "campaign_deliveries", force: :cascade do |t|
|
|
||||||
t.bigint "account_id", null: false
|
|
||||||
t.bigint "campaign_id", null: false
|
|
||||||
t.bigint "contact_id", null: false
|
|
||||||
t.bigint "inbox_id", null: false
|
|
||||||
t.string "source_id"
|
|
||||||
t.integer "status", default: 0, null: false
|
|
||||||
t.string "error_code"
|
|
||||||
t.string "error_title"
|
|
||||||
t.text "error_message"
|
|
||||||
t.text "message_content"
|
|
||||||
t.datetime "sent_at"
|
|
||||||
t.datetime "delivered_at"
|
|
||||||
t.datetime "read_at"
|
|
||||||
t.datetime "failed_at"
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["account_id", "campaign_id"], name: "index_campaign_deliveries_on_account_id_and_campaign_id"
|
|
||||||
t.index ["account_id"], name: "index_campaign_deliveries_on_account_id"
|
|
||||||
t.index ["campaign_id", "contact_id"], name: "index_campaign_deliveries_on_campaign_id_and_contact_id", unique: true
|
|
||||||
t.index ["campaign_id", "status"], name: "index_campaign_deliveries_on_campaign_id_and_status"
|
|
||||||
t.index ["campaign_id"], name: "index_campaign_deliveries_on_campaign_id"
|
|
||||||
t.index ["contact_id"], name: "index_campaign_deliveries_on_contact_id"
|
|
||||||
t.index ["inbox_id"], name: "index_campaign_deliveries_on_inbox_id"
|
|
||||||
t.index ["source_id"], name: "index_campaign_deliveries_on_source_id", unique: true, where: "(source_id IS NOT NULL)"
|
|
||||||
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
|
||||||
@@ -1350,10 +1323,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_30_090000) 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_deliveries", "accounts"
|
|
||||||
add_foreign_key "campaign_deliveries", "campaigns"
|
|
||||||
add_foreign_key "campaign_deliveries", "contacts"
|
|
||||||
add_foreign_key "campaign_deliveries", "inboxes"
|
|
||||||
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").
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module Enterprise::Campaign
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
included do
|
|
||||||
has_many :campaign_deliveries, dependent: :destroy
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -2,7 +2,6 @@ module Enterprise::Concerns::Contact
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
included do
|
included do
|
||||||
belongs_to :company, optional: true, counter_cache: true
|
belongs_to :company, optional: true, counter_cache: true
|
||||||
has_many :campaign_deliveries, dependent: :destroy_async
|
|
||||||
|
|
||||||
after_commit :associate_company_from_email,
|
after_commit :associate_company_from_email,
|
||||||
on: [:create, :update],
|
on: [:create, :update],
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
|
|||||||
]
|
]
|
||||||
expect(params['scope']).to eq(expected_scope)
|
expect(params['scope']).to eq(expected_scope)
|
||||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
|
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
|
||||||
|
expect(url).not_to match(/(?:\?|&)prompt=/)
|
||||||
|
|
||||||
# Validate state parameter exists and can be decoded back to the account
|
# Validate state parameter exists and can be decoded back to the account
|
||||||
expect(params['state']).to be_present
|
expect(params['state']).to be_present
|
||||||
|
|||||||
Reference in New Issue
Block a user