Compare commits

...
Author SHA1 Message Date
Muhsin Keloth a37fb7cbf4 chore: revert the changes 2025-08-06 16:38:04 +05:30
Muhsin Keloth 515356d143 feat: template v2 2025-08-06 16:31:55 +05:30
Sivin VargheseandGitHub 585f7d5b54 Merge branch 'develop' into feature/enhanced-template-support 2025-08-06 13:14:28 +05:30
Muhsin Keloth 0495316fd1 feat: add the support for legacy wa templates 2025-08-05 15:40:59 +05:30
Muhsin KelothandGitHub 7d8c3ca65e Merge branch 'develop' into feature/enhanced-template-support 2025-08-05 13:56:05 +05:30
Muhsin KelothandGitHub fdc9e133cd Merge branch 'develop' into feature/enhanced-template-support 2025-08-05 13:17:14 +05:30
Muhsin KelothandGitHub ecf01e9004 Merge branch 'develop' into feature/enhanced-template-support 2025-08-05 13:13:07 +05:30
6c64771fb3 Update app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 13:05:43 +05:30
Muhsin Keloth 17018af4ff Merge branch 'feature/enhanced-template-support' of https://github.com/chatwoot/chatwoot into feature/enhanced-template-support 2025-08-05 13:04:58 +05:30
Muhsin KelothandCopilot fa2d80535f Update app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 13:04:52 +05:30
c7ba57e80c Update app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 09:57:42 +05:30
Muhsin KelothandGitHub 1d53362471 Merge branch 'develop' into feature/enhanced-template-support 2025-08-05 09:08:01 +05:30
Muhsin Keloth 5acae27990 chore: remove auth template related codes 2025-08-04 14:11:03 +05:30
Muhsin KelothandGitHub c3f5fbe3db Merge branch 'develop' into feature/enhanced-template-support 2025-08-04 12:36:34 +04:00
Muhsin Keloth 79e28ae922 chore: add template parameter service 2025-07-31 18:47:51 +04:00
Muhsin Keloth 77aa9b8f74 chore: add more comprehensive specs 2025-07-31 18:27:57 +04:00
Muhsin Keloth 84f53f5dd9 chore: fix specs 2025-07-31 14:44:34 +04:00
Muhsin Keloth 609437ee92 chore: remove authentication related templates 2025-07-31 14:15:08 +04:00
Muhsin Keloth 35f5371e68 chore: fix whatsapp cloud service spec 2025-07-31 13:13:26 +04:00
Muhsin Keloth e8fd7e71d4 chore: add note about legacy format 2025-07-31 13:07:56 +04:00
Muhsin Keloth c8927f1e81 chore: simplify fetch_whatsapp_templates logic 2025-07-31 13:01:33 +04:00
Muhsin Keloth 9588253206 chore: remove legacy format support 2025-07-31 12:58:45 +04:00
Muhsin Keloth 4a419be73a Merge branch 'feature/enhanced-template-support' of https://github.com/chatwoot/chatwoot into feature/enhanced-template-support 2025-07-31 12:55:17 +04:00
Muhsin Keloth 356946a413 chore: add more specs 2025-07-31 12:55:09 +04:00
Muhsin KelothandGitHub c7af608510 Merge branch 'develop' into feature/enhanced-template-support 2025-07-31 12:00:20 +04:00
Muhsin Keloth 6d86c5739c Update template_processor_service.rb 2025-07-30 16:56:26 +04:00
Muhsin Keloth 1e2ce070aa chore: add getFilteredWhatsAppTemplates getter 2025-07-30 16:48:08 +04:00
Muhsin Keloth f49c31f7bc chore: update whatsapp campaign 2025-07-30 16:28:37 +04:00
Muhsin Keloth a3e0d84b15 chore: add WhatsAppTemplateParserCore 2025-07-30 16:13:15 +04:00
Muhsin Keloth c2b03d68f3 chore: more front end changes 2025-07-30 13:39:48 +04:00
Muhsin Keloth 533ae33199 chore: style fixes 2025-07-30 13:25:57 +04:00
Muhsin Keloth 54453581ed chore: remove buttons for otp types 2025-07-30 13:20:47 +04:00
Muhsin Keloth f5f729f23a chore: ui updates 2025-07-30 13:12:23 +04:00
Muhsin KelothandGitHub a3120add12 Merge branch 'develop' into feature/enhanced-template-support 2025-07-30 11:03:02 +04:00
Muhsin KelothandGitHub b49ab0e95e Merge branch 'develop' into feature/enhanced-template-support 2025-07-29 18:56:14 +04:00
Muhsin Keloth 74976f9d07 chore: remove unused files 2025-07-29 18:53:39 +04:00
Muhsin Keloth f25050e4cd chore: add guide 2025-07-29 16:21:02 +04:00
Muhsin Keloth 9e5ee2da51 Update templates.json 2025-07-29 16:17:27 +04:00
Muhsin Keloth e2702fd205 Update templates.json 2025-07-29 16:04:30 +04:00
Muhsin Keloth a7ea895508 Update templates.json 2025-07-29 15:49:33 +04:00
Muhsin Keloth a883904986 chore: add templates example 2025-07-29 14:30:46 +04:00
Muhsin KelothandGitHub 5fad897d8e Merge branch 'develop' into feature/enhanced-template-support 2025-07-29 12:50:09 +04:00
Muhsin Keloth d67b0114d0 chore: test all the cases 2025-07-23 16:55:04 +04:00
Muhsin Keloth 06c5633a28 chore: fix translations 2025-07-23 16:24:59 +04:00
Muhsin Keloth ec5d30745b Update inboxes.js 2025-07-23 15:37:03 +04:00
Muhsin KelothandGitHub a8e581d057 Merge branch 'develop' into feature/enhanced-template-support 2025-07-23 14:31:06 +04:00
Muhsin Keloth 684cb005c3 chore: add support for doc 2025-07-22 19:37:29 +04:00
Muhsin Keloth cbde1d9d83 Update WhatsappTemplateParser.vue 2025-07-22 19:01:05 +04:00
Muhsin Keloth cfda6696b6 chore: fix copy otp 2025-07-22 18:53:35 +04:00
Muhsin Keloth 0b816f2a01 chore: minor fixes 2025-07-22 18:15:54 +04:00
Muhsin Keloth a0e2936ab3 chore: remove location templates 2025-07-22 17:34:12 +04:00
Muhsin Keloth 2357f6ae47 chore: cleanup codes 2025-07-22 17:16:47 +04:00
Muhsin Keloth 3c99197e1e chore: cleanup code 2025-07-22 16:57:13 +04:00
Muhsin KelothandGitHub f93ed667c0 Merge branch 'develop' into feature/enhanced-template-support 2025-07-22 15:28:06 +04:00
Muhsin KelothandGitHub 01f85a7882 Merge branch 'develop' into feature/enhanced-template-support 2025-07-21 21:28:00 +04:00
Muhsin Keloth 4610cff67e chore: add more templates 2025-07-21 21:25:44 +04:00
Sojan JoseandClaude 44d29fa708 feat: Add media URL input support for WhatsApp templates
Add support for media URL input fields in WhatsApp templates with IMAGE, VIDEO,
or DOCUMENT headers, enabling users to provide media links for template messages.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 17:49:08 +04:00
Sojan JoseandClaude 9329813ff5 fix: Remove media template filtering from Vuex store getter
The root cause of media header templates not appearing was in the Vuex store
getter `getWhatsAppTemplates` which was explicitly filtering out templates
with IMAGE and VIDEO components.

