diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -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 diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index 1060b285d..05e81ff9f 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -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); }; diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index b595fdf00..713de56f1 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -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, diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js index 0216caed9..b95b2f659 100644 --- a/app/javascript/widget/api/specs/endPoints.spec.js +++ b/app/javascript/widget/api/specs/endPoints.spec.js @@ -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({ diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 0044ccdfc..07aa6a0e3 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -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 { diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue index a920c508d..25e119140 100755 --- a/app/javascript/widget/components/UserMessage.vue +++ b/app/javascript/widget/components/UserMessage.vue @@ -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; diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js index 407d90980..bc19c456b 100644 --- a/app/javascript/widget/composables/useDarkMode.js +++ b/app/javascript/widget/composables/useDarkMode.js @@ -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, diff --git a/app/javascript/widget/store/modules/conversation/actions.js b/app/javascript/widget/store/modules/conversation/actions.js index 6d4c26610..aa08af615 100644 --- a/app/javascript/widget/store/modules/conversation/actions.js +++ b/app/javascript/widget/store/modules/conversation/actions.js @@ -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) { diff --git a/app/javascript/widget/store/modules/conversation/getters.js b/app/javascript/widget/store/modules/conversation/getters.js index 151694b86..ef1cd1cc3 100644 --- a/app/javascript/widget/store/modules/conversation/getters.js +++ b/app/javascript/widget/store/modules/conversation/getters.js @@ -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; diff --git a/app/javascript/widget/store/modules/conversation/index.js b/app/javascript/widget/store/modules/conversation/index.js index 9869b6a87..077a16a04 100755 --- a/app/javascript/widget/store/modules/conversation/index.js +++ b/app/javascript/widget/store/modules/conversation/index.js @@ -14,6 +14,8 @@ const state = { isCreating: false, }, lastMessageId: null, + pendingCustomAttributes: {}, + pendingLabels: [], }; export default { diff --git a/app/javascript/widget/store/modules/conversation/mutations.js b/app/javascript/widget/store/modules/conversation/mutations.js index 781dcd67b..1c23e008d 100644 --- a/app/javascript/widget/store/modules/conversation/mutations.js +++ b/app/javascript/widget/store/modules/conversation/mutations.js @@ -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 = []; + }, }; diff --git a/app/javascript/widget/store/modules/conversationLabels.js b/app/javascript/widget/store/modules/conversationLabels.js index 3ae600082..ec3fc9fa4 100644 --- a/app/javascript/widget/store/modules/conversationLabels.js +++ b/app/javascript/widget/store/modules/conversationLabels.js @@ -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) { diff --git a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js index 39b8afe1a..2ab17cb37 100644 --- a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js @@ -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 }); diff --git a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js index bc6b6bd29..0894c5b52 100644 --- a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js +++ b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js @@ -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([]); }); }); diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index bc4cf775c..9289d0546 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { diff --git a/app/listeners/agent_bot_listener.rb b/app/listeners/agent_bot_listener.rb index ccac8005f..8fdf964fb 100644 --- a/app/listeners/agent_bot_listener.rb +++ b/app/listeners/agent_bot_listener.rb @@ -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 diff --git a/app/models/attachment.rb b/app/models/attachment.rb index a1cc062a0..769e134be 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -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 diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 52d8e2789..78418881a 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -58,9 +58,9 @@ By default, it renders: } - +
-
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> <% end %> diff --git a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb index 305699dcb..23a8cc5de 100644 --- a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb @@ -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? diff --git a/spec/controllers/api/v1/widget/messages_controller_spec.rb b/spec/controllers/api/v1/widget/messages_controller_spec.rb index 11c916514..902b4ec01 100644 --- a/spec/controllers/api/v1/widget/messages_controller_spec.rb +++ b/spec/controllers/api/v1/widget/messages_controller_spec.rb @@ -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 } diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb index 56c478a1b..24af37383 100644 --- a/spec/listeners/agent_bot_listener_spec.rb +++ b/spec/listeners/agent_bot_listener_spec.rb @@ -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' } diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index a17839174..82fb51bf1 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -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) }