mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 04:57:51 +00:00 
			
		
		
		
	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** <img width="1456" height="798" alt="CleanShot 2025-07-22 at 14 15 28@2x" src="https://github.com/user-attachments/assets/8a04ff26-61fa-42ee-a1b8-5e06433ae6e0" /> <img width="2376" height="1574" alt="CleanShot 2025-07-22 at 14 19 24@2x" src="https://github.com/user-attachments/assets/1c772034-04ff-484d-88dd-ca8123e93065" /> --------- Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
		@@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
 | 
				
			|||||||
    render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
 | 
					    render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
 | 
				
			||||||
  end
 | 
					  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
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def fetch_inbox
 | 
					  def fetch_inbox
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
 | 
				
			|||||||
      agent_bot: botId,
 | 
					      agent_bot: botId,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  syncTemplates(inboxId) {
 | 
				
			||||||
 | 
					    return axios.post(`${this.url}/${inboxId}/sync_templates`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new Inboxes();
 | 
					export default new Inboxes();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
 | 
				
			|||||||
    expect(inboxesAPI).toHaveProperty('getCampaigns');
 | 
					    expect(inboxesAPI).toHaveProperty('getCampaigns');
 | 
				
			||||||
    expect(inboxesAPI).toHaveProperty('getAgentBot');
 | 
					    expect(inboxesAPI).toHaveProperty('getAgentBot');
 | 
				
			||||||
    expect(inboxesAPI).toHaveProperty('setAgentBot');
 | 
					    expect(inboxesAPI).toHaveProperty('setAgentBot');
 | 
				
			||||||
 | 
					    expect(inboxesAPI).toHaveProperty('syncTemplates');
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('API calls', () => {
 | 
					  describe('API calls', () => {
 | 
				
			||||||
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
 | 
				
			|||||||
      inboxesAPI.deleteInboxAvatar(2);
 | 
					      inboxesAPI.deleteInboxAvatar(2);
 | 
				
			||||||
      expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
 | 
					      expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('#syncTemplates', () => {
 | 
				
			||||||
 | 
					      inboxesAPI.syncTemplates(2);
 | 
				
			||||||
 | 
					      expect(axiosMock.post).toHaveBeenCalledWith(
 | 
				
			||||||
 | 
					        '/api/v1/inboxes/2/sync_templates'
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -61,7 +61,9 @@ const onCopy = async e => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="relative text-left">
 | 
					  <div class="relative text-left">
 | 
				
			||||||
    <div class="top-1.5 absolute right-1.5 flex items-center gap-1">
 | 
					    <div
 | 
				
			||||||
 | 
					      class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex items-center gap-1"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <form
 | 
					      <form
 | 
				
			||||||
        v-if="enableCodePen"
 | 
					        v-if="enableCodePen"
 | 
				
			||||||
        class="flex items-center"
 | 
					        class="flex items-center"
 | 
				
			||||||
@@ -86,6 +88,11 @@ const onCopy = async e => {
 | 
				
			|||||||
        @click="onCopy"
 | 
					        @click="onCopy"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
 | 
					    <highlightjs
 | 
				
			||||||
 | 
					      v-if="script"
 | 
				
			||||||
 | 
					      :language="lang"
 | 
				
			||||||
 | 
					      :code="scrubbedScript"
 | 
				
			||||||
 | 
					      class="[&_code]:ltr:!pr-16 [&_code]:rtl:!pl-16 [&_code]:truncate [&_code]:text-start"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -94,7 +94,7 @@ export default {
 | 
				
			|||||||
      type: Boolean,
 | 
					      type: Boolean,
 | 
				
			||||||
      default: true,
 | 
					      default: true,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    hasWhatsappTemplates: {
 | 
					    enableWhatsAppTemplates: {
 | 
				
			||||||
      type: Boolean,
 | 
					      type: Boolean,
 | 
				
			||||||
      default: false,
 | 
					      default: false,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
@@ -333,7 +333,7 @@ export default {
 | 
				
			|||||||
        @click="toggleMessageSignature"
 | 
					        @click="toggleMessageSignature"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <NextButton
 | 
					      <NextButton
 | 
				
			||||||
        v-if="hasWhatsappTemplates"
 | 
					        v-if="enableWhatsAppTemplates"
 | 
				
			||||||
        v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
 | 
					        v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
 | 
				
			||||||
        icon="i-ph-whatsapp-logo"
 | 
					        icon="i-ph-whatsapp-logo"
 | 
				
			||||||
        slate
 | 
					        slate
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -184,9 +184,8 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    hasWhatsappTemplates() {
 | 
					    showWhatsappTemplates() {
 | 
				
			||||||
      return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
 | 
					      return this.isAWhatsAppCloudChannel && !this.isPrivate;
 | 
				
			||||||
        .length;
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    isPrivate() {
 | 
					    isPrivate() {
 | 
				
			||||||
      if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
 | 
					      if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
 | 
				
			||||||
@@ -1212,7 +1211,7 @@ export default {
 | 
				
			|||||||
    <ReplyBottomPanel
 | 
					    <ReplyBottomPanel
 | 
				
			||||||
      :conversation-id="conversationId"
 | 
					      :conversation-id="conversationId"
 | 
				
			||||||
      :enable-multiple-file-upload="enableMultipleFileUpload"
 | 
					      :enable-multiple-file-upload="enableMultipleFileUpload"
 | 
				
			||||||
      :has-whatsapp-templates="hasWhatsappTemplates"
 | 
					      :enable-whats-app-templates="showWhatsappTemplates"
 | 
				
			||||||
      :inbox="inbox"
 | 
					      :inbox="inbox"
 | 
				
			||||||
      :is-on-private-note="isOnPrivateNote"
 | 
					      :is-on-private-note="isOnPrivateNote"
 | 
				
			||||||
      :is-recording-audio="isRecordingAudio"
 | 
					      :is-recording-audio="isRecordingAudio"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,13 @@
 | 
				
			|||||||
<script>
 | 
					<script>
 | 
				
			||||||
 | 
					import { useAlert } from 'dashboard/composables';
 | 
				
			||||||
 | 
					import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
				
			||||||
// TODO: Remove this when we support all formats
 | 
					// TODO: Remove this when we support all formats
 | 
				
			||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
 | 
					const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
 | 
					  components: {
 | 
				
			||||||
 | 
					    Icon,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  props: {
 | 
					  props: {
 | 
				
			||||||
    inboxId: {
 | 
					    inboxId: {
 | 
				
			||||||
      type: Number,
 | 
					      type: Number,
 | 
				
			||||||
@@ -13,6 +18,7 @@ export default {
 | 
				
			|||||||
  data() {
 | 
					  data() {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      query: '',
 | 
					      query: '',
 | 
				
			||||||
 | 
					      isRefreshing: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  computed: {
 | 
					  computed: {
 | 
				
			||||||
@@ -37,14 +43,26 @@ export default {
 | 
				
			|||||||
      return template.components.find(component => component.type === 'BODY')
 | 
					      return template.components.find(component => component.type === 'BODY')
 | 
				
			||||||
        .text;
 | 
					        .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;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="w-full">
 | 
					  <div class="w-full">
 | 
				
			||||||
 | 
					    <div class="flex gap-2 mb-2.5">
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
      class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
 | 
					        class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <fluent-icon icon="search" class="text-n-slate-12" size="16" />
 | 
					        <fluent-icon icon="search" class="text-n-slate-12" size="16" />
 | 
				
			||||||
        <input
 | 
					        <input
 | 
				
			||||||
@@ -54,21 +72,34 @@ export default {
 | 
				
			|||||||
          class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
 | 
					          class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        :disabled="isRefreshing"
 | 
				
			||||||
 | 
					        class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
 | 
				
			||||||
 | 
					        :title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
 | 
				
			||||||
 | 
					        @click="refreshTemplates"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Icon
 | 
				
			||||||
 | 
					          icon="i-lucide-refresh-ccw"
 | 
				
			||||||
 | 
					          class="text-n-slate-12 size-4"
 | 
				
			||||||
 | 
					          :class="{ 'animate-spin': isRefreshing }"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
    <div
 | 
					    <div
 | 
				
			||||||
      class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
 | 
					      class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
 | 
					      <div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          class="rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
 | 
					          class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
 | 
				
			||||||
          @click="$emit('onSelect', template)"
 | 
					          @click="$emit('onSelect', template)"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <div class="flex items-center justify-between mb-2.5">
 | 
					            <div class="flex justify-between items-center mb-2.5">
 | 
				
			||||||
              <p class="text-sm">
 | 
					              <p class="text-sm">
 | 
				
			||||||
                {{ template.name }}
 | 
					                {{ template.name }}
 | 
				
			||||||
              </p>
 | 
					              </p>
 | 
				
			||||||
              <span
 | 
					              <span
 | 
				
			||||||
                class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default text-n-slate-12"
 | 
					                class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
 | 
					                {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
 | 
				
			||||||
                {{ template.language }}
 | 
					                {{ template.language }}
 | 
				
			||||||
@@ -94,12 +125,19 @@ export default {
 | 
				
			|||||||
          class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
 | 
					          class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div v-if="!filteredTemplateMessages.length">
 | 
					      <div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
 | 
				
			||||||
 | 
					        <div v-if="query && whatsAppTemplateMessages.length">
 | 
				
			||||||
          <p>
 | 
					          <p>
 | 
				
			||||||
            {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
 | 
					            {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
 | 
				
			||||||
            <strong>{{ query }}</strong>
 | 
					            <strong>{{ query }}</strong>
 | 
				
			||||||
          </p>
 | 
					          </p>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
 | 
				
			||||||
 | 
					          <p class="text-n-slate-11">
 | 
				
			||||||
 | 
					            {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -600,6 +600,10 @@
 | 
				
			|||||||
      "WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
 | 
					      "WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
 | 
				
			||||||
      "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
 | 
					      "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
 | 
				
			||||||
      "WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
 | 
					      "WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
 | 
				
			||||||
 | 
					      "WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
 | 
				
			||||||
 | 
					      "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
 | 
				
			||||||
 | 
					      "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
 | 
				
			||||||
 | 
					      "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
 | 
				
			||||||
      "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
 | 
					      "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "HELP_CENTER": {
 | 
					    "HELP_CENTER": {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,6 +8,10 @@
 | 
				
			|||||||
        "PICKER": {
 | 
					        "PICKER": {
 | 
				
			||||||
            "SEARCH_PLACEHOLDER": "Search Templates",
 | 
					            "SEARCH_PLACEHOLDER": "Search Templates",
 | 
				
			||||||
            "NO_TEMPLATES_FOUND": "No templates found for",
 | 
					            "NO_TEMPLATES_FOUND": "No templates found for",
 | 
				
			||||||
 | 
					            "NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
 | 
				
			||||||
 | 
					            "REFRESH_BUTTON": "Refresh templates",
 | 
				
			||||||
 | 
					            "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
 | 
				
			||||||
 | 
					            "REFRESH_ERROR": "Failed to refresh templates. Please try again.",
 | 
				
			||||||
            "LABELS": {
 | 
					            "LABELS": {
 | 
				
			||||||
                "LANGUAGE": "Language",
 | 
					                "LANGUAGE": "Language",
 | 
				
			||||||
                "TEMPLATE_BODY": "Template Body",
 | 
					                "TEMPLATE_BODY": "Template Body",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,6 +29,7 @@ export default {
 | 
				
			|||||||
    return {
 | 
					    return {
 | 
				
			||||||
      hmacMandatory: false,
 | 
					      hmacMandatory: false,
 | 
				
			||||||
      whatsAppInboxAPIKey: '',
 | 
					      whatsAppInboxAPIKey: '',
 | 
				
			||||||
 | 
					      isSyncingTemplates: false,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  validations: {
 | 
					  validations: {
 | 
				
			||||||
@@ -83,6 +84,19 @@ export default {
 | 
				
			|||||||
        useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
 | 
					        useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    async syncTemplates() {
 | 
				
			||||||
 | 
					      this.isSyncingTemplates = true;
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await this.$store.dispatch('inboxes/syncTemplates', this.inbox.id);
 | 
				
			||||||
 | 
					        useAlert(
 | 
				
			||||||
 | 
					          this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUCCESS')
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        this.isSyncingTemplates = false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -137,7 +151,7 @@ export default {
 | 
				
			|||||||
        :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
 | 
					        :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
 | 
				
			||||||
        :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
 | 
					        :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex items-center gap-2">
 | 
					        <div class="flex gap-2 items-center">
 | 
				
			||||||
          <input
 | 
					          <input
 | 
				
			||||||
            id="hmacMandatory"
 | 
					            id="hmacMandatory"
 | 
				
			||||||
            v-model="hmacMandatory"
 | 
					            v-model="hmacMandatory"
 | 
				
			||||||
@@ -169,7 +183,7 @@ export default {
 | 
				
			|||||||
      :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
 | 
					      :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
 | 
				
			||||||
      :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
 | 
					      :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div class="flex items-center gap-2">
 | 
					      <div class="flex gap-2 items-center">
 | 
				
			||||||
        <input
 | 
					        <input
 | 
				
			||||||
          id="hmacMandatory"
 | 
					          id="hmacMandatory"
 | 
				
			||||||
          v-model="hmacMandatory"
 | 
					          v-model="hmacMandatory"
 | 
				
			||||||
@@ -215,12 +229,12 @@ export default {
 | 
				
			|||||||
        "
 | 
					        "
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div
 | 
					        <div
 | 
				
			||||||
          class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
 | 
					          class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <woot-input
 | 
					          <woot-input
 | 
				
			||||||
            v-model="whatsAppInboxAPIKey"
 | 
					            v-model="whatsAppInboxAPIKey"
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
            class="flex-1 mr-2"
 | 
					            class="flex-1 mr-2 [&>input]:!mb-0"
 | 
				
			||||||
            :placeholder="
 | 
					            :placeholder="
 | 
				
			||||||
              $t(
 | 
					              $t(
 | 
				
			||||||
                'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
 | 
					                'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
 | 
				
			||||||
@@ -235,6 +249,18 @@ export default {
 | 
				
			|||||||
          </NextButton>
 | 
					          </NextButton>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </SettingsSection>
 | 
					      </SettingsSection>
 | 
				
			||||||
 | 
					      <SettingsSection
 | 
				
			||||||
 | 
					        :title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
 | 
				
			||||||
 | 
					        :sub-title="
 | 
				
			||||||
 | 
					          $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
 | 
				
			||||||
 | 
					        "
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="flex justify-start items-center mt-2">
 | 
				
			||||||
 | 
					          <NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
 | 
				
			||||||
 | 
					            {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
 | 
				
			||||||
 | 
					          </NextButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </SettingsSection>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -285,6 +285,13 @@ export const actions = {
 | 
				
			|||||||
      throw new Error(error);
 | 
					      throw new Error(error);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  syncTemplates: async (_, inboxId) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await InboxesAPI.syncTemplates(inboxId);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      throw new Error(error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const mutations = {
 | 
					export const mutations = {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -231,4 +231,28 @@ describe('#actions', () => {
 | 
				
			|||||||
      ).rejects.toThrow(Error);
 | 
					      ).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
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy
 | 
				
			|||||||
  def avatar?
 | 
					  def avatar?
 | 
				
			||||||
    @account_user.administrator?
 | 
					    @account_user.administrator?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def sync_templates?
 | 
				
			||||||
 | 
					    @account_user.administrator?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -185,6 +185,7 @@ Rails.application.routes.draw do
 | 
				
			|||||||
            get :agent_bot, on: :member
 | 
					            get :agent_bot, on: :member
 | 
				
			||||||
            post :set_agent_bot, on: :member
 | 
					            post :set_agent_bot, on: :member
 | 
				
			||||||
            delete :avatar, on: :member
 | 
					            delete :avatar, on: :member
 | 
				
			||||||
 | 
					            post :sync_templates, on: :member
 | 
				
			||||||
          end
 | 
					          end
 | 
				
			||||||
          resources :inbox_members, only: [:create, :show], param: :inbox_id do
 | 
					          resources :inbox_members, only: [:create, :show], param: :inbox_id do
 | 
				
			||||||
            collection do
 | 
					            collection do
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do
 | 
				
			|||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user