Compare commits

..
Author SHA1 Message Date
Tanmay Sharma 933d310185 assignment_v2: add inbox concerns used by Inbox 2025-08-11 09:09:32 +05:30
Tanmay Sharma 9f1a2b53ae assignment_v2: keep routes minimal for assignment policy only (revert other feature routes) 2025-08-11 09:08:01 +05:30
Tanmay Sharma b2ada112d7 assignment_v2: import assignment policy controllers/models/services/job/policy/specs 2025-08-11 09:06:30 +05:30
Sivin VargheseandGitHub 304c938260 fix: Styles issues with conversation card (#12107)
# Pull Request Template

## Description

This PR includes the following changes:

1. Fixes a couple of UI issues.
2. Moves all styles to inline classes.
3. Migrates the `ConversationCard.vue` component from the Options API to
the Composition API.


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots
**Before**
<img width="353" height="550" alt="image"
src="https://github.com/user-attachments/assets/070c9bf1-6077-48d8-832d-79037b794f42"
/>



**After**
<img width="353" height="541" alt="image"
src="https://github.com/user-attachments/assets/c54bf69c-d175-45cf-a6fe-9c7ab6f66226"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-08-06 15:28:38 +05:30
53 changed files with 3355 additions and 2755 deletions
@@ -0,0 +1,79 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
@@ -38,11 +38,13 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</div>
</template>
@@ -9,7 +9,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const emit = defineEmits(['submit', 'cancel']);
@@ -19,9 +18,7 @@ const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
@@ -33,7 +30,7 @@ const initialState = {
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
@@ -70,7 +67,7 @@ const inboxOptions = computed(() =>
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
@@ -91,6 +88,26 @@ const selectedTemplate = computed(() => {
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
@@ -105,7 +122,8 @@ const formErrors = computed(() => ({
}));
const hasRequiredTemplateParams = computed(() => {
return templateParserRef.value?.v$?.$invalid === false || true;
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
@@ -117,18 +135,32 @@ const formatToUTCString = localDateTime =>
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
// Extract template content - this should be the template message body
const templateContent = parserData?.renderedTemplate || '';
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
@@ -136,7 +168,7 @@ const prepareCampaignDetails = () => {
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
processed_params: processedParams.value,
};
return {
@@ -166,6 +198,15 @@ watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
@@ -213,12 +254,62 @@ watch(
</p>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
<!-- Template Preview -->
<div
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
/>
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
@@ -25,7 +25,6 @@ const props = defineProps({
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
@@ -151,10 +150,9 @@ useKeyboardEvents(keyboardEvents);
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -208,7 +206,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -336,7 +336,6 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@@ -1,24 +1,23 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
messageTemplates: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -26,7 +25,19 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
});
const filteredTemplates = computed(() => {
@@ -76,7 +87,7 @@ const handleSendMessage = template => {
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute top-2 left-3 i-lucide-search size-3.5" />
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
@@ -85,13 +96,13 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="py-2 pr-2 pl-10 w-full h-8 text-sm rounded-lg border-none outline-none reset-base bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -100,7 +111,7 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
<p class="w-full pt-2 text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
@@ -1,12 +1,16 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
defineProps({
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
required: true,
},
});
@@ -14,50 +18,159 @@ const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
<template #actions="{ sendMessage, goBack }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -1,247 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
allKeysRequired,
replaceTemplateVariables,
} from 'dashboard/helper/templateHelper';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
});
const categoryLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'UTILITY'}`;
});
const headerComponent = computed(() => {
return props.template.components.find(
component => component.type === 'HEADER'
);
});
const bodyComponent = computed(() => {
return props.template.components.find(component => component.type === 'BODY');
});
const bodyText = computed(() => {
return bodyComponent.value.text;
});
const hasMediaHeader = computed(() => {
return (
headerComponent.value &&
headerComponent.value.format &&
['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerComponent.value.format)
);
});
const hasVariables = computed(() => {
return bodyText.value.match(/{{([^}]+)}}/g);
});
const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(hasVariables),
allKeysRequired,
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
const templateParameters = buildTemplateParameters(
props.template,
hasMediaHeader.value
);
processedParams.value = templateParameters;
};
const updateMediaUrl = value => {
if (!processedParams.value.header) {
processedParams.value.header = {};
}
processedParams.value.header.media_url = value;
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const finalParams = processedParams.value;
const payload = {
message: renderedTemplate.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: finalParams,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaHeader">
<div v-if="hasMediaHeader" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
type:
headerComponent.format.charAt(0) +
headerComponent.format.slice(1).toLowerCase(),
}) ||
`${
headerComponent.format.charAt(0) +
headerComponent.format.slice(1).toLowerCase()
} Header`
}}
</p>
<div class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_url || ''"
type="url"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
type:
headerComponent.format.charAt(0) +
headerComponent.format.slice(1).toLowerCase(),
})
"
@update:model-value="updateMediaUrl"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="processedParams.body">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams.body"
:key="`body-${key}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.body[key]"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
<!-- Button Variables Section -->
<div v-if="processedParams.buttons">
<p class="mb-2.5 text-sm font-semibold">
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
</p>
<div
v-for="(button, index) in processedParams.buttons"
:key="`button-${index}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.buttons[index].parameter"
type="text"
class="flex-1"
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
/>
</div>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid"
/>
</div>
</template>
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,257 +14,224 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
assignee() {
return this.chatMetadata.assignee || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const router = useRouter();
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
unreadCount() {
return this.chat.unread_count;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactById = useMapGetter('contacts/getContact');
const inboxById = useMapGetter('inboxes/getInbox');
hasUnread() {
return this.unreadCount > 0;
},
const chatMetadata = computed(() => props.chat.meta || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const currentContact = computed(() => {
return contactById.value(chatMetadata.value.sender.id);
});
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
conversationPath() {
const { activeInbox, chat } = this;
return frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
const unreadCount = computed(() => props.chat.unread_count);
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
const hasUnread = computed(() => unreadCount.value > 0);
// Skip if already active
if (this.isActiveChat) return;
const isInboxNameVisible = computed(() => !activeInbox.value);
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
const stateInbox = inboxById.value(inboxId);
return stateInbox;
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -276,13 +243,14 @@ export default {
>
<label
v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
@@ -290,22 +258,30 @@ export default {
<Thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
/>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="flex items-center conversation-card--meta min-w-0">
<InboxName
v-if="showInboxName"
:inbox="inbox"
class="flex-1 min-w-0 mx-2"
/>
<div class="flex items-center gap-2 flex-shrink-0">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
@@ -317,7 +293,7 @@ export default {
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -325,24 +301,27 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -350,12 +329,17 @@ export default {
/>
</span>
<span
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -388,55 +372,3 @@ export default {
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -1,4 +1,4 @@
<script setup>
<script>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
@@ -8,50 +8,158 @@
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
template: {
type: Object,
default: () => ({}),
export default {
components: {
NextButton,
},
});
props: {
template: {
type: Object,
default: () => ({}),
},
},
emits: ['sendMessage', 'resetTemplate'],
setup(props, { emit }) {
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const processedParams = ref({});
const handleResetTemplate = () => {
emit('resetTemplate');
const templateString = computed(() => {
return props.template.components.find(
component => component.type === 'BODY'
).text;
});
const variables = computed(() => {
return templateString.value.match(/{{([^}]+)}}/g);
});
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(variables),
allKeysRequired,
},
},
{ processedParams }
);
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const resetTemplate = () => {
emit('resetTemplate');
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(generateVariables);
return {
processedParams,
variables,
templateString,
processedString,
v$,
resetTemplate,
sendMessage,
};
},
};
</script>
<template>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
<textarea
v-model="processedString"
rows="4"
readonly
class="template-input"
/>
<div v-if="variables" class="p-2.5">
<p class="text-sm font-semibold mb-2.5">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="items-center flex mb-2.5"
>
<span
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
>
{{ key }}
</span>
<woot-input
v-model="processedParams[key]"
type="text"
class="flex-1 text-sm ml-2.5"
:styles="{ marginBottom: 0 }"
/>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<footer class="flex justify-end gap-2">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer>
</div>
</template>
@@ -1,6 +1,8 @@
<script>
import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
export default {
components: {
@@ -21,9 +23,14 @@ export default {
},
computed: {
whatsAppTemplateMessages() {
return this.$store.getters['inboxes/getFilteredWhatsAppTemplates'](
this.inboxId
);
// TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
@@ -32,31 +39,9 @@ export default {
},
},
methods: {
getTemplateBody(template) {
return (
template.components.find(component => component.type === 'BODY')
?.text || ''
);
},
getTemplateHeader(template) {
return template.components.find(component => component.type === 'HEADER');
},
getTemplateFooter(template) {
return template.components.find(component => component.type === 'FOOTER');
},
getTemplateButtons(template) {
return template.components.find(
component => component.type === 'BUTTONS'
);
},
hasMediaContent(template) {
const header = this.getTemplateHeader(template);
return (
header &&
(header.format === 'IMAGE' ||
header.format === 'VIDEO' ||
header.format === 'DOCUMENT')
);
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
},
async refreshTemplates() {
this.isRefreshing = true;
@@ -116,72 +101,21 @@ export default {
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }}
</span>
</div>
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
$t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) || `${getTemplateHeader(template).format} media content`
}}
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
<p>{{ template.category }}</p>
</div>
</div>
</button>
@@ -263,7 +263,7 @@ export default {
<style scoped lang="scss">
.bulk-action__container {
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@@ -1,368 +0,0 @@
import {
replaceTemplateVariables,
buildTemplateParameters,
processVariable,
allKeysRequired,
} from '../templateHelper';
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
describe('templateHelper', () => {
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
describe('processVariable', () => {
it('should remove curly braces from variables', () => {
expect(processVariable('{{name}}')).toBe('name');
expect(processVariable('{{1}}')).toBe('1');
expect(processVariable('{{customer_id}}')).toBe('customer_id');
});
});
describe('allKeysRequired', () => {
it('should return true when all keys have values', () => {
const obj = { name: 'John', age: '30' };
expect(allKeysRequired(obj)).toBe(true);
});
it('should return false when some keys are empty', () => {
const obj = { name: 'John', age: '' };
expect(allKeysRequired(obj)).toBe(false);
});
it('should return true for empty object', () => {
expect(allKeysRequired({})).toBe(true);
});
});
describe('replaceTemplateVariables', () => {
const templateText =
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
it('should replace all variables with provided values', () => {
const processedParams = {
body: {
1: 'John',
2: '123 Main St',
3: '2025-01-15',
4: '10:00 AM',
5: '2:00 PM',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
);
});
it('should keep original variable format when no replacement value provided', () => {
const processedParams = {
body: {
1: 'John',
3: '2025-01-15',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toContain('John');
expect(result).toContain('2025-01-15');
expect(result).toContain('{{2}}');
expect(result).toContain('{{4}}');
expect(result).toContain('{{5}}');
});
it('should handle empty processedParams', () => {
const result = replaceTemplateVariables(templateText, {});
expect(result).toBe(templateText);
});
});
describe('buildTemplateParameters', () => {
it('should build parameters for template with body variables', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.body).toEqual({
1: '',
2: '',
3: '',
4: '',
5: '',
});
});
it('should include header parameters when hasMediaHeader is true', () => {
const imageTemplate = templates.find(
t => t.name === 'order_confirmation'
);
const result = buildTemplateParameters(imageTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
});
it('should not include header parameters when hasMediaHeader is false', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.header).toBeUndefined();
});
it('should handle template with no body component', () => {
const templateWithoutBody = {
components: [{ type: 'HEADER', format: 'TEXT' }],
};
const result = buildTemplateParameters(templateWithoutBody, false);
expect(result).toEqual({});
});
it('should handle template with no variables', () => {
const templateWithoutVars = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(templateWithoutVars, false);
expect(result.body).toBeUndefined();
});
it('should handle URL buttons with variables for non-authentication templates', () => {
const templateWithUrlButton = {
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'Check out our website at {{site_url}}',
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
url: 'https://example.com/{{campaign_id}}',
text: 'Visit Site',
},
],
},
],
};
const result = buildTemplateParameters(templateWithUrlButton, false);
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{campaign_id}}',
variables: ['campaign_id'],
},
]);
});
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
const complexTemplate = {
components: [
{ type: 'HEADER', format: 'IMAGE' },
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
{ type: 'FOOTER', text: 'Thank you for your business' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
},
],
};
const result = buildTemplateParameters(complexTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
expect(result.body).toEqual({ 1: '', 2: '' });
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{3}}',
variables: ['3'],
},
]);
});
it('should handle copy code buttons correctly', () => {
const copyCodeTemplate = templates.find(
t => t.name === 'discount_coupon'
);
const result = buildTemplateParameters(copyCodeTemplate, false);
expect(result.body).toBeDefined();
expect(result.buttons).toEqual([
{
type: 'copy_code',
parameter: '',
},
]);
});
it('should handle templates with document headers', () => {
const documentTemplate = templates.find(
t => t.name === 'purchase_receipt'
);
const result = buildTemplateParameters(documentTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
});
expect(result.body).toEqual({
1: '',
2: '',
3: '',
});
});
it('should handle video header templates', () => {
const videoTemplate = templates.find(t => t.name === 'training_video');
const result = buildTemplateParameters(videoTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
name: '',
date: '',
});
});
});
describe('enhanced format validation', () => {
it('should validate enhanced format structure', () => {
const processedParams = {
body: { 1: 'John', 2: 'Order123' },
header: {
media_url: 'https://example.com/image.jpg',
media_type: 'image',
},
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
};
// Test that structure is properly formed
expect(processedParams.body).toBeDefined();
expect(typeof processedParams.body).toBe('object');
expect(processedParams.header).toBeDefined();
expect(Array.isArray(processedParams.buttons)).toBe(true);
});
it('should handle empty component sections', () => {
const processedParams = {
body: {},
header: {},
buttons: [],
};
expect(allKeysRequired(processedParams.body)).toBe(true);
expect(allKeysRequired(processedParams.header)).toBe(true);
expect(processedParams.buttons.length).toBe(0);
});
it('should validate parameter completeness', () => {
const incompleteParams = {
body: { 1: 'John', 2: '' },
};
expect(allKeysRequired(incompleteParams.body)).toBe(false);
});
it('should handle edge cases in processVariable', () => {
expect(processVariable('{{')).toBe('');
expect(processVariable('}}')).toBe('');
expect(processVariable('')).toBe('');
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
});
it('should handle special characters in template variables', () => {
/* eslint-disable no-template-curly-in-string */
const templateText =
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
/* eslint-enable no-template-curly-in-string */
const processedParams = {
body: {
user_name: 'John & Jane',
order_id: '12345',
amount: '99.99',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
'Welcome John & Jane, your order #12345 costs $99.99'
);
});
it('should handle templates with mixed parameter types', () => {
const mixedTemplate = {
components: [
{ type: 'HEADER', format: 'VIDEO' },
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
{ type: 'FOOTER', text: 'Thank you' },
{
type: 'BUTTONS',
buttons: [
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
{ type: 'COPY_CODE' },
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
],
},
],
};
const result = buildTemplateParameters(mixedTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
order_id: '',
status: '',
});
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
expect(result.buttons[0].type).toBe('url');
expect(result.buttons[1].type).toBe('copy_code');
});
it('should handle templates with no processable components', () => {
const emptyTemplate = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
{ type: 'BODY', text: 'Static body with no variables' },
{ type: 'FOOTER', text: 'Static footer' },
],
};
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
const partialParams = {
body: {
name: 'John',
// order_id missing
status: 'ready',
},
};
const result = replaceTemplateVariables(templateText, partialParams);
expect(result).toBe('Hi John, order {{order_id}} is ready');
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
});
});
});
@@ -1,82 +0,0 @@
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
export const replaceTemplateVariables = (templateText, processedParams) => {
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.body?.[variableKey] || `{{${variable}}}`;
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = template.components.find(
component => component.type === 'BODY'
);
const headerComponent = template.components.find(
component => component.type === 'HEADER'
);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === 'BUTTONS'
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
@@ -1,45 +1,29 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
}
}
}
@@ -61,8 +61,8 @@ export default {
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact"
/>
</div>
</div>
@@ -75,12 +75,4 @@ export default {
.no-label-message {
@apply text-n-slate-11 mb-4;
}
::v-deep .conversation {
@apply pr-0;
.conversation--details {
@apply pl-2;
}
}
</style>
@@ -44,50 +44,15 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
return messagesTemplates;
},
getFilteredWhatsAppTemplates: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
const {
message_templates: whatsAppMessageTemplates,
additional_attributes: additionalAttributes,
} = inbox || {};
const { message_templates: apiInboxMessageTemplates } =
additionalAttributes || {};
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
if (!templates || !Array.isArray(templates)) {
return [];
// filtering out the whatsapp templates with media
if (messagesTemplates instanceof Array) {
return messagesTemplates.filter(template => {
return !template.components.some(
i => i.format === 'IMAGE' || i.format === 'VIDEO'
);
});
}
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG) and location templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG'].includes(component.type) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
return [];
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -1,6 +1,5 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -94,269 +93,4 @@ describe('#getters', () => {
provider: 'default',
});
});
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };
expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
});
it('returns empty array when templates is null or undefined', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: { message_templates: undefined },
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('returns empty array when templates is not an array', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: 'invalid',
additional_attributes: {},
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out templates without required properties', () => {
const invalidTemplates = [
{ name: 'incomplete_template' }, // missing status and components
{ status: 'approved' }, // missing name and components
{ name: 'another_incomplete', status: 'approved' }, // missing components
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: invalidTemplates,
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out non-approved templates', () => {
const mixedStatusTemplates = [
{
name: 'pending_template',
status: 'pending',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'rejected_template',
status: 'rejected',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'approved_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Test' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: mixedStatusTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('approved_template');
});
it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
const interactiveTemplates = [
{
name: 'list_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Choose an option' },
{ type: 'LIST', sections: [] },
],
},
{
name: 'product_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Product info' },
{ type: 'PRODUCT', catalog_id: '123' },
],
},
{
name: 'catalog_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Catalog' },
{ type: 'CATALOG', thumbnail_product_retailer_id: '123' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Regular message' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: interactiveTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('filters out location templates', () => {
const locationTemplates = [
{
name: 'location_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'LOCATION' },
{ type: 'BODY', text: 'Location message' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
{ type: 'BODY', text: 'Regular message' },
],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: locationTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('returns valid templates from fixture data', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: templates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
// All templates in fixtures should be approved and valid
expect(result.length).toBeGreaterThan(0);
// Verify all returned templates are approved
result.forEach(template => {
expect(template.status).toBe('approved');
expect(template.components).toBeDefined();
expect(Array.isArray(template.components)).toBe(true);
});
// Verify specific templates from fixtures are included
const templateNames = result.map(t => t.name);
expect(templateNames).toContain('sample_flight_confirmation');
expect(templateNames).toContain('sample_issue_resolution');
expect(templateNames).toContain('sample_shipping_confirmation');
expect(templateNames).toContain('no_variable_template');
expect(templateNames).toContain('order_confirmation');
});
it('prioritizes message_templates over additional_attributes.message_templates', () => {
const primaryTemplates = [
{
name: 'primary_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Primary' }],
},
];
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: primaryTemplates,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('primary_template');
});
it('falls back to additional_attributes.message_templates when message_templates is null', () => {
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('fallback_template');
});
});
});
@@ -260,285 +260,4 @@ export const templates = [
],
rejected_reason: 'NONE',
},
{
name: 'order_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://example.com/shoes.jpg'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'technician_visit',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'event_invitation_static',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'purchase_receipt',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'discount_coupon',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'support_callback',
status: 'approved',
category: 'UTILITY',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+16506677566',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'training_video',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'VIDEO',
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'product_launch',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'greet',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'hello_world',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'feedback_request',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'address_update',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'delivery_confirmation',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
];
@@ -0,0 +1,61 @@
import { shallowMount } from '@vue/test-utils';
import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
import { templates } from './fixtures';
import { nextTick } from 'vue';
const config = {
global: {
stubs: {
NextButton: { template: '<button />' },
WootInput: { template: '<input />' },
},
},
};
describe('#WhatsAppTemplates', () => {
it('returns all variables from a template string', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[0] },
});
await nextTick();
expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
});
it('returns no variables from a template string if it does not contain variables', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[12] },
});
await nextTick();
expect(wrapper.vm.variables).toBeNull();
});
it('returns the body of a template', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[1] },
});
await nextTick();
const expectedOutput =
templates[1].components.find(i => i.type === 'BODY')?.text || '';
expect(wrapper.vm.templateString).toEqual(expectedOutput);
});
it('generates the templates from variable input', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[0] },
});
await nextTick();
// Instead of using `setData`, directly modify the `processedParams` using the component's logic
await wrapper.vm.$nextTick();
wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
await wrapper.vm.$nextTick();
const expectedOutput =
'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
expect(wrapper.vm.processedString).toEqual(expectedOutput);
});
});
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
+103
View File
@@ -0,0 +1,103 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(10), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_enabled (enabled)
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
#
class AssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Enums
enum assignment_order: { round_robin: 0, balanced: 1 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
# Validate balanced assignment is only available for enterprise
validate :validate_balanced_assignment_enterprise_only
# Server-side validation to prevent bypass
before_save :enforce_enterprise_features
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
# Callbacks
after_update_commit :clear_assignment_caches
after_destroy :clear_assignment_caches
def can_use_balanced_assignment?
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
end
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
private
def validate_balanced_assignment_enterprise_only
return unless balanced?
return if can_use_balanced_assignment?
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
end
def enforce_enterprise_features
# Server-side enforcement to prevent API bypass
return unless balanced? && !can_use_balanced_assignment?
# Force to round_robin if enterprise not available
self.assignment_order = 'round_robin'
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
end
def clear_assignment_caches
# Clear Redis caches when policy is updated
Rails.cache.delete("assignment_v2:policy:#{id}")
inboxes.find_each do |inbox|
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
end
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module AssignmentV2FeatureFlag
extend ActiveSupport::Concern
def assignment_v2_enabled?
# Check account-level feature flag
return false unless feature_enabled_for_account?
# Check for any inbox-level overrides
return false if inbox_level_override_disabled?
# Check system-wide killswitch
!GlobalConfig.get('assignment_v2_disabled', false)
end
private
def feature_enabled_for_account?
config = GlobalConfig.get('assignment_v2')
return false unless config&.dig('enabled')
# If no account allowlist, enable for all
allowed_accounts = config['accounts']
return true if allowed_accounts.blank?
# Check if account is in allowlist
allowed_accounts.include?(id)
end
def inbox_level_override_disabled?
return false unless respond_to?(:id)
# Allow per-inbox disabling during migration
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
end
end
@@ -14,11 +14,18 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
if inbox.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
else
# Use legacy assignment system
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -0,0 +1,128 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
available_agents(check_capacity: true).pluck(:user_id)
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
def filter_agents_on_leave(inbox_members_scope)
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
# Filter out agents who are currently on leave
on_leave_user_ids = Enterprise::AgentLeave
.active
.where(account_id: account_id)
.pluck(:user_id)
return inbox_members_scope if on_leave_user_ids.empty?
inbox_members_scope.where.not(user_id: on_leave_user_ids)
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module InboxChannelTypes
extend ActiveSupport::Concern
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def inbox_type
channel.name
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
end
end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module InboxNameSanitization
extend ActiveSupport::Concern
included do
before_validation :sanitize_name, unless: :new_record?
before_save :ensure_name_present
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return handle_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
return sanitized if sanitized.present?
email? ? (display_name_from_email || '') : ''
end
private
def handle_blank_name
email? ? (display_name_from_email || '') : ''
end
def sanitize_name
self.name = apply_sanitization_rules(name) if name.present?
end
def ensure_name_present
self.name = default_name_for_blank_name if name.blank?
self.name = apply_sanitization_rules(name) if name.present?
end
def default_name_for_blank_name
return channel.try(:bot_name) if web_widget?
readable_name = display_name_from_email if email?
readable_name ||= 'Inbox'
"#{readable_name} #{SecureRandom.hex(4)}"
end
def apply_sanitization_rules(name)
# Remove forbidden characters and spam-trigger symbols
# Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis)
sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '')
# Normalize whitespace
sanitized = sanitized.gsub(/\s+/, ' ')
# Remove leading and trailing non-word characters (but keep Unicode including emojis)
# Use negative lookahead to exclude emoji ranges
sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '')
sanitized.strip
end
def display_name_from_email
email_address = channel.try(:imap_email) || channel.try(:email)
return nil unless email_address
local_part = email_address.split('@').first
return nil unless local_part
# Convert underscores and hyphens to spaces and capitalize each word
local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ')
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module InboxWebhooks
extend ActiveSupport::Concern
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
end
end
end
+153 -86
View File
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include AssignmentV2FeatureFlag
include InboxAgentAvailability
include InboxChannelTypes
include InboxWebhooks
include InboxNameSanitization
# Not allowing characters:
validates :name, presence: true
@@ -72,6 +77,10 @@ class Inbox < ApplicationRecord
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -97,109 +106,167 @@ class Inbox < ApplicationRecord
update_account_cache
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
# Assignment V2 methods
def assignment_v2_enabled?
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
end
def inbox_type
channel.name
end
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
def member_ids_with_assignment_capacity
members.ids
# Returns inbox members who are available for assignment
# This method performs all filtering upfront at the database level for optimal performance
#
# Filters applied:
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
#
# @param options [Hash] Additional filter options
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
#
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
#
# @example Get all available agents
# inbox.available_agents
#
# @example Get available agents excluding specific users
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
#
# @example Get available agents without capacity check (faster but less accurate)
# inbox.available_agents(check_capacity: false)
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
private
def default_name_for_blank_name
email? ? display_name_from_email : ''
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
# Get IDs of inbox members within rate limits
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
inbox_member.id if rate_limiter.within_limits?
end
# Return filtered scope maintaining ActiveRecord relation
inbox_members_scope.where(id: valid_inbox_member_ids)
end
def filter_agents_on_leave(inbox_members_scope)
# Get account users on active leave
account_user_ids_on_leave = account.account_users
.joins(:leaves)
.where(leaves: { status: 'approved' })
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
.pluck(:id)
return inbox_members_scope if account_user_ids_on_leave.empty?
# Exclude inbox members whose account_users are on leave
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
inbox_members_scope.where.not(user_id: user_ids_on_leave)
end
def dispatch_create_event
+67
View File
@@ -0,0 +1,67 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
# unique_inbox_assignment_policy (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
# Callbacks
after_commit :clear_inbox_cache
# Scopes
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
def clear_inbox_cache
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
update_account_cache
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
def show?
@account_user.administrator?
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end
@@ -0,0 +1,112 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless can_assign?(conversation)
agent = find_agent_for_conversation(conversation)
return false unless agent
assign_conversation_to_agent(conversation, agent)
end
def perform_bulk_assignment(limit: 50)
return 0 unless assignment_enabled?
conversations = unassigned_conversations(limit)
assigned_count = 0
conversations.find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def policy
@policy ||= inbox.assignment_policy
end
def assignment_enabled?
policy&.enabled?
end
def can_assign?(conversation)
assignment_enabled? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(check_rate_limits: true)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
def selector_service
@selector_service ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
::Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
else
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
end
end
def unassigned_conversations(limit)
scope = inbox.conversations
.unassigned
.open
# Apply conversation priority ordering
scope = case policy.conversation_priority
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
scope.limit(limit)
end
def assign_conversation_to_agent(conversation, agent)
conversation.update!(assignee: agent)
create_assignment_activity(conversation, agent)
record_assignment_in_rate_limiter(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
false
end
def create_assignment_activity(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def enterprise_enabled?
@enterprise_enabled ||= defined?(Enterprise)
end
def log_no_agents_available
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
end
def record_assignment_in_rate_limiter(conversation, agent)
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
rate_limiter.record_assignment(conversation)
rescue StandardError => e
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
end
end
@@ -0,0 +1,85 @@
# frozen_string_literal: true
# Rate limiter for assignment operations
# Uses Redis to track assignment counts per agent per time window
# based on assignment policy's fair_distribution_limit and fair_distribution_window
class AssignmentV2::RateLimiter
pattr_initialize [:inbox!, :user!]
# Check if the user has exceeded rate limits
# @return [Boolean] true if within limits, false if exceeded
def within_limits?
return true unless policy_exists?
current_count < rate_limit
end
# Record an assignment for rate limiting purposes
# @param conversation [Conversation] The conversation being assigned
def record_assignment(_conversation)
return unless policy_exists?
key = rate_limit_key
redis = Redis.new(Redis::Config.app)
redis.multi do |multi|
multi.incr(key)
multi.expire(key, time_window)
end
end
# Get current rate limit status for the user
# @return [Hash] Rate limit status information
def status
if policy_exists?
{
within_limits: within_limits?,
current_count: current_count,
limit: rate_limit,
reset_at: Time.zone.at(next_window_start)
}
else
{
within_limits: true,
current_count: 0,
limit: Float::INFINITY,
reset_at: nil
}
end
end
private
def policy
@policy ||= inbox.assignment_policy
end
def policy_exists?
policy.present? && policy.enabled?
end
def current_count
key = rate_limit_key
redis = Redis.new(Redis::Config.app)
redis.get(key).to_i
end
def rate_limit
policy&.fair_distribution_limit || 10
end
def time_window
policy&.fair_distribution_window || 3600
end
def rate_limit_key
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
end
def current_window
(Time.current.to_i / time_window) * time_window
end
def next_window_start
current_window + time_window
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class AssignmentV2::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Extract user IDs from inbox members
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
# Use Redis queue for round robin
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
return nil unless selected_user_id
# Return the user object
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
end
def add_agent_to_queue(user_id)
round_robin_service.add_agent_to_queue(user_id)
end
def remove_agent_from_queue(user_id)
round_robin_service.remove_agent_from_queue(user_id)
end
def reset_queue
round_robin_service.reset_queue
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
@@ -106,7 +106,10 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
policy: 'deterministic',
code: template_info[:lang_code]
},
components: template_info[:parameters]
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
}
end
@@ -12,20 +12,15 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def send_template(phone_number, template_info)
template_body = template_body_parameters(template_info)
request_body = {
messaging_product: 'whatsapp',
recipient_type: 'individual', # Only individual messages supported (not group messages)
to: phone_number,
type: 'template',
template: template_body
}
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: request_body.to_json
body: {
messaging_product: 'whatsapp',
to: phone_number,
template: template_body_parameters(template_info),
type: 'template'
}.to_json
)
process_response(response)
@@ -124,36 +119,17 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def template_body_parameters(template_info)
template_body = {
{
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
}
},
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
}
# Enhanced template parameters structure
# Note: Legacy format support (simple parameter arrays) has been removed
# in favor of the enhanced component-based structure that supports
# headers, buttons, and authentication templates.
#
# Expected payload format from frontend:
# {
# processed_params: {
# body: { '1': 'John', '2': '123 Main St' },
# header: { media_url: 'https://...', media_type: 'image' },
# buttons: [{ type: 'url', parameter: 'otp123456' }]
# }
# }
# This gets transformed into WhatsApp API component format:
# [
# { type: 'body', parameters: [...] },
# { type: 'header', parameters: [...] },
# { type: 'button', sub_type: 'url', parameters: [...] }
# ]
template_body[:components] = template_info[:parameters] || []
template_body
end
def whatsapp_reply_context(message)
@@ -1,115 +0,0 @@
# Service to convert legacy WhatsApp template parameter formats to enhanced format
#
# Legacy formats (deprecated):
# - Array: ["John", "Order123"] - positional parameters
# - Flat Hash: {"1": "John", "2": "Order123"} - direct key-value mapping
#
# Enhanced format:
# - Component-based: {"body": {"1": "John", "2": "Order123"}} - structured by template components
# - Supports header, body, footer, and button parameters separately
#
class Whatsapp::TemplateConverterService
def initialize(template_params, template)
@template_params = template_params
@template = template
end
def normalize_to_enhanced
processed_params = @template_params['processed_params']
# Early return if already enhanced format
return @template_params if enhanced_format?(processed_params)
# Convert legacy formats to enhanced structure
# TODO: Legacy format support will be deprecated and removed after 2-3 releases
enhanced_params = convert_legacy_to_enhanced(processed_params, @template)
# Replace original params with enhanced structure
@template_params['processed_params'] = enhanced_params
@template_params['format_version'] = 'enhanced'
@template_params
end
private
def enhanced_format?(processed_params)
return false unless processed_params.is_a?(Hash)
# Enhanced format has component-based structure
component_keys = %w[body header footer buttons]
has_component_structure = processed_params.keys.any? { |k| component_keys.include?(k) }
# Additional validation for enhanced format
if has_component_structure
validate_enhanced_structure(processed_params)
else
false
end
end
def validate_enhanced_structure(params)
valid_body?(params['body']) &&
valid_header?(params['header']) &&
valid_buttons?(params['buttons'])
end
def valid_body?(body)
body.nil? || body.is_a?(Hash)
end
def valid_header?(header)
header.nil? || header.is_a?(Hash)
end
def valid_buttons?(buttons)
return true if buttons.nil?
return false unless buttons.is_a?(Array)
buttons.all? { |b| b.is_a?(Hash) && b['type'] }
end
def convert_legacy_to_enhanced(legacy_params, _template)
# Legacy system only supported text-based templates with body parameters
# We only convert the parameter format, not add new features
enhanced = {}
case legacy_params
when Array
# Array format: ["John", "Order123"] → {body: {"1": "John", "2": "Order123"}}
body_params = convert_array_to_body_params(legacy_params)
enhanced['body'] = body_params unless body_params.empty?
when Hash
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
body_params = convert_hash_to_body_params(legacy_params)
enhanced['body'] = body_params unless body_params.empty?
else
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
end
enhanced
end
def convert_array_to_body_params(params_array)
return {} if params_array.empty?
body_params = {}
params_array.each_with_index do |value, index|
body_params[(index + 1).to_s] = value.to_s
end
body_params
end
def convert_hash_to_body_params(params_hash)
return {} if params_hash.empty?
body_params = {}
params_hash.each do |key, value|
body_params[key.to_s] = value.to_s
end
body_params
end
end
@@ -1,148 +0,0 @@
class Whatsapp::TemplateParameterService
def build_parameter(value)
case value
when String
build_string_parameter(value)
when Hash
build_hash_parameter(value)
else
{ type: 'text', text: value.to_s }
end
end
def build_button_parameter(button)
return { type: 'text', text: '' } if button.blank?
case button['type']
when 'copy_code'
coupon_code = button['parameter'].to_s.strip
raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank?
raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15
{
type: 'coupon_code',
coupon_code: coupon_code
}
else
# For URL buttons and other button types, treat parameter as text
# If parameter is blank, use empty string (required for URL buttons)
{ type: 'text', text: button['parameter'].to_s.strip }
end
end
def build_media_parameter(url, media_type)
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
validate_url(sanitized_url)
build_media_type_parameter(sanitized_url, media_type.downcase)
end
def build_named_parameter(parameter_name, value)
sanitized_value = sanitize_parameter(value.to_s)
{ type: 'text', parameter_name: parameter_name, text: sanitized_value }
end
private
def build_string_parameter(value)
sanitized_value = sanitize_parameter(value)
if rich_formatting?(sanitized_value)
build_rich_text_parameter(sanitized_value)
else
{ type: 'text', text: sanitized_value }
end
end
def build_hash_parameter(value)
case value['type']
when 'currency'
build_currency_parameter(value)
when 'date_time'
build_date_time_parameter(value)
else
{ type: 'text', text: value.to_s }
end
end
def build_currency_parameter(value)
{
type: 'currency',
currency: {
fallback_value: value['fallback_value'],
code: value['code'],
amount_1000: value['amount_1000']
}
}
end
def build_date_time_parameter(value)
{
type: 'date_time',
date_time: {
fallback_value: value['fallback_value'],
day_of_week: value['day_of_week'],
day_of_month: value['day_of_month'],
month: value['month'],
year: value['year']
}
}
end
def build_media_type_parameter(sanitized_url, media_type)
case media_type
when 'image'
build_image_parameter(sanitized_url)
when 'video'
build_video_parameter(sanitized_url)
when 'document'
build_document_parameter(sanitized_url)
else
raise ArgumentError, "Unsupported media type: #{media_type}"
end
end
def build_image_parameter(url)
{ type: 'image', image: { link: url } }
end
def build_video_parameter(url)
{ type: 'video', video: { link: url } }
end
def build_document_parameter(url)
{ type: 'document', document: { link: url } }
end
def rich_formatting?(text)
# Check if text contains WhatsApp rich formatting markers
text.match?(/\*[^*]+\*/) || # Bold: *text*
text.match?(/_[^_]+_/) || # Italic: _text_
text.match?(/~[^~]+~/) || # Strikethrough: ~text~
text.match?(/```[^`]+```/) # Monospace: ```text```
end
def build_rich_text_parameter(text)
# WhatsApp supports rich text formatting in templates
# This preserves the formatting markers for the API
{ type: 'text', text: text }
end
def sanitize_parameter(value)
# Basic sanitization - remove dangerous characters and limit length
sanitized = value.to_s.strip
sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars
sanitized[0...1000] # Limit length to prevent DoS
end
def validate_url(url)
return if url.blank?
uri = URI.parse(url)
raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme)
raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000
rescue URI::InvalidURIError => e
raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf"
end
end
@@ -2,9 +2,11 @@ class Whatsapp::TemplateProcessorService
pattr_initialize [:channel!, :template_params, :message]
def call
return [nil, nil, nil, nil] if template_params.blank?
process_template_with_params
if template_params.present?
process_template_with_params
else
process_template_from_message
end
end
private
@@ -18,6 +20,51 @@ class Whatsapp::TemplateProcessorService
]
end
def process_template_from_message
return [nil, nil, nil, nil] if message.blank?
# Delete the following logic once the update for template_params is stable
# see if we can match the message content to a template
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
# no need to look up further end the search
return [template['name'], template['namespace'], template['language'], processed_parameters]
end
[nil, nil, nil, nil]
end
def template_match_object(template)
body_object = validated_body_object(template)
return if body_object.blank?
template_match_regex = build_template_match_regex(body_object['text'])
message.outgoing_content.match(template_match_regex)
end
def build_template_match_regex(template_text)
# Converts the whatsapp template to a comparable regex string to check against the message content
# the variables are of the format {{num}} ex:{{1}}
# transform the template text into a regex string
# we need to replace the {{num}} with matchers that can be used to capture the variables
template_text = template_text.gsub(/{{\d}}/, '(.*)')
# escape if there are regex characters in the template text
template_text = Regexp.escape(template_text)
# ensuring only the variables remain as capture groups
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
template_match_string = "^#{template_text}$"
Regexp.new template_match_string
end
def find_template
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
@@ -28,100 +75,21 @@ class Whatsapp::TemplateProcessorService
template = find_template
return if template.blank?
# Convert legacy format to enhanced format before processing
converter = Whatsapp::TemplateConverterService.new(template_params, template)
normalized_params = converter.normalize_to_enhanced
parameter_format = template['parameter_format']
process_enhanced_template_params(template, normalized_params['processed_params'])
end
def process_enhanced_template_params(template, processed_params = nil)
processed_params ||= template_params['processed_params']
components = []
components.concat(process_header_components(processed_params))
components.concat(process_body_components(processed_params, template))
components.concat(process_footer_components(processed_params))
components.concat(process_button_components(processed_params))
@template_params = components
end
def process_header_components(processed_params)
return [] if processed_params['header'].blank?
header_params = build_header_params(processed_params['header'])
header_params.present? ? [{ type: 'header', parameters: header_params }] : []
end
def build_header_params(header_data)
header_params = []
header_data.each do |key, value|
next if value.blank?
if media_url_with_type?(key, header_data)
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
header_params << media_param if media_param
elsif key != 'media_type'
header_params << parameter_builder.build_parameter(value)
end
if parameter_format == 'NAMED'
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
else
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
end
header_params
end
def media_url_with_type?(key, header_data)
key == 'media_url' && header_data['media_type'].present?
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
def process_body_components(processed_params, template)
return [] if processed_params['body'].blank?
body_params = processed_params['body'].filter_map do |key, value|
next if value.blank?
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
parameter_builder.build_named_parameter(key, value)
else
parameter_builder.build_parameter(value)
end
end
body_params.present? ? [{ type: 'body', parameters: body_params }] : []
end
def process_footer_components(processed_params)
return [] if processed_params['footer'].blank?
footer_params = processed_params['footer'].filter_map do |_, value|
next if value.blank?
parameter_builder.build_parameter(value)
end
footer_params.present? ? [{ type: 'footer', parameters: footer_params }] : []
end
def process_button_components(processed_params)
return [] if processed_params['buttons'].blank?
button_params = processed_params['buttons'].filter_map.with_index do |button, index|
next if button.blank?
if button['type'] == 'url' || button['parameter'].present?
{
type: 'button',
sub_type: button['type'] || 'url',
index: index,
parameters: [parameter_builder.build_button_parameter(button)]
}
end
end
button_params.compact
end
def parameter_builder
@parameter_builder ||= Whatsapp::TemplateParameterService.new
# we only care about text body object in template. if not present we discard the template
# we don't support other forms of templates
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
end
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
# rubocop:disable Metrics/BlockLength
namespace :assignment_v2 do
desc 'Enable Assignment V2 for an account with default policy'
task :enable, [:account_id] => :environment do |_task, args|
account = Account.find(args[:account_id])
# Create default assignment policy if not exists
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
p.description = 'Default round-robin assignment policy'
p.assignment_order = 'round_robin'
p.conversation_priority = 'earliest_created'
p.enabled = true
end
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
end
desc 'Disable Assignment V2 for an account'
task :disable, [:account_id] => :environment do |_task, args|
account = Account.find(args[:account_id])
# Disable all assignment policies
account.assignment_policies.find_each do |policy|
policy.update!(enabled: false)
end
puts "Assignment V2 disabled for account #{account.name}"
end
desc 'Run assignment for all enabled inboxes'
task run_all: :environment do
count = 0
Inbox.joins(:assignment_policy)
.where(assignment_policies: { enabled: true })
.find_each do |inbox|
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
count += 1
end
puts "Queued assignment jobs for #{count} inboxes"
end
desc 'Show Assignment V2 status'
task status: :environment do
puts 'Assignment V2 Status'
puts '==================='
total_policies = AssignmentPolicy.count
enabled_policies = AssignmentPolicy.enabled.count
puts "Total Assignment Policies: #{total_policies}"
puts "Enabled Policies: #{enabled_policies}"
puts
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
total_inboxes = Inbox.count
puts "Total Inboxes: #{total_inboxes}"
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
end
end
# rubocop:enable Metrics/BlockLength
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
FactoryBot.define do
factory :assignment_policy do
account
sequence(:name) { |n| "Assignment Policy #{n}" }
description { 'Test assignment policy' }
assignment_order { :round_robin }
conversation_priority { :earliest_created }
fair_distribution_limit { 10 }
fair_distribution_window { 3600 }
enabled { true }
trait :balanced do
assignment_order { :balanced }
end
trait :disabled do
enabled { false }
end
trait :longest_waiting do
conversation_priority { :longest_waiting }
end
trait :with_high_limit do
fair_distribution_limit { 50 }
end
trait :with_short_window do
fair_distribution_window { 300 } # 5 minutes
end
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_assignment_policy do
inbox
assignment_policy
# Ensure inbox and policy belong to same account
after(:build) do |inbox_policy|
inbox_policy.assignment_policy.account = inbox_policy.inbox.account if inbox_policy.inbox && inbox_policy.assignment_policy
end
end
end
@@ -0,0 +1,194 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe '#perform' do
context 'with conversation_id' do
it 'assigns a single conversation' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
described_class.new.perform(conversation_id: conversation.id)
end
it 'handles non-existent conversation gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(conversation_id: 999_999)
end.not_to raise_error
end
end
context 'with inbox_id' do
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create_list(:conversation, 3, inbox: inbox, assignee: nil)
create(:inbox_member, inbox: inbox, user: agent)
end
it 'assigns multiple conversations for inbox' do
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).and_return(3)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the number of assigned conversations' do
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(2)
expect(Rails.logger).to receive(:info).with("AssignmentV2::AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when inbox has no policy' do
inbox_assignment_policy.destroy!
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when policy is disabled' do
assignment_policy.update!(enabled: false)
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'handles non-existent inbox gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(inbox_id: 999_999)
end.not_to raise_error
end
end
context 'without parameters' do
it 'logs error when no parameters provided' do
expect(Rails.logger).to receive(:error).with('AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided')
described_class.new.perform
end
it 'does not attempt assignment' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform
end
end
context 'with both parameters' do
it 'prioritizes conversation_id over inbox_id' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
expect(service).not_to receive(:perform_bulk_assignment)
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
end
end
end
describe 'job configuration' do
it 'uses the low queue' do
expect(described_class.new.queue_name).to eq('low')
end
end
describe 'error handling' do
context 'when assignment service raises error' do
it 'propagates the error for retry' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_for_conversation).and_raise(StandardError, 'Assignment failed')
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(StandardError, 'Assignment failed')
end
end
context 'when database connection fails' do
it 'raises error for retry' do
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(ActiveRecord::ConnectionNotEstablished)
end
end
end
describe 'concurrency and idempotency' do
it 'handles concurrent job execution safely' do
# Create multiple jobs for same inbox
jobs = []
3.times { jobs << described_class.new }
# All should execute without issues
expect do
jobs.each { |job| job.perform(inbox_id: inbox.id) }
end.not_to raise_error
end
it 'is idempotent for conversation assignment' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# First call assigns
expect(service).to receive(:perform_for_conversation).and_return(true)
described_class.new.perform(conversation_id: conversation.id)
# Second call should handle already assigned conversation
expect(service).to receive(:perform_for_conversation).and_return(false)
expect { described_class.new.perform(conversation_id: conversation.id) }.not_to raise_error
end
end
describe 'performance considerations' do
it 'processes large inbox assignments in batches' do
# Create many unassigned conversations
create_list(:conversation, 100, inbox: inbox, assignee: nil)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# Service should be called with default limit
expect(service).to receive(:perform_bulk_assignment).with(no_args).and_return(50)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
+127
View File
@@ -0,0 +1,127 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
end
describe 'validations' do
subject { assignment_policy }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
it { is_expected.to validate_presence_of(:fair_distribution_window) }
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
context 'with balanced assignment validation' do
let(:enterprise_account) { create(:account) }
before do
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
end
it 'allows balanced assignment for enterprise accounts' do
policy = build(:assignment_policy, account: enterprise_account, assignment_order: :balanced)
expect(policy).to be_valid
end
it 'rejects balanced assignment for non-enterprise accounts' do
policy = build(:assignment_policy, account: account, assignment_order: :balanced)
expect(policy).not_to be_valid
expect(policy.errors[:assignment_order]).to include('Balanced assignment is only available for enterprise accounts')
end
end
end
describe 'enums' do
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0, balanced: 1) }
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
end
describe 'scopes' do
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
it 'filters enabled policies' do
expect(described_class.enabled).to include(enabled_policy)
expect(described_class.enabled).not_to include(disabled_policy)
end
it 'filters disabled policies' do
expect(described_class.disabled).to include(disabled_policy)
expect(described_class.disabled).not_to include(enabled_policy)
end
end
describe '#can_use_balanced_assignment?' do
context 'when account has enterprise agent capacity feature' do
before do
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
end
it 'returns true' do
expect(assignment_policy.can_use_balanced_assignment?).to be true
end
end
context 'when account does not have enterprise features' do
before do
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(false)
end
it 'returns false' do
expect(assignment_policy.can_use_balanced_assignment?).to be false
end
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = assignment_policy.webhook_data
expect(data).to include(
id: assignment_policy.id,
name: assignment_policy.name,
description: assignment_policy.description,
assignment_order: assignment_policy.assignment_order,
conversation_priority: assignment_policy.conversation_priority,
fair_distribution_limit: assignment_policy.fair_distribution_limit,
fair_distribution_window: assignment_policy.fair_distribution_window,
enabled: assignment_policy.enabled
)
end
end
describe 'cache invalidation' do
let(:inbox) { create(:inbox, account: account) }
before { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
it 'clears assignment caches on update' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
assignment_policy.update!(name: 'Updated Policy')
end
it 'clears assignment caches on destroy' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
assignment_policy.destroy!
end
end
end
+165
View File
@@ -0,0 +1,165 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxAssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe 'associations' do
it { is_expected.to belong_to(:inbox) }
it { is_expected.to belong_to(:assignment_policy) }
end
describe 'validations' do
subject { inbox_assignment_policy }
it { is_expected.to validate_uniqueness_of(:inbox_id) }
context 'with inbox and policy from different accounts' do
let(:other_account) { create(:account) }
let(:other_policy) { create(:assignment_policy, account: other_account) }
it 'validates inbox belongs to same account as policy' do
# Build without the factory callback that sets the accounts to be the same
invalid_policy = described_class.new(inbox: inbox, assignment_policy: other_policy)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
end
end
end
describe 'delegations' do
it 'delegates account to inbox' do
expect(inbox_assignment_policy.account).to eq(account)
end
it 'delegates policy attributes' do
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
end
end
describe 'scopes' do
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
let(:inbox2) { create(:inbox, account: account) }
let!(:enabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: enabled_policy) }
let!(:disabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: disabled_policy) }
describe '.enabled' do
it 'returns only inbox policies with enabled assignment policies' do
expect(described_class.enabled).to include(enabled_inbox_policy)
expect(described_class.enabled).not_to include(disabled_inbox_policy)
end
end
describe '.disabled' do
it 'returns only inbox policies with disabled assignment policies' do
expect(described_class.disabled).to include(disabled_inbox_policy)
expect(described_class.disabled).not_to include(enabled_inbox_policy)
end
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = inbox_assignment_policy.webhook_data
expect(data).to include(
id: inbox_assignment_policy.id,
inbox_id: inbox.id,
assignment_policy_id: assignment_policy.id
)
expect(data[:policy]).to eq(assignment_policy.webhook_data)
end
end
describe 'cache management' do
it 'clears inbox cache on create' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
end
it 'clears inbox cache on update' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
inbox_assignment_policy.update!(updated_at: Time.current)
end
it 'clears inbox cache on destroy' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
inbox_assignment_policy.destroy!
end
it 'updates account cache' do
# AccountCacheRevalidator concern should trigger cache update
expect(inbox_assignment_policy).to receive(:update_account_cache)
inbox_assignment_policy.send(:clear_inbox_cache)
end
end
describe 'business logic constraints' do
it 'prevents multiple policies per inbox' do
# Ensure first policy exists
inbox_assignment_policy
policy2 = create(:assignment_policy, account: account)
# Try to create a second policy for the same inbox
duplicate_policy = described_class.new(inbox: inbox, assignment_policy: policy2)
expect(duplicate_policy).not_to be_valid
expect(duplicate_policy.errors[:inbox_id]).to include('has already been taken')
end
it 'allows reassigning to different policy' do
policy2 = create(:assignment_policy, account: account)
expect do
inbox_assignment_policy.update!(assignment_policy: policy2)
end.not_to raise_error
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
end
end
describe 'edge cases' do
it 'handles nil associations gracefully' do
# Build without saving to test nil handling
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
expect { policy.valid? }.not_to raise_error
expect(policy).not_to be_valid
end
it 'handles policy deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting policy should delete inbox assignment
assignment_policy.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
it 'handles inbox deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting inbox should delete inbox assignment
inbox.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
end
end
@@ -0,0 +1,329 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentService do
before do
# Mock the GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Define the constant if not already defined
stub_const('ASSIGNEE_CHANGED', 'assignee.changed') unless defined?(ASSIGNEE_CHANGED)
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:inbox_member, inbox: inbox, user: agent3)
# Mock available agents to return inbox members
online_members = InboxMember.joins(:user).where(inbox: inbox, user: [agent1, agent2])
allow(inbox).to receive(:available_agents).and_return(online_members)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:service) { described_class.new(inbox: inbox) }
# Create agents
let!(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent3) { create(:user, account: account, role: :agent, availability: :offline) }
# Make agents members of inbox
describe '#perform_for_conversation' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
context 'when policy is enabled' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'assigns conversation to an available agent' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'dispatches assignment event' do
# The dispatcher is called from the assignment service and also from conversation model
allow(Rails.configuration.dispatcher).to receive(:dispatch)
service.perform_for_conversation(conversation)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'assignee.changed',
anything,
hash_including(conversation: conversation, user: agent1)
).at_least(:once)
end
it 'returns false when no agents are available' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
allow(Rails.logger).to receive(:warn)
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when conversation is already assigned' do
before { conversation.update!(assignee: agent1) }
it 'does not reassign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to eq(agent1)
end
end
context 'with round robin assignment' do
before do
assignment_policy.update!(assignment_order: :round_robin)
# Mock round robin selector to return agents in rotation
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
agent_index = 0
allow(selector).to receive(:select_agent) do
agent = [agent1, agent2][agent_index % 2]
agent_index += 1
agent
end
end
it 'assigns agents in rotation' do
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
assignments = conversations.map do |conv|
service.perform_for_conversation(conv)
conv.reload.assignee
end
# Should rotate between available agents
expect(assignments[0]).to eq(agent1)
expect(assignments[1]).to eq(agent2)
expect(assignments[2]).to eq(agent1) # Back to first agent
expect(assignments[3]).to eq(agent2) # Back to second agent
end
end
context 'with balanced assignment' do
before do
# For now, just use round robin since balanced is enterprise only
# The test is verifying the service works, not the specific algorithm
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent2)
end
it 'assigns conversations successfully' do
# Create existing assignments
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2)
end
it 'handles different conversation statuses' do
# Create resolved conversations (should not count)
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
# Create open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2) # Selected by mock
end
end
context 'when error occurs' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'returns false and logs error on assignment failure' do
allow(conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(conversation))
expect(Rails.logger).to receive(:error).with(/Failed to assign conversation/)
expect(service.perform_for_conversation(conversation)).to be false
end
end
end
describe '#perform_bulk_assignment' do
before do
create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to return agents
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
call_count = 0
allow(selector).to receive(:select_agent) do
call_count += 1
call_count.odd? ? agent1 : agent2
end
end
context 'when policy is enabled' do
it 'assigns multiple conversations' do
assigned_count = service.perform_bulk_assignment(limit: 3)
expect(assigned_count).to eq(3)
expect(inbox.conversations.unassigned.count).to eq(2)
end
it 'respects conversation priority order' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different timestamps
old_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago)
new_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.minute.ago)
assignment_policy.update!(conversation_priority: :earliest_created)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).not_to be_nil
expect(new_conversation.reload.assignee).to be_nil
end
it 'handles longest_waiting priority' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different last activity
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 2.hours.ago)
active_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 5.minutes.ago)
assignment_policy.update!(conversation_priority: :longest_waiting)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(inactive_conversation.reload.assignee).not_to be_nil
expect(active_conversation.reload.assignee).to be_nil
end
it 'returns 0 when no conversations to assign' do
Conversation.find_each { |c| c.update!(assignee_id: agent1.id) }
expect(service.perform_bulk_assignment).to eq(0)
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign any conversations' do
expect(service.perform_bulk_assignment).to eq(0)
expect(inbox.conversations.unassigned.count).to eq(5)
end
end
end
describe 'enterprise capacity features' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Mock enterprise availability
stub_const('Enterprise', Module.new)
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
# Mock the selector to return agent1
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'uses round robin when enterprise features are available' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'handles absence of enterprise features gracefully' do
# Remove enterprise constant
hide_const('Enterprise')
# Service should still work with round robin
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
end
describe 'cache management' do
it 'uses cache for round robin state' do
assignment_policy.update!(assignment_order: :round_robin)
# Mock the selector and round robin service
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
# Create and assign conversations
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation1)
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation2)
# Just verify assignments worked
expect(conversation1.reload.assignee).to eq(agent1)
expect(conversation2.reload.assignee).to eq(agent1)
end
end
describe 'edge cases' do
it 'handles inbox without policy gracefully' do
inbox_assignment_policy.destroy!
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'handles empty agent list' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'filters out agents without inbox membership' do
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
conversation = create(:conversation, inbox: inbox, assignee: nil)
# Mock selector to return agent1 (who is a member)
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).not_to eq(non_member_agent)
expect(conversation.reload.assignee).to eq(agent1)
end
end
end
@@ -0,0 +1,293 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RateLimiter, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
redis = Redis.new(Redis::Config.app)
redis.flushdb if Rails.env.test?
# Ensure inbox_assignment_policy exists so the inbox has a policy
inbox_assignment_policy
end
let(:account) { create(:account) }
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
let(:agent) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy) }
let(:rate_limiter) { described_class.new(inbox: inbox, user: agent) }
describe '#initialize' do
it 'sets up rate limiter with inbox and user' do
expect(rate_limiter.instance_variable_get(:@inbox)).to eq(inbox)
expect(rate_limiter.instance_variable_get(:@user)).to eq(agent)
end
end
describe '#within_limits?' do
context 'when agent has no assignments in current window' do
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent is below limit' do
before do
# Simulate 3 assignments in current window
3.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent reaches limit' do
before do
# Simulate reaching the limit (5 assignments)
5.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when agent exceeds limit' do
before do
# Simulate exceeding the limit
6.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
end
describe '#record_assignment' do
let(:conversation) { create(:conversation, inbox: inbox) }
it 'increments assignment count for agent' do
initial_status = rate_limiter.status
rate_limiter.record_assignment(conversation)
new_status = rate_limiter.status
expect(new_status[:current_count]).to eq(initial_status[:current_count] + 1)
end
it 'sets expiration on the key' do
rate_limiter.record_assignment(conversation)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
redis = Redis.new(Redis::Config.app)
ttl = redis.ttl(key)
expect(ttl).to be > 0
expect(ttl).to be <= 3600
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:multi).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
expect { rate_limiter.record_assignment(conversation) }.to raise_error(Redis::ConnectionError)
end
end
end
describe '#status' do
it 'returns correct status for agent with no assignments' do
status = rate_limiter.status
expect(status[:current_count]).to eq(0)
expect(status[:within_limits]).to be true
expect(status[:limit]).to eq(5)
end
it 'returns correct count after assignments' do
3.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:get).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
expect { rate_limiter.status }.to raise_error(Redis::ConnectionError)
end
end
end
describe 'remaining assignments' do
it 'returns full limit when no assignments made' do
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(5)
end
it 'returns correct remaining count' do
2.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(3)
end
it 'returns 0 when limit reached' do
5.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(0)
end
it 'returns negative when limit exceeded' do
6.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(-1)
end
end
describe 'assignment capacity checks' do
it 'returns true when agent has remaining capacity' do
2.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.within_limits?).to be true
end
it 'returns false when agent has no capacity' do
5.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.within_limits?).to be false
end
it 'correctly tracks multiple assignments' do
3.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
expect(status[:limit] - status[:current_count]).to eq(2)
end
end
describe 'multiple agents' do
let(:agent2) { create(:user, account: account) }
let(:rate_limiter2) { described_class.new(inbox: inbox, user: agent2) }
before do
2.times { rate_limiter.record_assignment(create(:conversation)) }
4.times { rate_limiter2.record_assignment(create(:conversation)) }
end
it 'tracks status independently for each agent' do
status1 = rate_limiter.status
status2 = rate_limiter2.status
expect(status1[:current_count]).to eq(2)
expect(status1[:within_limits]).to be true
expect(status1[:limit] - status1[:current_count]).to eq(3)
expect(status2[:current_count]).to eq(4)
expect(status2[:within_limits]).to be true
expect(status2[:limit] - status2[:current_count]).to eq(1)
end
end
describe 'reset functionality' do
before do
3.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'can be reset by clearing Redis key' do
status_before = rate_limiter.status
expect(status_before[:current_count]).to eq(3)
# Manually clear the key
redis = Redis.new(Redis::Config.app)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
redis.del(key)
status_after = rate_limiter.status
expect(status_after[:current_count]).to eq(0)
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:del).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
redis = Redis.new(Redis::Config.app)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
expect { redis.del(key) }.to raise_error(Redis::ConnectionError)
end
end
end
describe 'window timing' do
it 'calculates reset time correctly' do
# Mock current time to make test predictable
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
status = rate_limiter.status
reset_time = status[:reset_at]
expect(reset_time).to be_a(Time)
expect(reset_time).to be > Time.current
expect(reset_time - Time.current).to be <= 3600
end
end
end
describe 'window boundaries' do
it 'resets count in new window' do
# Set up assignment in current window
2.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.status[:current_count]).to eq(2)
# Travel to next window (advance by window size)
travel(3601.seconds) do
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
end
describe 'concurrent access' do
it 'handles concurrent increments correctly' do
threads = []
results = []
mutex = Mutex.new
# Simulate concurrent assignment requests
5.times do
threads << Thread.new do
within_limits = rate_limiter.within_limits?
mutex.synchronize { results << within_limits }
rate_limiter.record_assignment(create(:conversation)) if within_limits
end
end
threads.each(&:join)
# Final count should not exceed the limit
final_count = rate_limiter.status[:current_count]
expect(final_count).to be <= 5
expect(results.count(true)).to eq(final_count)
end
end
end
@@ -0,0 +1,145 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
create(:inbox_member, inbox: inbox, user: user1)
create(:inbox_member, inbox: inbox, user: user2)
create(:inbox_member, inbox: inbox, user: user3)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:policy) { create(:assignment_policy, account: account) }
let(:user1) { create(:user, account: account, availability: :online) }
let(:user2) { create(:user, account: account, availability: :online) }
let(:user3) { create(:user, account: account, availability: :offline) }
describe '#select_agent' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
context 'when Redis is available' do
before do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user1.id.to_s)
end
it 'returns an online agent' do
result = selector.select_agent(available_agents)
expect(result).to eq(user1)
end
it 'excludes offline agents' do
result = selector.select_agent(available_agents)
expect(result).not_to eq(user3)
end
it 'handles no available agent gracefully' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(nil)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
context 'when Redis fails' do
before do
allow(round_robin_service).to receive(:available_agent).and_raise(Redis::CannotConnectError)
end
it 'raises the error' do
expect { selector.select_agent(available_agents) }.to raise_error(Redis::CannotConnectError)
end
end
context 'with empty available agents' do
it 'returns nil when no agents are available' do
result = selector.select_agent(InboxMember.none)
expect(result).to be_nil
end
end
context 'with different user IDs' do
it 'correctly finds the inbox member by user_id' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user2.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to eq(user2)
end
end
end
describe '#add_agent_to_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:add_agent_to_queue).with(user1.id)
selector.add_agent_to_queue(user1.id)
end
end
describe '#remove_agent_from_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:remove_agent_from_queue).with(user1.id)
selector.remove_agent_from_queue(user1.id)
end
end
describe '#reset_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:reset_queue)
selector.reset_queue
end
end
describe 'edge cases' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'handles invalid user_id from round robin service' do
allow(round_robin_service).to receive(:available_agent).and_return('invalid_id')
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
it 'handles user_id not in available agents' do
other_user = create(:user, account: account)
allow(round_robin_service).to receive(:available_agent).and_return(other_user.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
end
@@ -19,7 +19,7 @@ describe Whatsapp::OneoffCampaignService do
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => { 'body' => { 'name' => 'John', 'ticket_id' => '2332' } }
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
}
end
@@ -125,13 +125,8 @@ describe Whatsapp::OneoffCampaignService do
namespace: '23423423_2342423_324234234_2343224',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'body',
parameters: array_including(
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
)
)
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
)
)
)
@@ -165,17 +165,19 @@ describe Whatsapp::Providers::WhatsappCloudService do
let(:template_body) do
{
messaging_product: 'whatsapp',
recipient_type: 'individual', # Added recipient_type field
to: '+123456789',
type: 'template',
template: {
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: template_info[:parameters] # Changed to use parameters directly (enhanced format)
}
components: [
{ type: 'body',
parameters: template_info[:parameters] }
]
},
type: 'template'
}
end
@@ -6,7 +6,7 @@ describe Whatsapp::SendOnWhatsappService do
namespace: '23423423_2342423_324234234_2343224',
language: 'en_US',
category: 'Marketing',
processed_params: { 'body' => { '1' => '3' } }
processed_params: { '1' => '3' }
}
describe '#perform' do
@@ -39,16 +39,15 @@ describe Whatsapp::SendOnWhatsappService do
let(:named_template_body) do
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: '123456789',
type: 'template',
template: {
name: 'ticket_status_updated',
language: { 'policy': 'deterministic', 'code': 'en_US' },
components: [{ 'type': 'body',
'parameters': [{ 'type': 'text', parameter_name: 'last_name', 'text': 'Dale' },
{ 'type': 'text', parameter_name: 'ticket_id', 'text': '2332' }] }]
}
},
type: 'template'
}
end
@@ -74,7 +73,7 @@ describe Whatsapp::SendOnWhatsappService do
it 'calls channel.send_template when after 24 hour limit' do
message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.',
conversation: conversation, additional_attributes: { template_params: template_params })
conversation: conversation)
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
@@ -108,18 +107,12 @@ describe Whatsapp::SendOnWhatsappService do
name: 'ticket_status_updated',
language: 'en_US',
category: 'UTILITY',
processed_params: { 'body' => { 'last_name' => 'Dale', 'ticket_id' => '2332' } }
processed_params: { 'last_name' => 'Dale', 'ticket_id' => '2332' }
}
stub_request(:post, "https://graph.facebook.com/v13.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages")
.with(
:headers => {
'Accept' => '*/*',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}",
'User-Agent' => 'Ruby'
},
:headers => { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}" },
:body => named_template_body.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
message = create(:message,
@@ -131,225 +124,30 @@ describe Whatsapp::SendOnWhatsappService do
end
it 'calls channel.send_template when template has regexp characters' do
regexp_template_params = build_template_params('customer_yes_no', '2342384942_32423423_23423fdsdaf23', 'ar', {})
arabic_content = 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.'
message = create_message_with_template(arabic_content, regexp_template_params)
stub_template_request(regexp_template_params, [])
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
it 'handles template with header parameters' do
processed_params = {
'body' => { '1' => '3' },
'header' => { 'media_url' => 'https://example.com/image.jpg', 'media_type' => 'image' }
}
header_template_params = build_sample_template_params(processed_params)
message = create_message_with_template('', header_template_params)
components = [
{ type: 'header', parameters: [{ type: 'image', image: { link: 'https://example.com/image.jpg' } }] },
{ type: 'body', parameters: [{ type: 'text', text: '3' }] }
]
stub_sample_template_request(components)
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
it 'handles empty processed_params gracefully' do
empty_template_params = {
name: 'sample_shipping_confirmation',
namespace: '23423423_2342423_324234234_2343224',
language: 'en_US',
category: 'SHIPPING_UPDATE',
processed_params: {}
}
message = create(:message, additional_attributes: { template_params: empty_template_params },
conversation: conversation, message_type: :outgoing)
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
headers: headers,
body: {
to: '123456789',
template: {
name: 'sample_shipping_confirmation',
namespace: '23423423_2342423_324234234_2343224',
language: { 'policy': 'deterministic', 'code': 'en_US' },
components: []
},
type: 'template'
}.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
it 'handles template with button parameters' do
processed_params = {
'body' => { '1' => '3' },
'buttons' => [{ 'type' => 'url', 'parameter' => 'https://track.example.com/123' }]
}
button_template_params = build_sample_template_params(processed_params)
message = create_message_with_template('', button_template_params)
components = [
{ type: 'body', parameters: [{ type: 'text', text: '3' }] },
{ type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'https://track.example.com/123' }] }
]
stub_sample_template_request(components)
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
it 'processes template parameters correctly via integration' do
processed_params = {
'body' => { '1' => '5' },
'footer' => { 'text' => 'Thank you' }
}
complex_template_params = build_sample_template_params(processed_params)
message = create_message_with_template('', complex_template_params)
components = [
{ type: 'body', parameters: [{ type: 'text', text: '5' }] },
{ type: 'footer', parameters: [{ type: 'text', text: 'Thank you' }] }
]
stub_sample_template_request(components)
expect { described_class.new(message: message).perform }.not_to raise_error
expect(message.reload.source_id).to eq('123456789')
end
it 'handles edge case with missing template gracefully' do
# Test the service behavior when template is not found
missing_template_params = {
'name' => 'non_existent_template',
'namespace' => 'missing_namespace',
'language' => 'en_US',
'category' => 'UTILITY',
'processed_params' => { 'body' => { '1' => 'test' } }
}
service = Whatsapp::TemplateProcessorService.new(
channel: whatsapp_channel,
template_params: missing_template_params
message = create(
:message,
message_type: :outgoing,
content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.',
conversation: conversation
)
expect { service.call }.not_to raise_error
name, namespace, language, processed_params = service.call
expect(name).to eq('non_existent_template')
expect(namespace).to eq('missing_namespace')
expect(language).to eq('en_US')
expect(processed_params).to be_nil
end
it 'handles template with blank parameter values correctly' do
processed_params = {
'body' => { '1' => '', '2' => 'valid_value', '3' => nil },
'header' => { 'media_url' => '', 'media_type' => 'image' }
}
blank_values_template_params = build_sample_template_params(processed_params)
message = create_message_with_template('', blank_values_template_params)
components = [{ type: 'body', parameters: [{ type: 'text', text: 'valid_value' }] }]
stub_sample_template_request(components)
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
it 'handles nil template_params gracefully' do
# Test service behavior when template_params is completely nil
message = create(:message, additional_attributes: {},
conversation: conversation, message_type: :outgoing)
# Should send regular message, not template
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
headers: headers,
body: {
to: '123456789',
text: { body: message.content },
type: 'text'
}.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
expect { described_class.new(message: message).perform }.not_to raise_error
end
it 'processes template with rich text formatting' do
processed_params = { 'body' => { '1' => '*Bold text* and _italic text_' } }
rich_text_template_params = build_sample_template_params(processed_params)
message = create_message_with_template('', rich_text_template_params)
components = [{ type: 'body', parameters: [{ type: 'text', text: '*Bold text* and _italic text_' }] }]
stub_sample_template_request(components)
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
private
def build_template_params(name, namespace, language, processed_params)
{
name: name,
namespace: namespace,
language: language,
category: 'SHIPPING_UPDATE',
processed_params: processed_params
}
end
def create_message_with_template(content, template_params)
create(:message,
message_type: :outgoing,
content: content,
conversation: conversation,
additional_attributes: { template_params: template_params })
end
def stub_template_request(template_params, components)
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
headers: headers,
body: {
to: '123456789',
template: {
name: template_params[:name],
namespace: template_params[:namespace],
language: { 'policy': 'deterministic', 'code': template_params[:language] },
components: components
name: 'customer_yes_no',
namespace: '2342384942_32423423_23423fdsdaf23',
language: { 'policy': 'deterministic', 'code': 'ar' },
components: [{ 'type': 'body', 'parameters': [] }]
},
type: 'template'
}.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
end
def build_sample_template_params(processed_params)
build_template_params('sample_shipping_confirmation', '23423423_2342423_324234234_2343224', 'en_US', processed_params)
end
def stub_sample_template_request(components)
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
headers: headers,
body: {
to: '123456789',
template: {
name: 'sample_shipping_confirmation',
namespace: '23423423_2342423_324234234_2343224',
language: { 'policy': 'deterministic', 'code': 'en_US' },
components: components
},
type: 'template'
}.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
end
end
@@ -1,199 +0,0 @@
require 'rails_helper'
describe Whatsapp::TemplateConverterService do
let(:template) do
{
'name' => 'test_template',
'language' => 'en',
'components' => [
{
'type' => 'BODY',
'text' => 'Hello {{1}}, your order {{2}} is ready!'
}
]
}
end
let(:media_template) do
{
'name' => 'media_template',
'language' => 'en',
'components' => [
{
'type' => 'HEADER',
'format' => 'IMAGE'
},
{
'type' => 'BODY',
'text' => 'Check out {{1}}!'
}
]
}
end
let(:button_template) do
{
'name' => 'button_template',
'language' => 'en',
'components' => [
{
'type' => 'BODY',
'text' => 'Visit our website!'
},
{
'type' => 'BUTTONS',
'buttons' => [
{
'type' => 'URL',
'url' => 'https://example.com/{{1}}'
},
{
'type' => 'COPY_CODE'
}
]
}
]
}
end
describe '#normalize_to_enhanced' do
context 'when already enhanced format' do
let(:enhanced_params) do
{
'processed_params' => {
'body' => { '1' => 'John', '2' => 'Order123' }
}
}
end
it 'returns unchanged' do
converter = described_class.new(enhanced_params, template)
result = converter.normalize_to_enhanced
expect(result).to eq(enhanced_params)
end
end
context 'when legacy array format' do
let(:legacy_array_params) do
{
'processed_params' => %w[John Order123]
}
end
it 'converts to enhanced format' do
converter = described_class.new(legacy_array_params, template)
result = converter.normalize_to_enhanced
expect(result['processed_params']).to eq({
'body' => { '1' => 'John', '2' => 'Order123' }
})
expect(result['format_version']).to eq('enhanced')
end
end
context 'when legacy flat hash format' do
let(:legacy_hash_params) do
{
'processed_params' => { '1' => 'John', '2' => 'Order123' }
}
end
it 'converts to enhanced format' do
converter = described_class.new(legacy_hash_params, template)
result = converter.normalize_to_enhanced
expect(result['processed_params']).to eq({
'body' => { '1' => 'John', '2' => 'Order123' }
})
expect(result['format_version']).to eq('enhanced')
end
end
context 'when legacy hash with all body parameters' do
let(:legacy_hash_params) do
{
'processed_params' => {
'1' => 'Product',
'customer_name' => 'John'
}
}
end
it 'converts to enhanced format with body only' do
converter = described_class.new(legacy_hash_params, media_template)
result = converter.normalize_to_enhanced
expect(result['processed_params']).to eq({
'body' => {
'1' => 'Product',
'customer_name' => 'John'
}
})
expect(result['format_version']).to eq('enhanced')
end
end
context 'when invalid format' do
let(:invalid_params) do
{
'processed_params' => 'invalid_string'
}
end
it 'raises ArgumentError' do
expect do
converter = described_class.new(invalid_params, template)
converter.normalize_to_enhanced
end.to raise_error(ArgumentError, /Unknown legacy format/)
end
end
end
describe '#enhanced_format?' do
it 'returns true for valid enhanced format' do
enhanced = { 'body' => { '1' => 'test' } }
converter = described_class.new({}, template)
expect(converter.send(:enhanced_format?, enhanced)).to be true
end
it 'returns false for array' do
converter = described_class.new({}, template)
expect(converter.send(:enhanced_format?, ['test'])).to be false
end
it 'returns false for flat hash' do
converter = described_class.new({}, template)
expect(converter.send(:enhanced_format?, { '1' => 'test' })).to be false
end
it 'returns false for invalid structure' do
invalid = { 'body' => 'not_a_hash' }
converter = described_class.new({}, template)
expect(converter.send(:enhanced_format?, invalid)).to be false
end
end
describe 'simplified conversion methods' do
describe '#convert_array_to_body_params' do
it 'converts empty array' do
converter = described_class.new({}, template)
result = converter.send(:convert_array_to_body_params, [])
expect(result).to eq({})
end
it 'converts array to numbered body parameters' do
converter = described_class.new({}, template)
result = converter.send(:convert_array_to_body_params, %w[John Order123])
expect(result).to eq({ '1' => 'John', '2' => 'Order123' })
end
end
describe '#convert_hash_to_body_params' do
it 'converts hash to body parameters' do
converter = described_class.new({}, template)
result = converter.send(:convert_hash_to_body_params, { 'name' => 'John', 'order' => '123' })
expect(result).to eq({ 'name' => 'John', 'order' => '123' })
end
end
end
end