This fix removes the media filtering logic to allow all approved WhatsApp
templates to be available in the template selector, including those with:
- IMAGE headers
- VIDEO headers
- DOCUMENT headers

Now the `order_confirmation` template and other media templates will appear
in the template picker as expected.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 17:40:59 +04:00
Sojan JoseandClaude 16011b06a3 fix: Ensure media header templates appear in template selector
- Remove format-based filtering that was preventing media templates from showing
- Add robust error handling for template loading without console statements
- Include comprehensive validation for template properties
- Add debug helper method to troubleshoot template visibility issues

Previously, templates with media headers (IMAGE, VIDEO, DOCUMENT) were being
filtered out even though we removed format restrictions. This fix ensures
all approved templates are visible in the template selector regardless of
their media format.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 17:36:28 +04:00
Sojan JoseandClaude ee176480da feat: Enhanced WhatsApp template support with security optimizations
- Add comprehensive WhatsApp template parameter support for header/footer/buttons
- Implement coupon code template support with 15-character validation
- Support all WhatsApp template types (text, media, interactive)
- Add advanced parameter types (currency, date/time, location)
- Remove restrictions on DOCUMENT, IMAGE, VIDEO template formats

Security improvements:
- Fix XSS vulnerability by replacing v-html with v-dompurify-html
- Add input sanitization and parameter validation
- Implement URL validation with scheme and length checks
- Add protection against DoS attacks with length limits

