From ab1ba1c4c7f2a3023232eb43079c84c63cf91f0c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 23 Jul 2025 12:22:51 +0400 Subject: [PATCH] feat: Add manual WhatsApp templates sync with UI (#12007) Fixes https://github.com/chatwoot/chatwoot/issues/9600 **Summary** - Added manual WhatsApp templates sync functionality accessible via UI - Added refresh button in both inbox settings and template picker modal - Enhanced template picker to always show for WhatsApp Cloud channels (even when empty) **Preview** CleanShot 2025-07-22 at 14 15 28@2x CleanShot 2025-07-22 at 14 19
24@2x --------- Co-authored-by: iamsivin --- .../api/v1/accounts/inboxes_controller.rb | 11 +++ app/javascript/dashboard/api/inboxes.js | 4 + .../dashboard/api/specs/inboxes.spec.js | 8 ++ app/javascript/dashboard/components/Code.vue | 11 ++- .../widgets/WootWriter/ReplyBottomPanel.vue | 4 +- .../widgets/conversation/ReplyBox.vue | 7 +- .../WhatsappTemplates/TemplatesPicker.vue | 74 +++++++++++++----- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 + .../i18n/locale/en/whatsappTemplates.json | 4 + .../inbox/settingsPage/ConfigurationPage.vue | 34 ++++++++- .../dashboard/store/modules/inboxes.js | 7 ++ .../modules/specs/inboxes/actions.spec.js | 24 ++++++ app/policies/inbox_policy.rb | 4 + config/routes.rb | 1 + .../v1/accounts/inboxes_controller_spec.rb | 76 +++++++++++++++++++ 15 files changed, 243 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index e7b3b197b..78b4b9e2f 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end + def sync_templates + unless @inbox.channel.is_a?(Channel::Whatsapp) + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } + end + + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + private def fetch_inbox diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 8c09791c8..361b9472f 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient { agent_bot: botId, }); } + + syncTemplates(inboxId) { + return axios.post(`${this.url}/${inboxId}/sync_templates`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 628ce0f34..64ba44aea 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -12,6 +12,7 @@ describe('#InboxesAPI', () => { expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot'); + expect(inboxesAPI).toHaveProperty('syncTemplates'); }); describe('API calls', () => { @@ -40,5 +41,12 @@ describe('#InboxesAPI', () => { inboxesAPI.deleteInboxAvatar(2); expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); }); + + it('#syncTemplates', () => { + inboxesAPI.syncTemplates(2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/inboxes/2/sync_templates' + ); + }); }); }); diff --git a/app/javascript/dashboard/components/Code.vue b/app/javascript/dashboard/components/Code.vue index 5823ec937..c96bdea8d 100644 --- a/app/javascript/dashboard/components/Code.vue +++ b/app/javascript/dashboard/components/Code.vue @@ -61,7 +61,9 @@ const onCopy = async e => { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index ad3edce1b..279686ced 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -94,7 +94,7 @@ export default { type: Boolean, default: true, }, - hasWhatsappTemplates: { + enableWhatsAppTemplates: { type: Boolean, default: false, }, @@ -333,7 +333,7 @@ export default { @click="toggleMessageSignature" /> +import { useAlert } from 'dashboard/composables'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; // TODO: Remove this when we support all formats const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO']; export default { + components: { + Icon, + }, props: { inboxId: { type: Number, @@ -13,6 +18,7 @@ export default { data() { return { query: '', + isRefreshing: false, }; }, computed: { @@ -37,38 +43,63 @@ export default { return template.components.find(component => component.type === 'BODY') .text; }, + async refreshTemplates() { + this.isRefreshing = true; + try { + await this.$store.dispatch('inboxes/syncTemplates', this.inboxId); + useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS')); + } catch (error) { + useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR')); + } finally { + this.isRefreshing = false; + } + }, }, }; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 14bd4c2a9..a6b93c923 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -285,6 +285,13 @@ export const actions = { throw new Error(error); } }, + syncTemplates: async (_, inboxId) => { + try { + await InboxesAPI.syncTemplates(inboxId); + } catch (error) { + throw new Error(error); + } + }, }; export const mutations = { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js index a91addaa3..edbc3f764 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js @@ -231,4 +231,28 @@ describe('#actions', () => { ).rejects.toThrow(Error); }); }); + + describe('#syncTemplates', () => { + it('sends correct API call when sync is successful', async () => { + axios.post.mockResolvedValue({ + data: { message: 'Template sync initiated successfully' }, + }); + + await actions.syncTemplates({ commit }, 123); + + expect(axios.post).toHaveBeenCalledWith( + '/api/v1/inboxes/123/sync_templates' + ); + }); + + it('throws error when API call fails', async () => { + const errorMessage = + 'Template sync is only available for WhatsApp channels'; + axios.post.mockRejectedValue(new Error(errorMessage)); + + await expect(actions.syncTemplates({ commit }, 123)).rejects.toThrow( + errorMessage + ); + }); + }); }); diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb index 9c2d3c094..8a6f81484 100644 --- a/app/policies/inbox_policy.rb +++ b/app/policies/inbox_policy.rb @@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy def avatar? @account_user.administrator? end + + def sync_templates? + @account_user.administrator? + end end diff --git a/config/routes.rb b/config/routes.rb index 681c21e44..0627c2c6d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,6 +185,7 @@ Rails.application.routes.draw do get :agent_bot, on: :member post :set_agent_bot, on: :member delete :avatar, on: :member + post :sync_templates, on: :member end resources :inbox_members, only: [:create, :show], param: :inbox_id do collection do diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 96272f9ac..cc235cada 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/sync_templates' do + 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, account: account, channel: whatsapp_channel) } + let(:non_whatsapp_inbox) { create(:inbox, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated agent' do + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated administrator' do + context 'with WhatsApp inbox' do + it 'successfully initiates template sync' do + expect(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).with(whatsapp_channel) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['message']).to eq('Template sync initiated successfully') + end + + it 'handles job errors gracefully' do + allow(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).and_raise(StandardError, 'Job failed') + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:internal_server_error) + json_response = response.parsed_body + expect(json_response['error']).to eq('Job failed') + end + end + + context 'with non-WhatsApp inbox' do + it 'returns unprocessable entity error' do + post "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/sync_templates", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq('Template sync is only available for WhatsApp channels') + end + end + + context 'with non-existent inbox' do + it 'returns not found error' do + post "/api/v1/accounts/#{account.id}/inboxes/999999/sync_templates", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + end end