Merge branch 'develop' into inline-edit
This commit is contained in:
@@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = create_conversation if conversation.nil?
|
||||
return unless conversation.nil?
|
||||
|
||||
@conversation = create_conversation
|
||||
apply_labels if permitted_params[:labels].present?
|
||||
end
|
||||
|
||||
def apply_labels
|
||||
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
|
||||
@conversation.update_labels(valid_labels) if valid_labels.present?
|
||||
end
|
||||
|
||||
def message_finder_params
|
||||
@@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||
# custom_attributes and labels are applied when a new conversation is created alongside the first message
|
||||
params.permit(
|
||||
:id, :before, :after, :website_token,
|
||||
contact: [:name, :email],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
|
||||
custom_attributes: {},
|
||||
labels: []
|
||||
)
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -6,13 +6,26 @@ const createConversationAPI = async content => {
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendMessageAPI = async (content, replyTo = null) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo);
|
||||
const sendMessageAPI = async (
|
||||
content,
|
||||
replyTo = null,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||
const sendAttachmentAPI = async (
|
||||
attachment,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,23 +22,30 @@ const createConversation = params => {
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage = (content, replyTo) => {
|
||||
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
|
||||
const referrerURL = window.referrerURL || '';
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return {
|
||||
url: `/api/v1/widget/messages${search}`,
|
||||
params: {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
const params = {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
};
|
||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
||||
params.custom_attributes = customAttributes;
|
||||
}
|
||||
if (labels && labels.length > 0) {
|
||||
params.labels = labels;
|
||||
}
|
||||
return { url: `/api/v1/widget/messages${search}`, params };
|
||||
};
|
||||
|
||||
const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
const sendAttachment = (
|
||||
{ attachment, replyTo = null },
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const { referrerURL = '' } = window;
|
||||
const timestamp = new Date().toString();
|
||||
const { file } = attachment;
|
||||
@@ -55,6 +62,16 @@ const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
if (replyTo !== null) {
|
||||
formData.append('message[reply_to]', replyTo);
|
||||
}
|
||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
||||
Object.entries(customAttributes).forEach(([key, value]) => {
|
||||
formData.append(`custom_attributes[${key}]`, value);
|
||||
});
|
||||
}
|
||||
if (labels && labels.length > 0) {
|
||||
labels.forEach(label => {
|
||||
formData.append('labels[]', label);
|
||||
});
|
||||
}
|
||||
return {
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: formData,
|
||||
|
||||
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessage with pending metadata', () => {
|
||||
it('includes custom_attributes and labels in payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.sendMessage('hello', null, {
|
||||
customAttributes: { plan: 'enterprise' },
|
||||
labels: ['vip'],
|
||||
});
|
||||
|
||||
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
|
||||
expect(result.params.labels).toEqual(['vip']);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not include metadata keys when not provided', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.sendMessage('hello');
|
||||
expect(result.params.custom_attributes).toBeUndefined();
|
||||
expect(result.params.labels).toBeUndefined();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('returns correct payload', () => {
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
||||
@apply antialiased h-full;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
||||
@@ -85,10 +85,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async retrySendMessage() {
|
||||
await this.$store.dispatch(
|
||||
'conversation/sendMessageWithData',
|
||||
this.message
|
||||
);
|
||||
await this.$store.dispatch('conversation/sendMessageWithData', {
|
||||
message: this.message,
|
||||
});
|
||||
},
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
@@ -23,10 +23,6 @@ export function useDarkMode() {
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
||||
});
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
|
||||
@@ -30,18 +30,37 @@ export const actions = {
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
}
|
||||
},
|
||||
sendMessage: async ({ dispatch }, params) => {
|
||||
sendMessage: async ({ dispatch, state: conversationState }, params) => {
|
||||
const { content, replyTo } = params;
|
||||
const message = createTemporaryMessage({ content, replyTo });
|
||||
dispatch('sendMessageWithData', message);
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
dispatch('sendMessageWithData', {
|
||||
message,
|
||||
pendingCustomAttributes,
|
||||
pendingLabels,
|
||||
});
|
||||
},
|
||||
sendMessageWithData: async ({ commit }, message) => {
|
||||
sendMessageWithData: async (
|
||||
{ commit },
|
||||
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
|
||||
) => {
|
||||
const { id, content, replyTo, meta = {} } = message;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', message);
|
||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||
try {
|
||||
const { data } = await sendMessageAPI(content, replyTo);
|
||||
const { data } = await sendMessageAPI(content, replyTo, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
|
||||
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
|
||||
// commit('deleteMessage', message.id);
|
||||
@@ -59,7 +78,7 @@ export const actions = {
|
||||
commit('setLastMessageId');
|
||||
},
|
||||
|
||||
sendAttachment: async ({ commit }, params) => {
|
||||
sendAttachment: async ({ commit, state: conversationState }, params) => {
|
||||
const {
|
||||
attachment: { thumbUrl, fileType },
|
||||
meta = {},
|
||||
@@ -74,9 +93,22 @@ export const actions = {
|
||||
attachments: [attachment],
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', tempMessage);
|
||||
try {
|
||||
const { data } = await sendAttachmentAPI(params);
|
||||
const { data } = await sendAttachmentAPI(params, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
commit('updateAttachmentMessageStatus', {
|
||||
message: data,
|
||||
tempId: tempMessage.id,
|
||||
@@ -180,7 +212,14 @@ export const actions = {
|
||||
await toggleStatus();
|
||||
},
|
||||
|
||||
setCustomAttributes: async (_, customAttributes = {}) => {
|
||||
setCustomAttributes: async (
|
||||
{ commit, rootGetters },
|
||||
customAttributes = {}
|
||||
) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('setPendingCustomAttributes', customAttributes);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setCustomAttributes(customAttributes);
|
||||
} catch (error) {
|
||||
@@ -188,7 +227,11 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomAttribute: async (_, customAttribute) => {
|
||||
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('removePendingCustomAttribute', customAttribute);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCustomAttribute(customAttribute);
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,6 +33,8 @@ export const getters = {
|
||||
messages: groupConversationBySender(conversationGroupedByDate[date]),
|
||||
}));
|
||||
},
|
||||
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
|
||||
getPendingLabels: _state => _state.pendingLabels,
|
||||
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
|
||||
getMessageCount: _state => {
|
||||
return Object.values(_state.conversations).length;
|
||||
|
||||
@@ -14,6 +14,8 @@ const state = {
|
||||
isCreating: false,
|
||||
},
|
||||
lastMessageId: null,
|
||||
pendingCustomAttributes: {},
|
||||
pendingLabels: [],
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { findUndeliveredMessage } from './helpers';
|
||||
export const mutations = {
|
||||
clearConversations($state) {
|
||||
$state.conversations = {};
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
pushMessageToConversation($state, message) {
|
||||
const { id, status, message_type: type } = message;
|
||||
@@ -113,4 +115,31 @@ export const mutations = {
|
||||
const { id } = lastMessage;
|
||||
$state.lastMessageId = id;
|
||||
},
|
||||
|
||||
setPendingCustomAttributes($state, data) {
|
||||
$state.pendingCustomAttributes = {
|
||||
...$state.pendingCustomAttributes,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
setPendingLabels($state, label) {
|
||||
if (!$state.pendingLabels.includes(label)) {
|
||||
$state.pendingLabels.push(label);
|
||||
}
|
||||
},
|
||||
|
||||
removePendingCustomAttribute($state, key) {
|
||||
const { [key]: _, ...rest } = $state.pendingCustomAttributes;
|
||||
$state.pendingCustomAttributes = rest;
|
||||
},
|
||||
|
||||
removePendingLabel($state, label) {
|
||||
$state.pendingLabels = $state.pendingLabels.filter(l => l !== label);
|
||||
},
|
||||
|
||||
clearPendingConversationMetadata($state) {
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,14 +5,22 @@ const state = {};
|
||||
export const getters = {};
|
||||
|
||||
export const actions = {
|
||||
create: async (_, label) => {
|
||||
create: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/setPendingLabels', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.create(label);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
destroy: async (_, label) => {
|
||||
destroy: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/removePendingLabel', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.destroy(label);
|
||||
} catch (error) {
|
||||
|
||||
@@ -111,20 +111,45 @@ describe('#actions', () => {
|
||||
search: '?param=1',
|
||||
},
|
||||
}));
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
await actions.sendMessage(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ content: 'hello', replyTo: 124 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
windowSpy.mockRestore();
|
||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
status: 'in_progress',
|
||||
message: {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
status: 'in_progress',
|
||||
},
|
||||
pendingCustomAttributes: {},
|
||||
pendingLabels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('includes pending metadata when available', async () => {
|
||||
const mockDate = new Date(1466424490000);
|
||||
getUuid.mockImplementationOnce(() => '2222');
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
await actions.sendMessage(
|
||||
{ commit, dispatch, state },
|
||||
{ content: 'hello' }
|
||||
);
|
||||
spy.mockRestore();
|
||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||
message: expect.objectContaining({ content: 'hello' }),
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,9 +161,10 @@ describe('#actions', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
const thumbUrl = '';
|
||||
const attachment = { thumbUrl, fileType: 'file' };
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
|
||||
actions.sendAttachment(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ attachment, replyTo: 135 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
@@ -180,6 +206,58 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setCustomAttributes', () => {
|
||||
it('queues to pending state when no conversation exists', async () => {
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
await actions.setCustomAttributes(
|
||||
{ commit, rootGetters },
|
||||
{ plan: 'enterprise' }
|
||||
);
|
||||
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
|
||||
plan: 'enterprise',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls API when conversation exists', async () => {
|
||||
API.post.mockResolvedValue({ data: {} });
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: 123 },
|
||||
};
|
||||
await actions.setCustomAttributes(
|
||||
{ commit, rootGetters },
|
||||
{ plan: 'enterprise' }
|
||||
);
|
||||
expect(commit).not.toBeCalledWith(
|
||||
'setPendingCustomAttributes',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteCustomAttribute', () => {
|
||||
it('removes from pending state when no conversation exists', async () => {
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
||||
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
|
||||
});
|
||||
|
||||
it('calls API when conversation exists', async () => {
|
||||
API.post.mockResolvedValue({ data: {} });
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: 123 },
|
||||
};
|
||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
||||
expect(commit).not.toBeCalledWith(
|
||||
'removePendingCustomAttribute',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('sends correct mutations', () => {
|
||||
actions.clearConversations({ commit });
|
||||
|
||||
@@ -169,10 +169,77 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('clears the state', () => {
|
||||
const state = { conversations: { 1: { id: 1 } } };
|
||||
it('clears conversations and pending metadata', () => {
|
||||
const state = {
|
||||
conversations: { 1: { id: 1 } },
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearConversations(state);
|
||||
expect(state.conversations).toEqual({});
|
||||
expect(state.pendingCustomAttributes).toEqual({});
|
||||
expect(state.pendingLabels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPendingCustomAttributes', () => {
|
||||
it('merges custom attributes into pending state', () => {
|
||||
const state = { pendingCustomAttributes: { existing: 'value' } };
|
||||
mutations.setPendingCustomAttributes(state, { plan: 'enterprise' });
|
||||
expect(state.pendingCustomAttributes).toEqual({
|
||||
existing: 'value',
|
||||
plan: 'enterprise',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPendingLabels', () => {
|
||||
it('adds label to pending state', () => {
|
||||
const state = { pendingLabels: [] };
|
||||
mutations.setPendingLabels(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
|
||||
it('does not add duplicate labels', () => {
|
||||
const state = { pendingLabels: ['vip'] };
|
||||
mutations.setPendingLabels(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removePendingCustomAttribute', () => {
|
||||
it('removes a single key from pending custom attributes', () => {
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise', region: 'us' },
|
||||
};
|
||||
mutations.removePendingCustomAttribute(state, 'plan');
|
||||
expect(state.pendingCustomAttributes).toEqual({ region: 'us' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removePendingLabel', () => {
|
||||
it('removes a label from pending labels', () => {
|
||||
const state = { pendingLabels: ['vip', 'premium'] };
|
||||
mutations.removePendingLabel(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['premium']);
|
||||
});
|
||||
|
||||
it('does nothing if label not present', () => {
|
||||
const state = { pendingLabels: ['vip'] };
|
||||
mutations.removePendingLabel(state, 'premium');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clearPendingConversationMetadata', () => {
|
||||
it('clears pending custom attributes and labels', () => {
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearPendingConversationMetadata(state);
|
||||
expect(state.pendingCustomAttributes).toEqual({});
|
||||
expect(state.pendingLabels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,14 @@ class AgentBotListener < BaseListener
|
||||
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
||||
end
|
||||
|
||||
def conversation_updated(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
inbox = conversation.inbox
|
||||
event_name = __method__.to_s
|
||||
payload = conversation.webhook_data.merge(event: event_name)
|
||||
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
||||
end
|
||||
|
||||
def message_created(event)
|
||||
message = extract_message_and_account(event)[0]
|
||||
inbox = message.inbox
|
||||
|
||||
@@ -37,6 +37,7 @@ class Attachment < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :message
|
||||
has_one_attached :file
|
||||
before_save :set_extension
|
||||
validate :acceptable_file
|
||||
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
||||
@@ -111,6 +112,7 @@ class Attachment < ApplicationRecord
|
||||
def file_metadata
|
||||
metadata = {
|
||||
extension: extension,
|
||||
content_type: file.content_type,
|
||||
data_url: file_url,
|
||||
thumb_url: thumb_url,
|
||||
file_size: file.byte_size,
|
||||
@@ -118,7 +120,7 @@ class Attachment < ApplicationRecord
|
||||
height: file.metadata[:height]
|
||||
}
|
||||
|
||||
metadata[:data_url] = metadata[:thumb_url] = external_url if message.inbox.instagram? && message.incoming?
|
||||
metadata[:data_url] = metadata[:thumb_url] = external_url if instagram_incoming_message?
|
||||
metadata
|
||||
end
|
||||
|
||||
@@ -154,6 +156,21 @@ class Attachment < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def instagram_incoming_message?
|
||||
return false unless message.incoming?
|
||||
|
||||
return true if message.inbox.instagram_direct?
|
||||
|
||||
message.inbox.instagram? && message.conversation&.additional_attributes&.dig('type') == 'instagram_direct_message'
|
||||
end
|
||||
|
||||
def set_extension
|
||||
return unless file.attached?
|
||||
return if extension.present?
|
||||
|
||||
self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence
|
||||
end
|
||||
|
||||
def should_validate_file?
|
||||
return unless file.attached?
|
||||
# we are only limiting attachment types in case of website widget
|
||||
|
||||
@@ -58,9 +58,9 @@ By default, it renders:
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white dark:bg-slate-900">
|
||||
<body>
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
module Enterprise::SuperAdmin::AccountsController
|
||||
def create
|
||||
manually_managed = params[:account]&.delete(:manually_managed_features)
|
||||
|
||||
super do |resource|
|
||||
if manually_managed.present?
|
||||
service = ::Internal::Accounts::InternalAttributesService.new(resource)
|
||||
service.manually_managed_features = manually_managed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
# Handle manually managed features from form submission
|
||||
if params[:account] && params[:account][:manually_managed_features].present?
|
||||
|
||||
@@ -56,6 +56,65 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
expect(json_response['content']).to eq(message_params[:content])
|
||||
end
|
||||
|
||||
it 'creates conversation with custom_attributes when first message is sent' do
|
||||
conversation.destroy!
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
custom_attributes = { plan: 'enterprise', source: 'website' }
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params, custom_attributes: custom_attributes },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
new_conversation = contact.conversations.last
|
||||
expect(new_conversation.custom_attributes).to include('plan' => 'enterprise', 'source' => 'website')
|
||||
end
|
||||
|
||||
it 'creates conversation with labels when first message is sent' do
|
||||
conversation.destroy!
|
||||
label = create(:label, title: 'vip', account: account)
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params, labels: [label.title] },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
new_conversation = contact.conversations.last
|
||||
expect(new_conversation.label_list).to include('vip')
|
||||
end
|
||||
|
||||
it 'ignores invalid labels when creating conversation with first message' do
|
||||
conversation.destroy!
|
||||
create(:label, title: 'valid-label', account: account)
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params, labels: %w[valid-label nonexistent] },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
new_conversation = contact.conversations.last
|
||||
expect(new_conversation.label_list).to include('valid-label')
|
||||
expect(new_conversation.label_list).not_to include('nonexistent')
|
||||
end
|
||||
|
||||
it 'does not apply labels or custom_attributes when conversation already exists' do
|
||||
create(:label, title: 'vip', account: account)
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
custom_attributes = { plan: 'enterprise' }
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params,
|
||||
custom_attributes: custom_attributes, labels: ['vip'] },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
conversation.reload
|
||||
expect(conversation.custom_attributes).not_to include('plan' => 'enterprise')
|
||||
expect(conversation.label_list).not_to include('vip')
|
||||
end
|
||||
|
||||
it 'does not create the message' do
|
||||
conversation.destroy! # Test all params
|
||||
message_params = { content: "#{'h' * 150 * 1000}a", timestamp: Time.current }
|
||||
|
||||
@@ -57,6 +57,39 @@ describe AgentBotListener do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_updated' do
|
||||
let(:event_name) { 'conversation.updated' }
|
||||
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
context 'when agent bot is not configured' do
|
||||
it 'does not send webhook' do
|
||||
expect(AgentBots::WebhookJob).not_to receive(:perform_later)
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot is configured on inbox' do
|
||||
it 'sends webhook to the inbox agent bot' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(event: 'conversation_updated')).once
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is assigned to an agent bot' do
|
||||
before do
|
||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
|
||||
end
|
||||
|
||||
it 'sends webhook to the assigned agent bot' do
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(event: 'conversation_updated')).once
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webwidget_triggered' do
|
||||
let(:event_name) { 'webwidget.triggered' }
|
||||
|
||||
|
||||
@@ -57,11 +57,6 @@ RSpec.describe Attachment do
|
||||
}.to_json, headers: {})
|
||||
end
|
||||
|
||||
it 'returns external url as data and thumb urls when message is incoming' do
|
||||
external_url = instagram_message.attachments.first.external_url
|
||||
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
|
||||
end
|
||||
|
||||
it 'returns original attachment url as data url if the message is outgoing' do
|
||||
message = create(:message, :instagram_story_mention, message_type: :outgoing)
|
||||
expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url
|
||||
@@ -155,6 +150,83 @@ RSpec.describe Attachment do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data for instagram direct message attachments' do
|
||||
let(:account) { create(:account) }
|
||||
let(:instagram_inbox) do
|
||||
create(:inbox, account: account,
|
||||
channel: create(:channel_instagram_fb_page, account: account, instagram_id: 'instagram-dm-test'))
|
||||
end
|
||||
|
||||
context 'when conversation type is instagram_direct_message' do
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, inbox: instagram_inbox,
|
||||
additional_attributes: { 'type' => 'instagram_direct_message' })
|
||||
end
|
||||
let(:instagram_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :incoming) }
|
||||
|
||||
it 'uses external_url for data_url and thumb_url' do
|
||||
attachment = instagram_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg')
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:data_url]).to eq('https://instagram.com/image.jpg')
|
||||
expect(event_data[:thumb_url]).to eq('https://instagram.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation type is not instagram_direct_message' do
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, inbox: instagram_inbox,
|
||||
additional_attributes: { 'type' => 'other_type' })
|
||||
end
|
||||
let(:instagram_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :incoming) }
|
||||
|
||||
it 'uses file_url for data_url instead of external_url' do
|
||||
attachment = instagram_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg')
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:data_url]).not_to eq('https://instagram.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is outgoing on instagram DM conversation' do
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, inbox: instagram_inbox,
|
||||
additional_attributes: { 'type' => 'instagram_direct_message' })
|
||||
end
|
||||
let(:outgoing_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :outgoing) }
|
||||
|
||||
it 'does not override data_url with external_url' do
|
||||
attachment = outgoing_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg')
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:data_url]).not_to eq('https://instagram.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is Channel::Instagram (direct login)' do
|
||||
let(:instagram_channel) { create(:channel_instagram, account: account) }
|
||||
let(:direct_inbox) { instagram_channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: direct_inbox) }
|
||||
let(:incoming_message) { create(:message, account: account, inbox: direct_inbox, conversation: conversation, message_type: :incoming) }
|
||||
|
||||
it 'uses external_url for data_url and thumb_url' do
|
||||
attachment = incoming_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg')
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:data_url]).to eq('https://instagram.com/image.jpg')
|
||||
expect(event_data[:thumb_url]).to eq('https://instagram.com/image.jpg')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data for ig_reel attachments' do
|
||||
it 'returns external_url as data_url when no file is attached' do
|
||||
attachment = message.attachments.create!(
|
||||
@@ -187,6 +259,44 @@ RSpec.describe Attachment do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'set_extension' do
|
||||
it 'sets extension from filename on save' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to eq('pdf')
|
||||
end
|
||||
|
||||
it 'does not overwrite extension if already set' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file, extension: 'doc')
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to eq('doc')
|
||||
end
|
||||
|
||||
it 'handles filenames without extension' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake data'), filename: 'README', content_type: 'text/plain')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data includes extension and content_type' do
|
||||
it 'returns extension and content_type for file attachments' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:extension]).to eq('pdf')
|
||||
expect(event_data[:content_type]).to eq('application/pdf')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'file size validation' do
|
||||
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user