Performance optimizations:
- Replace recursive template fetching with iterative pagination
- Add safety limits to prevent infinite loops and stack overflow
- Optimize template processing for better memory usage

Frontend enhancements:
- Enhanced template picker with header/footer/button display
- Component-based parameter input organized by type
- Real-time validation for coupon codes and parameters
- Improved template preview with sanitized HTML rendering
- Fix Vue linting issues with proper computed properties

Maintains full backward compatibility with existing template configurations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 17:03:11 +04:00
24 changed files with 2345 additions and 645 deletions
@@ -9,6 +9,7 @@ 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']);
@@ -18,7 +19,9 @@ const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
@@ -30,7 +33,7 @@ const initialState = {
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const templateParserRef = ref(null);
const rules = {
title: { required, minLength: minLength(1) },
@@ -67,7 +70,7 @@ const inboxOptions = computed(() =>
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
@@ -88,26 +91,6 @@ 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`) : '';
@@ -122,8 +105,7 @@ const formErrors = computed(() => ({
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
return templateParserRef.value?.v$?.$invalid === false || true;
});
const isSubmitDisabled = computed(
@@ -135,32 +117,18 @@ 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 = templateString.value;
const templateContent = parserData?.renderedTemplate || '';
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
@@ -168,7 +136,7 @@ const prepareCampaignDetails = () => {
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
processed_params: parserData?.processedParams || {},
};
return {
@@ -198,15 +166,6 @@ watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
@@ -254,62 +213,12 @@ watch(
</p>
</div>
<!-- Template Preview -->
<div
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="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>
ref="templateParserRef"
:template="selectedTemplate"
/>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
@@ -25,6 +25,7 @@ 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([
@@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -336,6 +336,7 @@ 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,23 +1,24 @@
<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({
messageTemplates: {
type: Array,
default: () => [],
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -25,19 +26,7 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// 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);
});
});
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
const filteredTemplates = computed(() => {
@@ -87,7 +76,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 i-lucide-search size-3.5 top-2 left-3" />
<span class="absolute top-2 left-3 i-lucide-search size-3.5" />
<input
v-model="searchQuery"
type="search"
@@ -96,13 +85,13 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
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"
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"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
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"
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"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -111,7 +100,7 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
@@ -1,16 +1,12 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
defineProps({
template: {
type: Object,
required: true,
default: () => ({}),
},
});
@@ -18,159 +14,50 @@ const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
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,
},
};
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
const handleBack = () => {
emit('back');
};
</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"
>
<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"
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
{{ 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"
/>
<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>
</div>
</div>
</template>
@@ -0,0 +1,247 @@
<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,4 +1,4 @@
<script>
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
@@ -8,158 +8,50 @@
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
defineProps({
template: {
type: Object,
default: () => ({}),
},
props: {
template: {
type: Object,
default: () => ({}),
},
},
emits: ['sendMessage', 'resetTemplate'],
setup(props, { emit }) {
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
});
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const processedParams = ref({});
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
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,
};
},
const handleResetTemplate = () => {
emit('resetTemplate');
};
</script>
<template>
<div class="w-full">
<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>
<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>
</div>
</template>
@@ -1,8 +1,6 @@
<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: {
@@ -23,14 +21,9 @@ export default {
},
computed: {
whatsAppTemplateMessages() {
// 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);
});
});
return this.$store.getters['inboxes/getFilteredWhatsAppTemplates'](
this.inboxId
);
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
@@ -39,9 +32,31 @@ export default {
},
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
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')
);
},
async refreshTemplates() {
this.isRefreshing = true;
@@ -101,21 +116,72 @@ 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>
<div>
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
<!-- 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>
<p class="label-body">{{ getTemplatebody(template) }}</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>
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p>{{ template.category }}</p>
<p class="text-sm 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' }}
</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>
</div>
</div>
</button>
@@ -0,0 +1,368 @@
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
});
});
});
@@ -0,0 +1,82 @@
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,29 +1,45 @@
{
"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"
}
"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"
}
}
}
@@ -44,15 +44,50 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
// 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 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 [];
}
return [];
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;
});
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -1,5 +1,6 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -93,4 +94,269 @@ 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,4 +260,285 @@ 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',
},
];
@@ -1,61 +0,0 @@
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);
});
});
@@ -106,10 +106,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
components: template_info[:parameters]
}
end
@@ -12,15 +12,20 @@ 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: {
messaging_product: 'whatsapp',
to: phone_number,
template: template_body_parameters(template_info),
type: 'template'
}.to_json
body: request_body.to_json
)
process_response(response)
@@ -119,17 +124,36 @@ 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)
@@ -0,0 +1,115 @@
# 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
@@ -0,0 +1,148 @@
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,11 +2,9 @@ class Whatsapp::TemplateProcessorService
pattr_initialize [:channel!, :template_params, :message]
def call
if template_params.present?
process_template_with_params
else
process_template_from_message
end
return [nil, nil, nil, nil] if template_params.blank?
process_template_with_params
end
private
@@ -20,51 +18,6 @@ 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'
@@ -75,21 +28,100 @@ class Whatsapp::TemplateProcessorService
template = find_template
return if template.blank?
parameter_format = template['parameter_format']
# Convert legacy format to enhanced format before processing
converter = Whatsapp::TemplateConverterService.new(template_params, template)
normalized_params = converter.normalize_to_enhanced
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
process_enhanced_template_params(template, normalized_params['processed_params'])
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
def process_enhanced_template_params(template, processed_params = nil)
processed_params ||= template_params['processed_params']
components = []
# 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') }
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
end
header_params
end
def media_url_with_type?(key, header_data)
key == 'media_url' && header_data['media_type'].present?
end
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
end
end
@@ -19,7 +19,7 @@ describe Whatsapp::OneoffCampaignService do
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
'processed_params' => { 'body' => { 'name' => 'John', 'ticket_id' => '2332' } }
}
end
@@ -125,8 +125,13 @@ describe Whatsapp::OneoffCampaignService do
namespace: '23423423_2342423_324234234_2343224',
lang_code: 'en',
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: 'body',
parameters: array_including(
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
)
)
)
)
)
@@ -165,19 +165,17 @@ 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: [
{ type: 'body',
parameters: template_info[:parameters] }
]
},
type: 'template'
components: template_info[:parameters] # Changed to use parameters directly (enhanced format)
}
}
end
@@ -6,7 +6,7 @@ describe Whatsapp::SendOnWhatsappService do
namespace: '23423423_2342423_324234234_2343224',
language: 'en_US',
category: 'Marketing',
processed_params: { '1' => '3' }
processed_params: { 'body' => { '1' => '3' } }
}
describe '#perform' do
@@ -39,15 +39,16 @@ 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
@@ -73,7 +74,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)
conversation: conversation, additional_attributes: { template_params: template_params })
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
@@ -107,12 +108,18 @@ describe Whatsapp::SendOnWhatsappService do
name: 'ticket_status_updated',
language: 'en_US',
category: 'UTILITY',
processed_params: { 'last_name' => 'Dale', 'ticket_id' => '2332' }
processed_params: { 'body' => { '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 => { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}" },
: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'
},
:body => named_template_body.to_json
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
message = create(:message,
@@ -124,12 +131,44 @@ describe Whatsapp::SendOnWhatsappService do
end
it 'calls channel.send_template when template has regexp characters' do
message = create(
:message,
message_type: :outgoing,
content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.',
conversation: conversation
)
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(
@@ -137,10 +176,10 @@ describe Whatsapp::SendOnWhatsappService do
body: {
to: '123456789',
template: {
name: 'customer_yes_no',
namespace: '2342384942_32423423_23423fdsdaf23',
language: { 'policy': 'deterministic', 'code': 'ar' },
components: [{ 'type': 'body', 'parameters': [] }]
name: 'sample_shipping_confirmation',
namespace: '23423423_2342423_324234234_2343224',
language: { 'policy': 'deterministic', 'code': 'en_US' },
components: []
},
type: 'template'
}.to_json
@@ -149,6 +188,169 @@ describe Whatsapp::SendOnWhatsappService do
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
)
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
},
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' })
end
end
end
end
@@ -0,0 +1,199 @@
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