Merge branch 'develop' into feature/cw-6117
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
|
||||
def show
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return render json: { template_exists: false } unless template
|
||||
|
||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
render_template_status_response(status_result, template_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def create
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
# Delete existing template even though we are using a new one.
|
||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||
delete_existing_template_if_needed
|
||||
|
||||
result = create_template_via_provider(template_params)
|
||||
render_template_creation_result(result)
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def validate_whatsapp_channel
|
||||
return if @inbox.whatsapp?
|
||||
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||
status: :bad_request
|
||||
end
|
||||
|
||||
def extract_template_params
|
||||
params.require(:template).permit(:message, :button_text, :language)
|
||||
end
|
||||
|
||||
def render_missing_message_error
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def create_template_via_provider(template_params)
|
||||
template_config = {
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
}
|
||||
|
||||
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
else
|
||||
render_failed_template_creation(result)
|
||||
end
|
||||
end
|
||||
|
||||
def render_successful_template_creation(result)
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || DEFAULT_LANGUAGE
|
||||
}
|
||||
}, status: :created
|
||||
end
|
||||
|
||||
def render_failed_template_creation(result)
|
||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||
error_message = whatsapp_error[:user_message] || result[:error]
|
||||
|
||||
render json: {
|
||||
error: error_message,
|
||||
details: whatsapp_error[:technical_details]
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delete_existing_template_if_needed
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return true if template.blank?
|
||||
|
||||
template_name = template['name']
|
||||
return true if template_name.blank?
|
||||
|
||||
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
return true unless template_status[:success]
|
||||
|
||||
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||
if deletion_result[:success]
|
||||
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||
true
|
||||
else
|
||||
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||
false
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def render_template_status_response(status_result, template_name)
|
||||
if status_result[:success]
|
||||
render json: {
|
||||
template_exists: true,
|
||||
template_name: template_name,
|
||||
status: status_result[:template][:status],
|
||||
template_id: status_result[:template][:id]
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
template_exists: false,
|
||||
error: 'Template not found'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_whatsapp_error(response_body)
|
||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||
|
||||
begin
|
||||
error_data = JSON.parse(response_body)
|
||||
whatsapp_error = error_data['error'] || {}
|
||||
|
||||
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||
technical_details = {
|
||||
code: whatsapp_error['code'],
|
||||
subcode: whatsapp_error['error_subcode'],
|
||||
type: whatsapp_error['type'],
|
||||
title: whatsapp_error['error_user_title']
|
||||
}.compact
|
||||
|
||||
{ user_message: user_message, technical_details: technical_details }
|
||||
rescue JSON::ParserError
|
||||
{ user_message: nil, technical_details: response_body }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def format_csat_config(config)
|
||||
{
|
||||
display_type: config['display_type'] || 'emoji',
|
||||
message: config['message'] || '',
|
||||
survey_rules: {
|
||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||
values: config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
formatted = {
|
||||
'display_type' => config['display_type'] || 'emoji',
|
||||
'message' => config['message'] || '',
|
||||
:survey_rules => {
|
||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||
'values' => config.dig('survey_rules', 'values') || []
|
||||
},
|
||||
'button_text' => config['button_text'] || 'Please rate us',
|
||||
'language' => config['language'] || 'en'
|
||||
}
|
||||
format_template_config(config, formatted)
|
||||
formatted
|
||||
end
|
||||
|
||||
def format_template_config(config, formatted)
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :created_at, :language] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||
|
||||
params.permit(
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||
end
|
||||
|
||||
def channel_type_from_params
|
||||
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
else
|
||||
[]
|
||||
end
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
|
||||
@@ -24,6 +24,7 @@ const props = defineProps({
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -106,6 +107,7 @@ watch(
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
+24
-6
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@@ -193,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
@@ -202,21 +210,27 @@ const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(state.message, props.messageSignature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -227,12 +241,16 @@ const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
inboxChannelType.value
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
+24
-97
@@ -1,122 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const state = ref({
|
||||
hasSlashCommand: false,
|
||||
showMentions: false,
|
||||
mentionSearchKey: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
if (props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const bodyWithoutSignature = newValue
|
||||
? removeSignature(newValue, props.messageSignature)
|
||||
: '';
|
||||
|
||||
// Check if message starts with slash
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Update slash command and mentions state
|
||||
state.value = {
|
||||
...state.value,
|
||||
hasSlashCommand: startsWithSlash,
|
||||
showMentions: startsWithSlash,
|
||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const hideMention = () => {
|
||||
state.value.showMentions = false;
|
||||
};
|
||||
|
||||
const replaceText = async message => {
|
||||
// Only append signature on replace if sendWithSignature is true
|
||||
const finalMessage = props.sendWithSignature
|
||||
? appendSignature(message, props.messageSignature, props.channelType)
|
||||
: message;
|
||||
|
||||
await nextTick();
|
||||
modelValue.value = finalMessage;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<template v-if="isEmailOrWebWidgetInbox">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TextArea
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
||||
:custom-text-area-class="
|
||||
hasErrors
|
||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
auto-height
|
||||
allow-signature
|
||||
:signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
>
|
||||
<CannedResponse
|
||||
v-if="state.showMentions && state.hasSlashCommand"
|
||||
v-on-clickaway="hideMention"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="state.mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
</TextArea>
|
||||
</template>
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -81,6 +82,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
@@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
);
|
||||
|
||||
const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: effectiveChannelType.value;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
});
|
||||
@@ -116,7 +124,7 @@ const editorSchema = computed(() => {
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: props.channelType || DEFAULT_FORMATTING;
|
||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
});
|
||||
@@ -301,8 +309,13 @@ function isBodyEmpty(content) {
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(content, props.signature)
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
@@ -370,7 +383,11 @@ function addSignature() {
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
const contentWasEmpty = isBodyEmpty(content);
|
||||
content = appendSignature(content, props.signature, props.channelType);
|
||||
content = appendSignature(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
@@ -382,7 +399,11 @@ function addSignature() {
|
||||
function removeSignature() {
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(content, props.signature);
|
||||
content = removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// reload the state, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
@@ -43,6 +45,8 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -68,6 +72,8 @@ export default {
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
ResizableTextArea,
|
||||
CannedResponse,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -108,6 +114,8 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@@ -136,9 +144,12 @@ export default {
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.currentChat.meta.sender.id
|
||||
);
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
@@ -395,6 +406,19 @@ export default {
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -437,7 +461,25 @@ export default {
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
message() {
|
||||
message(updatedMessage) {
|
||||
// Check if the message starts with a slash.
|
||||
const bodyWithoutSignature = removeSignature(
|
||||
updatedMessage,
|
||||
this.signatureToApply
|
||||
);
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Determine if the user is potentially typing a slash command.
|
||||
// This is true if the message starts with a slash and the rich content editor is not active.
|
||||
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
|
||||
this.showMentions = this.hasSlashCommand;
|
||||
|
||||
// If a slash command is active, extract the command text after the slash.
|
||||
// If not, reset the mentionSearchKey.
|
||||
this.mentionSearchKey = this.hasSlashCommand
|
||||
? bodyWithoutSignature.substring(1)
|
||||
: '';
|
||||
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
@@ -487,14 +529,20 @@ export default {
|
||||
methods: {
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
if (this.isRichEditorEnabled) {
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
} else {
|
||||
this.addIntoEditor(
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
@@ -563,10 +611,26 @@ export default {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, this.channelType)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -582,6 +646,7 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -624,6 +689,9 @@ export default {
|
||||
},
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
@@ -757,11 +825,19 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
);
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -785,26 +861,52 @@ export default {
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
this.updateEditorSelectionWith = '';
|
||||
},
|
||||
insertIntoTextEditor(text, selectionStart, selectionEnd) {
|
||||
const { message } = this;
|
||||
const newMessage =
|
||||
message.slice(0, selectionStart) +
|
||||
text +
|
||||
message.slice(selectionEnd, message.length);
|
||||
this.message = newMessage;
|
||||
},
|
||||
addIntoEditor(content) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
);
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@@ -839,6 +941,9 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@@ -1085,6 +1190,13 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
@@ -1108,7 +1220,23 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<ResizableTextArea
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
@@ -1121,6 +1249,7 @@ export default {
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@@ -1233,6 +1362,10 @@ export default {
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
|
||||
textarea {
|
||||
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@@ -1252,4 +1385,9 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [
|
||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||
patterns: [
|
||||
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
{
|
||||
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
replacement: '$1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -227,11 +232,6 @@ export const MARKDOWN_PATTERNS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNEL_WITH_RICH_SIGNATURE = [
|
||||
'Channel::Email',
|
||||
'Channel::WebWidget',
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
|
||||
@@ -5,11 +5,8 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
FORMATTING,
|
||||
MARKDOWN_PATTERNS,
|
||||
CHANNEL_WITH_RICH_SIGNATURE,
|
||||
} from 'dashboard/constants/editor';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
@@ -35,6 +32,56 @@ export function extractTextFromMarkdown(markdown) {
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
* @returns {string} - The markdown with unsupported formatting removed
|
||||
*/
|
||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
const has = (arr, key) => arr.includes(key);
|
||||
|
||||
// Define stripping rules: [condition, pattern, replacement]
|
||||
const rules = [
|
||||
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
[
|
||||
!has(marks, 'em'),
|
||||
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
'$1',
|
||||
],
|
||||
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||
];
|
||||
|
||||
const result = rules.reduce(
|
||||
(text, [shouldStrip, pattern, replacement]) =>
|
||||
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||
markdown
|
||||
);
|
||||
|
||||
return result
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
* @type {string}
|
||||
@@ -97,29 +144,36 @@ export function findSignatureInBody(body, signature) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the channel supports image signatures.
|
||||
* Gets the effective channel type for formatting purposes.
|
||||
* For Twilio channels, returns WhatsApp or Twilio based on medium.
|
||||
*
|
||||
* @param {string} channelType - The channel type.
|
||||
* @returns {boolean} - True if the channel supports image signatures.
|
||||
* @param {string} channelType - The channel type
|
||||
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||
* @returns {string} - The effective channel type for formatting
|
||||
*/
|
||||
export function supportsImageSignature(channelType) {
|
||||
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
|
||||
export function getEffectiveChannelType(channelType, medium) {
|
||||
if (channelType === INBOX_TYPES.TWILIO) {
|
||||
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
? INBOX_TYPES.WHATSAPP
|
||||
: INBOX_TYPES.TWILIO;
|
||||
}
|
||||
return channelType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature to the body, separated by the signature delimiter.
|
||||
* Automatically strips images for channels that don't support image signatures.
|
||||
* Automatically strips unsupported formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {string} channelType - Optional. The channel type to determine if images should be stripped.
|
||||
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// For channels that don't support images, strip markdown formatting
|
||||
const shouldStripImages = channelType && !supportsImageSignature(channelType);
|
||||
const preparedSignature = shouldStripImages
|
||||
? extractTextFromMarkdown(signature)
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// if signature is already present, return body
|
||||
@@ -132,21 +186,28 @@ export function appendSignature(body, signature, channelType) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version (for non-image channels).
|
||||
* Tries to find both the original signature and the stripped version.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature removed.
|
||||
*/
|
||||
export function removeSignature(body, signature) {
|
||||
// Build list of signatures to try: original first, then stripped version
|
||||
// Always try both to handle cases where channelType is unknown or inbox is being removed
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
|
||||
const signaturesToTry =
|
||||
cleanedSignature === strippedSignature
|
||||
? [cleanedSignature]
|
||||
: [cleanedSignature, strippedSignature];
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// Try signatures in order: original → channel-specific → fully stripped
|
||||
const signaturesToTry = [
|
||||
cleanedSignature,
|
||||
channelStripped,
|
||||
fullyStripped,
|
||||
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||
|
||||
// Find the first matching signature
|
||||
const signatureIndex = signaturesToTry.reduce(
|
||||
|
||||
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
supportsImageSignature,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
@@ -145,10 +145,63 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
const richSignature =
|
||||
'**Bold** _italic_ [link](http://example.com) ';
|
||||
|
||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).toContain('');
|
||||
});
|
||||
it('strips images but keeps bold/italic for Api channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Api'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('link'); // link text kept
|
||||
expect(result).not.toContain('[link]('); // link syntax removed
|
||||
expect(result).not.toContain('; // image removed
|
||||
});
|
||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Telegram'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('strips all formatting for SMS channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Sms'
|
||||
);
|
||||
expect(result).toContain('Bold');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('link');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('_');
|
||||
expect(result).not.toContain('[');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSignature with channelType', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
const strippedSignature = 'Thanks';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
@@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => {
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('strips images for Api channel', () => {
|
||||
it('strips images but keeps text for Api channel', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain(strippedSignature);
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('strips images for WhatsApp channel', () => {
|
||||
it('strips images but keeps text for WhatsApp channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Whatsapp'
|
||||
);
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain(strippedSignature);
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('keeps images when channelType is not provided', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps bold/italic for channels that support them', () => {
|
||||
const boldSignature = '**Bold** *italic* Thanks';
|
||||
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
|
||||
// Api supports strong and em
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('*italic*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSignature', () => {
|
||||
@@ -331,24 +391,6 @@ describe('extractTextFromMarkdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsImageSignature', () => {
|
||||
it('returns true for Email channel', () => {
|
||||
expect(supportsImageSignature('Channel::Email')).toBe(true);
|
||||
});
|
||||
it('returns true for WebWidget channel', () => {
|
||||
expect(supportsImageSignature('Channel::WebWidget')).toBe(true);
|
||||
});
|
||||
it('returns false for Api channel', () => {
|
||||
expect(supportsImageSignature('Channel::Api')).toBe(false);
|
||||
});
|
||||
it('returns false for WhatsApp channel', () => {
|
||||
expect(supportsImageSignature('Channel::Whatsapp')).toBe(false);
|
||||
});
|
||||
it('returns false for Telegram channel', () => {
|
||||
expect(supportsImageSignature('Channel::Telegram')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
@@ -884,6 +926,26 @@ describe('stripUnsupportedFormatting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves underscores in URLs and mid-word positions', () => {
|
||||
// Underscores in URLs should not be stripped as italic formatting
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'https://www.chatwoot.com/new_first_second-third/ssd',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
|
||||
|
||||
// Underscores in variable names should not be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('some_variable_name', emptySchema)
|
||||
).toBe('some_variable_name');
|
||||
|
||||
// But actual italic formatting with spaces should still be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('hello _world_ there', emptySchema)
|
||||
).toBe('hello world there');
|
||||
});
|
||||
|
||||
it('strips inline code formatting', () => {
|
||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||
'inline code'
|
||||
|
||||
@@ -111,10 +111,15 @@ export default {
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
toggleSignatureInEditor(signatureEnabled) {
|
||||
const valueWithSignature = signatureEnabled
|
||||
let valueWithSignature = signatureEnabled
|
||||
? appendSignature(this.modelValue, this.cleanedSignature)
|
||||
: removeSignature(this.modelValue, this.cleanedSignature);
|
||||
|
||||
// Clean up whitespace when removing signature from empty body
|
||||
if (!signatureEnabled && !valueWithSignature.trim()) {
|
||||
valueWithSignature = '';
|
||||
}
|
||||
|
||||
this.$emit('update:modelValue', valueWithSignature);
|
||||
this.$emit('input', valueWithSignature);
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class Whatsapp::CsatTemplateNameService
|
||||
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
||||
|
||||
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
||||
|
||||
def self.csat_template_name(inbox_id, version = nil)
|
||||
base_name = csat_base_name_for_inbox(inbox_id)
|
||||
version ? "#{base_name}_#{version}" : base_name
|
||||
end
|
||||
|
||||
def self.extract_version(template_name, inbox_id)
|
||||
return nil if template_name.blank?
|
||||
|
||||
pattern = versioned_pattern_for_inbox(inbox_id)
|
||||
match = template_name.match(pattern)
|
||||
match ? match[1].to_i : nil
|
||||
end
|
||||
|
||||
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
|
||||
return base_name if current_template_name.blank?
|
||||
|
||||
current_version = extract_version(current_template_name, inbox_id)
|
||||
next_version = current_version ? current_version + 1 : 1
|
||||
csat_template_name(inbox_id, next_version)
|
||||
end
|
||||
|
||||
def self.matches_csat_pattern?(template_name, inbox_id)
|
||||
return false if template_name.blank?
|
||||
|
||||
base_pattern = base_pattern_for_inbox(inbox_id)
|
||||
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
|
||||
|
||||
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
|
||||
end
|
||||
|
||||
def self.csat_base_name_for_inbox(inbox_id)
|
||||
"#{CSAT_BASE_NAME}_#{inbox_id}"
|
||||
end
|
||||
|
||||
def self.base_pattern_for_inbox(inbox_id)
|
||||
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
|
||||
end
|
||||
|
||||
def self.versioned_pattern_for_inbox(inbox_id)
|
||||
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
|
||||
end
|
||||
|
||||
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
@whatsapp_channel = whatsapp_channel
|
||||
end
|
||||
|
||||
def create_template(template_config)
|
||||
base_name = template_config[:template_name]
|
||||
template_name = generate_template_name(base_name)
|
||||
template_config_with_name = template_config.merge(template_name: template_name)
|
||||
request_body = build_template_request_body(template_config_with_name)
|
||||
response = send_template_creation_request(request_body)
|
||||
process_template_creation_response(response, template_config_with_name)
|
||||
end
|
||||
|
||||
def delete_template(template_name = nil)
|
||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
|
||||
response = HTTParty.delete(
|
||||
"#{business_account_path}/message_templates?name=#{template_name}",
|
||||
headers: api_headers
|
||||
)
|
||||
{ success: response.success?, response_body: response.body }
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
|
||||
|
||||
if response.success? && response['data']&.any?
|
||||
template_data = response['data'].first
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
id: template_data['id'], name: template_data['name'],
|
||||
status: template_data['status'], language: template_data['language']
|
||||
}
|
||||
}
|
||||
else
|
||||
{ success: false, error: 'Template not found' }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching template status: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_template_name(base_name)
|
||||
current_template_name = current_template_name_from_config
|
||||
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
||||
end
|
||||
|
||||
def current_template_name_from_config
|
||||
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
|
||||
end
|
||||
|
||||
def build_template_request_body(template_config)
|
||||
{
|
||||
name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
category: TEMPLATE_CATEGORY,
|
||||
components: build_template_components(template_config)
|
||||
}
|
||||
end
|
||||
|
||||
def build_template_components(template_config)
|
||||
[
|
||||
build_body_component(template_config[:message]),
|
||||
build_buttons_component(template_config)
|
||||
]
|
||||
end
|
||||
|
||||
def build_body_component(message)
|
||||
{
|
||||
type: 'BODY',
|
||||
text: message
|
||||
}
|
||||
end
|
||||
|
||||
def build_buttons_component(template_config)
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def send_template_creation_request(request_body)
|
||||
HTTParty.post(
|
||||
"#{business_account_path}/message_templates",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def process_template_creation_response(response, template_config = {})
|
||||
if response.success?
|
||||
{
|
||||
success: true,
|
||||
template_id: response['id'],
|
||||
template_name: response['name'] || template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: TEMPLATE_STATUS_PENDING
|
||||
}
|
||||
else
|
||||
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
|
||||
{
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: response.body
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def business_account_path
|
||||
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
end
|
||||
@@ -62,12 +62,31 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
def create_csat_template(template_config)
|
||||
csat_template_service.create_template(template_config)
|
||||
end
|
||||
|
||||
def delete_csat_template(template_name = nil)
|
||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
|
||||
csat_template_service.delete_template(template_name)
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
csat_template_service.get_template_status(template_name)
|
||||
end
|
||||
|
||||
def media_url(media_id, phone_number_id = nil)
|
||||
url = "#{api_base_path}/v13.0/#{media_id}"
|
||||
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
|
||||
url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csat_template_service
|
||||
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
|
||||
@@ -203,6 +203,8 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
||||
end
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
collection do
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:web_widget_inbox) { create(:inbox, account: account) }
|
||||
let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is not a WhatsApp channel' do
|
||||
it 'returns bad request' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a WhatsApp channel' do
|
||||
it 'returns template not found when no configuration exists' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['template_exists']).to be false
|
||||
end
|
||||
|
||||
it 'returns template status when template exists on WhatsApp' do
|
||||
template_config = {
|
||||
'template' => {
|
||||
'name' => 'custom_survey_template',
|
||||
'template_id' => '123456789',
|
||||
'language' => 'en'
|
||||
}
|
||||
}
|
||||
whatsapp_inbox.update!(csat_config: template_config)
|
||||
|
||||
allow(mock_service).to receive(:get_template_status)
|
||||
.with('custom_survey_template')
|
||||
.and_return({
|
||||
success: true,
|
||||
template: { id: '123456789', status: 'APPROVED' }
|
||||
})
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['template_exists']).to be true
|
||||
expect(response_data['template_name']).to eq('custom_survey_template')
|
||||
expect(response_data['status']).to eq('APPROVED')
|
||||
expect(response_data['template_id']).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'returns template not found when template does not exist on WhatsApp' do
|
||||
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
|
||||
whatsapp_inbox.update!(csat_config: template_config)
|
||||
|
||||
allow(mock_service).to receive(:get_template_status)
|
||||
.with('custom_survey_template')
|
||||
.and_return({ success: false, error: 'Template not found' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['template_exists']).to be false
|
||||
expect(response_data['error']).to eq('Template not found')
|
||||
end
|
||||
|
||||
it 'handles service errors gracefully' do
|
||||
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
|
||||
whatsapp_inbox.update!(csat_config: template_config)
|
||||
|
||||
allow(mock_service).to receive(:get_template_status)
|
||||
.and_raise(StandardError, 'API connection failed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
expect(response.parsed_body['error']).to eq('API connection failed')
|
||||
end
|
||||
|
||||
it 'returns unauthorized when agent is not assigned to inbox' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: other_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'allows access when agent is assigned to inbox' do
|
||||
whatsapp_inbox.update!(csat_config: { 'template' => { 'name' => 'test' } })
|
||||
allow(mock_service).to receive(:get_template_status)
|
||||
.and_return({ success: true, template: { id: '123', status: 'APPROVED' } })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
||||
let(:valid_template_params) do
|
||||
{
|
||||
template: {
|
||||
message: 'How would you rate your experience?',
|
||||
button_text: 'Rate Us',
|
||||
language: 'en'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is not a WhatsApp channel' do
|
||||
it 'returns bad request' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a WhatsApp channel' do
|
||||
it 'returns error when message is missing' do
|
||||
invalid_params = {
|
||||
template: {
|
||||
button_text: 'Rate Us',
|
||||
language: 'en'
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: invalid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Message is required')
|
||||
end
|
||||
|
||||
it 'returns error when template parameters are completely missing' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: {},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Template parameters are required')
|
||||
end
|
||||
|
||||
it 'creates template successfully' do
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
||||
success: true,
|
||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||
template_id: '987654321'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['template']['name']).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
||||
expect(response_data['template']['template_id']).to eq('987654321')
|
||||
expect(response_data['template']['status']).to eq('PENDING')
|
||||
expect(response_data['template']['language']).to eq('en')
|
||||
end
|
||||
|
||||
it 'uses default values for optional parameters' do
|
||||
minimal_params = {
|
||||
template: {
|
||||
message: 'How would you rate your experience?'
|
||||
}
|
||||
}
|
||||
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
expect(mock_service).to receive(:create_csat_template) do |config|
|
||||
expect(config[:button_text]).to eq('Please rate us')
|
||||
expect(config[:language]).to eq('en')
|
||||
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
||||
{ success: true, template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", template_id: '123' }
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: minimal_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
|
||||
it 'handles WhatsApp API errors with user-friendly messages' do
|
||||
whatsapp_error_response = {
|
||||
'error' => {
|
||||
'code' => 100,
|
||||
'error_subcode' => 2_388_092,
|
||||
'message' => 'Invalid parameter',
|
||||
'error_user_title' => 'Template Creation Failed',
|
||||
'error_user_msg' => 'The template message contains invalid content. Please review your message and try again.'
|
||||
}
|
||||
}
|
||||
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: whatsapp_error_response.to_json
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['error']).to eq('The template message contains invalid content. Please review your message and try again.')
|
||||
expect(response_data['details']).to include({
|
||||
'code' => 100,
|
||||
'subcode' => 2_388_092,
|
||||
'title' => 'Template Creation Failed'
|
||||
})
|
||||
end
|
||||
|
||||
it 'handles generic API errors' do
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
||||
success: false,
|
||||
error: 'Network timeout',
|
||||
response_body: nil
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Network timeout')
|
||||
end
|
||||
|
||||
it 'handles unexpected service errors' do
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
allow(mock_service).to receive(:create_csat_template)
|
||||
.and_raise(StandardError, 'Unexpected error')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
expect(response.parsed_body['error']).to eq('Template creation failed')
|
||||
end
|
||||
|
||||
it 'deletes existing template before creating new one' do
|
||||
whatsapp_inbox.update!(csat_config: {
|
||||
'template' => {
|
||||
'name' => 'existing_template',
|
||||
'template_id' => '111111111'
|
||||
}
|
||||
})
|
||||
|
||||
allow(mock_service).to receive(:get_template_status)
|
||||
.with('existing_template')
|
||||
.and_return({ success: true, template: { id: '111111111' } })
|
||||
expect(mock_service).to receive(:delete_csat_template)
|
||||
.with('existing_template')
|
||||
.and_return({ success: true })
|
||||
expect(mock_service).to receive(:create_csat_template)
|
||||
.and_return({
|
||||
success: true,
|
||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||
template_id: '222222222'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
|
||||
it 'continues with creation even if deletion fails' do
|
||||
whatsapp_inbox.update!(csat_config: {
|
||||
'template' => { 'name' => 'existing_template' }
|
||||
})
|
||||
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
|
||||
allow(mock_service).to receive(:delete_csat_template)
|
||||
.and_return({ success: false, response_body: 'Delete failed' })
|
||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
||||
success: true,
|
||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||
template_id: '333333333'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
|
||||
it 'returns unauthorized when agent is not assigned to inbox' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: other_agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'allows access when agent is assigned to inbox' do
|
||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
||||
success: true,
|
||||
template_name: 'customer_satisfaction_survey',
|
||||
template_id: '444444444'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -804,6 +804,101 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
|
||||
end
|
||||
end
|
||||
|
||||
it 'successfully updates inbox with template configuration' do
|
||||
csat_config_with_template = csat_config.merge({
|
||||
'template' => {
|
||||
'name' => 'custom_survey_template',
|
||||
'template_id' => '123456789',
|
||||
'language' => 'en',
|
||||
'created_at' => Time.current.iso8601
|
||||
}
|
||||
})
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config_with_template
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
inbox.reload
|
||||
template_config = inbox.csat_config['template']
|
||||
expect(template_config).to be_present
|
||||
expect(template_config['name']).to eq('custom_survey_template')
|
||||
expect(template_config['template_id']).to eq('123456789')
|
||||
expect(template_config['language']).to eq('en')
|
||||
end
|
||||
|
||||
it 'returns template configuration in inbox details' do
|
||||
csat_config_with_template = csat_config.merge({
|
||||
'template' => {
|
||||
'name' => 'custom_survey_template',
|
||||
'template_id' => '123456789',
|
||||
'language' => 'en',
|
||||
'created_at' => Time.current.iso8601
|
||||
}
|
||||
})
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config_with_template
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
template_config = json_response['csat_config']['template']
|
||||
|
||||
expect(template_config).to be_present
|
||||
expect(template_config['name']).to eq('custom_survey_template')
|
||||
expect(template_config['template_id']).to eq('123456789')
|
||||
expect(template_config['language']).to eq('en')
|
||||
expect(template_config['created_at']).to be_present
|
||||
end
|
||||
|
||||
it 'removes template configuration when not provided in update' do
|
||||
# First set up template configuration
|
||||
csat_config_with_template = csat_config.merge({
|
||||
'template' => {
|
||||
'name' => 'custom_survey_template',
|
||||
'template_id' => '123456789'
|
||||
}
|
||||
})
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config_with_template
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
# Then update without template
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config.merge({ 'message' => 'Updated message' })
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
inbox.reload
|
||||
config = inbox.csat_config
|
||||
expect(config['message']).to eq('Updated message')
|
||||
expect(config['template']).to be_nil # Template should be removed when not provided
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Whatsapp::CsatTemplateService do
|
||||
let(:account) { create(:account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:service) { described_class.new(whatsapp_channel) }
|
||||
|
||||
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
|
||||
let(:template_config) do
|
||||
{
|
||||
message: 'How would you rate your experience?',
|
||||
button_text: 'Rate Us',
|
||||
language: 'en',
|
||||
base_url: 'https://example.com',
|
||||
template_name: expected_template_name
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('WHATSAPP_CLOUD_BASE_URL', anything).and_return('https://graph.facebook.com')
|
||||
end
|
||||
|
||||
describe '#generate_template_name' do
|
||||
context 'when no existing template' do
|
||||
it 'returns base name as-is' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
|
||||
it 'returns base name when template key is missing' do
|
||||
whatsapp_channel.inbox.update!(csat_config: { 'other_config' => 'value' })
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has no versioned name' do
|
||||
it 'starts versioning from 1' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => expected_template_name }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
|
||||
it 'starts versioning from 1 for custom name' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => 'custom_survey' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has versioned name' do
|
||||
it 'increments version number' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_1" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_2")
|
||||
end
|
||||
|
||||
it 'increments higher version numbers' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_5" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_6")
|
||||
end
|
||||
|
||||
it 'handles double digit version numbers' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_12" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_13")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has non-matching versioned name' do
|
||||
it 'starts versioning from 1' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => 'different_survey_3' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template name is blank' do
|
||||
it 'returns base name' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => '' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
|
||||
it 'returns base name when template name is nil' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => nil }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_template_request_body' do
|
||||
it 'builds correct request structure' do
|
||||
result = service.send(:build_template_request_body, template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'How would you rate your experience?'
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: 'Rate Us',
|
||||
url: 'https://example.com/survey/responses/{{1}}',
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses default language when not provided' do
|
||||
config_without_language = template_config.except(:language)
|
||||
result = service.send(:build_template_request_body, config_without_language)
|
||||
expect(result[:language]).to eq('en')
|
||||
end
|
||||
|
||||
it 'uses default button text when not provided' do
|
||||
config_without_button = template_config.except(:button_text)
|
||||
result = service.send(:build_template_request_body, config_without_button)
|
||||
expect(result[:components][1][:buttons][0][:text]).to eq('Please rate us')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_template' do
|
||||
let(:mock_response) do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
double('response', :success? => true, :body => '{}', '[]' => { 'id' => '123', 'name' => 'template_name' })
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
end
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:post).and_return(mock_response)
|
||||
inbox.update!(csat_config: {})
|
||||
end
|
||||
|
||||
it 'creates template with generated name' do
|
||||
expected_body = {
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'How would you rate your experience?'
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: 'Rate Us',
|
||||
url: 'https://example.com/survey/responses/{{1}}',
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(HTTParty).to receive(:post).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
},
|
||||
body: expected_body.to_json
|
||||
)
|
||||
|
||||
service.create_template(template_config)
|
||||
end
|
||||
|
||||
it 'returns success response on successful creation' do
|
||||
allow(mock_response).to receive(:[]).with('id').and_return('template_123')
|
||||
allow(mock_response).to receive(:[]).with('name').and_return(expected_template_name)
|
||||
|
||||
result = service.create_template(template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
success: true,
|
||||
template_id: 'template_123',
|
||||
template_name: expected_template_name,
|
||||
language: 'en',
|
||||
status: 'PENDING'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when API call fails' do
|
||||
let(:error_response) do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
double('response', success?: false, code: 400, body: '{"error": "Invalid template"}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
end
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
result = service.create_template(template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: '{"error": "Invalid template"}'
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('WhatsApp template creation failed: 400 - {"error": "Invalid template"}')
|
||||
service.create_template(template_config)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete_template' do
|
||||
it 'makes DELETE request to correct endpoint' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
expect(HTTParty).to receive(:delete).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
).and_return(mock_response)
|
||||
|
||||
result = service.delete_template('test_template')
|
||||
expect(result).to eq({ success: true, response_body: '{}' })
|
||||
end
|
||||
|
||||
it 'uses default template name when none provided' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
expect(HTTParty).to receive(:delete).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=#{expected_template_name}",
|
||||
anything
|
||||
).and_return(mock_response)
|
||||
|
||||
service.delete_template
|
||||
end
|
||||
|
||||
it 'returns failure response when API call fails' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: false, body: '{"error": "Template not found"}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(HTTParty).to receive(:delete).and_return(mock_response)
|
||||
|
||||
result = service.delete_template('test_template')
|
||||
expect(result).to eq({ success: false, response_body: '{"error": "Template not found"}' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_template_status' do
|
||||
it 'makes GET request to correct endpoint' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([{
|
||||
'id' => '123',
|
||||
'name' => 'test_template',
|
||||
'status' => 'APPROVED',
|
||||
'language' => 'en'
|
||||
}])
|
||||
|
||||
expect(HTTParty).to receive(:get).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
).and_return(mock_response)
|
||||
|
||||
service.get_template_status('test_template')
|
||||
end
|
||||
|
||||
it 'returns success response when template exists' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([{
|
||||
'id' => '123',
|
||||
'name' => 'test_template',
|
||||
'status' => 'APPROVED',
|
||||
'language' => 'en'
|
||||
}])
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
|
||||
expect(result).to eq({
|
||||
success: true,
|
||||
template: {
|
||||
id: '123',
|
||||
name: 'test_template',
|
||||
status: 'APPROVED',
|
||||
language: 'en'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns failure response when template not found' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([])
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Template not found' })
|
||||
end
|
||||
|
||||
it 'returns failure response when API call fails' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: false, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Template not found' })
|
||||
end
|
||||
|
||||
context 'when API raises an exception' do
|
||||
before do
|
||||
allow(HTTParty).to receive(:get).and_raise(StandardError, 'Network error')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles exceptions gracefully' do
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Network error' })
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('Error fetching template status: Network error')
|
||||
service.get_template_status('test_template')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -312,4 +312,92 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'CSAT template methods' do
|
||||
let(:mock_csat_template_service) { instance_double(Whatsapp::CsatTemplateService) }
|
||||
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
|
||||
let(:template_config) do
|
||||
{
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'UTILITY'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Whatsapp::CsatTemplateService).to receive(:new)
|
||||
.with(whatsapp_channel)
|
||||
.and_return(mock_csat_template_service)
|
||||
end
|
||||
|
||||
describe '#create_csat_template' do
|
||||
it 'delegates to csat_template_service with correct config' do
|
||||
allow(mock_csat_template_service).to receive(:create_template)
|
||||
.with(template_config)
|
||||
.and_return({ success: true, template_id: '123' })
|
||||
|
||||
result = service.create_csat_template(template_config)
|
||||
|
||||
expect(mock_csat_template_service).to have_received(:create_template).with(template_config)
|
||||
expect(result).to eq({ success: true, template_id: '123' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete_csat_template' do
|
||||
it 'delegates to csat_template_service with default template name' do
|
||||
allow(mock_csat_template_service).to receive(:delete_template)
|
||||
.with(expected_template_name)
|
||||
.and_return({ success: true })
|
||||
|
||||
result = service.delete_csat_template
|
||||
|
||||
expect(mock_csat_template_service).to have_received(:delete_template).with(expected_template_name)
|
||||
expect(result).to eq({ success: true })
|
||||
end
|
||||
|
||||
it 'delegates to csat_template_service with custom template name' do
|
||||
custom_template_name = 'custom_csat_template'
|
||||
allow(mock_csat_template_service).to receive(:delete_template)
|
||||
.with(custom_template_name)
|
||||
.and_return({ success: true })
|
||||
|
||||
result = service.delete_csat_template(custom_template_name)
|
||||
|
||||
expect(mock_csat_template_service).to have_received(:delete_template).with(custom_template_name)
|
||||
expect(result).to eq({ success: true })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_template_status' do
|
||||
it 'delegates to csat_template_service with template name' do
|
||||
template_name = 'customer_survey_template'
|
||||
expected_response = { success: true, template: { status: 'APPROVED' } }
|
||||
allow(mock_csat_template_service).to receive(:get_template_status)
|
||||
.with(template_name)
|
||||
.and_return(expected_response)
|
||||
|
||||
result = service.get_template_status(template_name)
|
||||
|
||||
expect(mock_csat_template_service).to have_received(:get_template_status).with(template_name)
|
||||
expect(result).to eq(expected_response)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'csat_template_service memoization' do
|
||||
it 'creates and memoizes the csat_template_service instance' do
|
||||
allow(Whatsapp::CsatTemplateService).to receive(:new)
|
||||
.with(whatsapp_channel)
|
||||
.and_return(mock_csat_template_service)
|
||||
allow(mock_csat_template_service).to receive(:get_template_status)
|
||||
.and_return({ success: true })
|
||||
|
||||
# Call multiple methods that use the service
|
||||
service.get_template_status('test1')
|
||||
service.get_template_status('test2')
|
||||
|
||||
# Verify the service was only instantiated once
|
||||
expect(Whatsapp::CsatTemplateService).to have_received(:new).once
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user