diff --git a/app/controllers/api/v1/profile/inbox_signatures_controller.rb b/app/controllers/api/v1/profile/inbox_signatures_controller.rb new file mode 100644 index 0000000000000..e375184f639c3 --- /dev/null +++ b/app/controllers/api/v1/profile/inbox_signatures_controller.rb @@ -0,0 +1,63 @@ +class Api::V1::Profile::InboxSignaturesController < Api::BaseController + before_action :set_user + before_action :set_inbox_signature, only: %i[show update destroy] + before_action :validate_inbox_access, only: %i[show update destroy] + + def index + if params[:account_id].present? + validate_account_access! + return if performed? + + @inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] }) + else + @inbox_signatures = @user.inbox_signatures + end + end + + def show + head :not_found and return unless @inbox_signature + end + + def update + if @inbox_signature + @inbox_signature.update!(inbox_signature_params) + else + @inbox_signature = @user.inbox_signatures.create!( + inbox_signature_params.merge(inbox_id: params[:inbox_id]) + ) + end + end + + def destroy + @inbox_signature&.destroy! + head :no_content + end + + private + + def set_user + @user = current_user + end + + def set_inbox_signature + @inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id]) + end + + def inbox_signature_params + params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator) + end + + def validate_inbox_access + inbox_id = params[:inbox_id] + return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox_id) + + head :unauthorized + end + + def validate_account_access! + account_id = params[:account_id] + return if @user.account_ids.include?(account_id.to_i) + + head :unauthorized + end +end diff --git a/app/javascript/dashboard/api/inboxSignatures.js b/app/javascript/dashboard/api/inboxSignatures.js new file mode 100644 index 0000000000000..6f915b1e7022d --- /dev/null +++ b/app/javascript/dashboard/api/inboxSignatures.js @@ -0,0 +1,25 @@ +/* global axios */ + +const API_BASE = '/api/v1/profile/inbox_signatures'; + +export default { + getAll(accountId) { + return axios.get(API_BASE, { + params: { account_id: accountId }, + }); + }, + + get(inboxId) { + return axios.get(`${API_BASE}/${inboxId}`); + }, + + upsert(inboxId, params) { + return axios.put(`${API_BASE}/${inboxId}`, { + inbox_signature: params, + }); + }, + + delete(inboxId) { + return axios.delete(`${API_BASE}/${inboxId}`); + }, +}; diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 8e24f3d501420..9aa365f9b4e79 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -4,6 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useI18n } from 'vue-i18n'; import { useWindowSize } from '@vueuse/core'; import { useUISettings } from 'dashboard/composables/useUISettings'; +import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures'; import { vOnClickOutside } from '@vueuse/components'; import { useAlert } from 'dashboard/composables'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; @@ -84,6 +85,24 @@ const uiFlags = useMapGetter('contactConversations/getUIFlags'); const messageSignature = useMapGetter('getMessageSignature'); const inboxesList = useMapGetter('inboxes/getInboxes'); +const { + fetchInboxSignatures, + getSignatureForInbox, + getSignatureSettingsForInbox, +} = useInboxSignatures(); + +fetchInboxSignatures(); + +const resolvedMessageSignature = computed(() => { + if (!targetInbox.value?.id) return messageSignature.value; + return getSignatureForInbox(targetInbox.value.id); +}); + +const resolvedSignatureSettings = computed(() => { + if (!targetInbox.value?.id) return null; + return getSignatureSettingsForInbox(targetInbox.value.id); +}); + const sendWithSignature = computed(() => fetchSignatureFlagFromUISettings(targetInbox.value?.channelType) ); @@ -307,8 +326,9 @@ useKeyboardEvents(keyboardEvents); :is-direct-uploads-enabled="directUploadsEnabled" :contact-conversations-ui-flags="uiFlags" :contacts-ui-flags="contactsUiFlags" - :message-signature="messageSignature" + :message-signature="resolvedMessageSignature" :send-with-signature="sendWithSignature" + :signature-settings="resolvedSignatureSettings" @search-contacts="onContactSearch" @reset-contact-search="resetContacts" @update-selected-contact="handleSelectedContact" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 37f96d781cf4c..06af369275bbe 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -35,6 +35,7 @@ const props = defineProps({ contactsUiFlags: { type: Object, default: null }, messageSignature: { type: String, default: '' }, sendWithSignature: { type: Boolean, default: false }, + signatureSettings: { type: Object, default: null }, formState: { type: Object, required: true }, }); @@ -131,6 +132,7 @@ const newMessagePayload = () => { directUploadsEnabled: props.isDirectUploadsEnabled, sendWithSignature: props.sendWithSignature, messageSignature: props.messageSignature, + signatureSettings: props.signatureSettings, }); }; diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js index 0bf542657b84f..a946fa325289e 100644 --- a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js +++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js @@ -132,20 +132,15 @@ export const prepareNewMessagePayload = ({ directUploadsEnabled = false, sendWithSignature = false, messageSignature = '', + signatureSettings = null, }) => { let finalMessage = message; if (sendWithSignature && messageSignature) { - const { signature_position, signature_separator } = - currentUser?.ui_settings || {}; - const signatureSettings = { - position: signature_position || 'top', - separator: signature_separator || 'blank', + const settings = signatureSettings || { + position: currentUser?.ui_settings?.signature_position || 'top', + separator: currentUser?.ui_settings?.signature_separator || 'blank', }; - finalMessage = appendSignature( - message, - messageSignature, - signatureSettings - ); + finalMessage = appendSignature(message, messageSignature, settings); } const payload = { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 86dd42276d9d4..8971dff18cfa8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -87,6 +87,9 @@ const props = defineProps({ // allowSignature is a kill switch, ensuring no signature methods // are triggered except when this flag is true allowSignature: { type: Boolean, default: false }, + // Per-inbox overrides; when empty, falls back to currentUser.ui_settings + signaturePositionOverride: { type: String, default: '' }, + signatureSeparatorOverride: { type: String, default: '' }, channelType: { type: String, default: '' }, conversationId: { type: Number, default: null }, medium: { type: String, default: '' }, @@ -322,11 +325,19 @@ const sendWithSignature = computed(() => { }); const signaturePosition = computed(() => { - return currentUser.value?.ui_settings?.signature_position || 'top'; + return ( + props.signaturePositionOverride || + currentUser.value?.ui_settings?.signature_position || + 'top' + ); }); const signatureSeparator = computed(() => { - return currentUser.value?.ui_settings?.signature_separator || 'blank'; + return ( + props.signatureSeparatorOverride || + currentUser.value?.ui_settings?.signature_separator || + 'blank' + ); }); const shouldShowSignaturePreview = computed(() => { @@ -850,6 +861,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
Global Signature
', + ui_settings: { + signature_position: 'bottom', + signature_separator: '--', + }, + }, + }, + getCurrentAccountId: { + value: 1, + }, + }), +})); + +describe('useInboxSignatures', () => { + beforeEach(() => { + vi.clearAllMocks(); + const { _resetForTesting } = useInboxSignatures(); + _resetForTesting(); + }); + + describe('fetchInboxSignatures', () => { + it('fetches and caches inbox signatures', async () => { + const mockData = [ + { + inbox_id: 1, + message_signature: 'Inbox 1 Sig
', + signature_position: 'top', + signature_separator: 'blank', + }, + { + inbox_id: 2, + message_signature: 'Inbox 2 Sig
', + signature_position: 'bottom', + signature_separator: '--', + }, + ]; + + mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: mockData }); + + const { fetchInboxSignatures, inboxSignatures, hasFetched } = + useInboxSignatures(); + await fetchInboxSignatures(); + + expect(mockInboxSignaturesAPI.getAll).toHaveBeenCalled(); + expect(inboxSignatures.value[1].message_signature).toBe( + 'Inbox 1 Sig
' + ); + expect(inboxSignatures.value[2].message_signature).toBe( + 'Inbox 2 Sig
' + ); + expect(hasFetched.value).toBe(true); + }); + }); + + describe('getSignatureForInbox', () => { + it('returns inbox-specific signature when available', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ + data: [ + { + inbox_id: 1, + message_signature: 'Inbox 1 Sig
', + signature_position: 'top', + signature_separator: 'blank', + }, + ], + }); + + const { fetchInboxSignatures, getSignatureForInbox } = + useInboxSignatures(); + await fetchInboxSignatures(); + + expect(getSignatureForInbox(1)).toBe('Inbox 1 Sig
'); + }); + + it('falls back to global signature when no inbox-specific override', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] }); + + const { fetchInboxSignatures, getSignatureForInbox } = + useInboxSignatures(); + await fetchInboxSignatures(); + + expect(getSignatureForInbox(999)).toBe('Global Signature
'); + }); + }); + + describe('getSignatureSettingsForInbox', () => { + it('returns inbox-specific settings when available', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ + data: [ + { + inbox_id: 1, + message_signature: 'Sig
', + signature_position: 'top', + signature_separator: 'blank', + }, + ], + }); + + const { fetchInboxSignatures, getSignatureSettingsForInbox } = + useInboxSignatures(); + await fetchInboxSignatures(); + + const settings = getSignatureSettingsForInbox(1); + expect(settings.position).toBe('top'); + expect(settings.separator).toBe('blank'); + }); + + it('falls back to global user settings when no inbox-specific override', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] }); + + const { fetchInboxSignatures, getSignatureSettingsForInbox } = + useInboxSignatures(); + await fetchInboxSignatures(); + + const settings = getSignatureSettingsForInbox(999); + expect(settings.position).toBe('bottom'); + expect(settings.separator).toBe('--'); + }); + }); + + describe('upsertInboxSignature', () => { + it('updates the cache after upserting', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] }); + mockInboxSignaturesAPI.upsert.mockResolvedValue({ + data: { + inbox_id: 3, + message_signature: 'New Sig
', + signature_position: 'top', + signature_separator: 'blank', + }, + }); + + const { + fetchInboxSignatures, + upsertInboxSignature, + getSignatureForInbox, + } = useInboxSignatures(); + await fetchInboxSignatures(); + + await upsertInboxSignature(3, { + message_signature: 'New Sig
', + signature_position: 'top', + signature_separator: 'blank', + }); + + expect(getSignatureForInbox(3)).toBe('New Sig
'); + }); + }); + + describe('deleteInboxSignature', () => { + it('removes from cache after deleting', async () => { + mockInboxSignaturesAPI.getAll.mockResolvedValue({ + data: [ + { + inbox_id: 1, + message_signature: 'Sig
', + signature_position: 'top', + signature_separator: 'blank', + }, + ], + }); + mockInboxSignaturesAPI.delete.mockResolvedValue({}); + + const { + fetchInboxSignatures, + deleteInboxSignature, + hasInboxSignature, + getSignatureForInbox, + } = useInboxSignatures(); + await fetchInboxSignatures(); + + expect(hasInboxSignature(1)).toBe(true); + + await deleteInboxSignature(1); + + expect(hasInboxSignature(1)).toBe(false); + // Falls back to global + expect(getSignatureForInbox(1)).toBe('Global Signature
'); + }); + }); +}); diff --git a/app/javascript/dashboard/composables/useInboxSignatures.js b/app/javascript/dashboard/composables/useInboxSignatures.js new file mode 100644 index 0000000000000..40c43b4c14170 --- /dev/null +++ b/app/javascript/dashboard/composables/useInboxSignatures.js @@ -0,0 +1,116 @@ +import { ref, computed } from 'vue'; +import { useStoreGetters } from 'dashboard/composables/store'; +import inboxSignaturesAPI from 'dashboard/api/inboxSignatures'; + +const inboxSignatures = ref({}); +const isFetching = ref(false); +const hasFetched = ref(false); + +/** + * Composable for managing per-inbox signatures. + * Provides methods to fetch, upsert, and delete inbox-specific signatures, + * with fallback to the global user signature. + */ +export function useInboxSignatures() { + const getters = useStoreGetters(); + const currentUser = computed(() => getters.getCurrentUser.value); + const currentAccountId = computed(() => getters.getCurrentAccountId.value); + const globalSignature = computed( + () => currentUser.value?.message_signature || '' + ); + + const fetchInboxSignatures = async ({ force = false } = {}) => { + if (isFetching.value) return; + if (hasFetched.value && !force) return; + + isFetching.value = true; + try { + const { data } = await inboxSignaturesAPI.getAll(currentAccountId.value); + const signaturesMap = {}; + data.forEach(sig => { + signaturesMap[sig.inbox_id] = sig; + }); + inboxSignatures.value = signaturesMap; + hasFetched.value = true; + } catch { + // Silently fail — fallback to global signature + } finally { + isFetching.value = false; + } + }; + + const upsertInboxSignature = async (inboxId, params) => { + const { data } = await inboxSignaturesAPI.upsert(inboxId, params); + inboxSignatures.value = { + ...inboxSignatures.value, + [inboxId]: data, + }; + return data; + }; + + const deleteInboxSignature = async inboxId => { + await inboxSignaturesAPI.delete(inboxId); + const updated = { ...inboxSignatures.value }; + delete updated[inboxId]; + inboxSignatures.value = updated; + }; + + /** + * Returns the inbox-specific signature if it exists, otherwise the global signature. + */ + const getSignatureForInbox = inboxId => { + const inboxSig = inboxSignatures.value[inboxId]; + return inboxSig?.message_signature || globalSignature.value; + }; + + /** + * Returns signature settings (position, separator) for the given inbox, + * falling back to the user's global settings. + */ + const getSignatureSettingsForInbox = inboxId => { + const inboxSig = inboxSignatures.value[inboxId]; + if (inboxSig) { + return { + position: inboxSig.signature_position || 'top', + separator: inboxSig.signature_separator || 'blank', + }; + } + const uiSettings = currentUser.value?.ui_settings || {}; + return { + position: uiSettings.signature_position || 'top', + separator: uiSettings.signature_separator || 'blank', + }; + }; + + /** + * Returns the raw inbox signature record if one exists. + */ + const getInboxSignature = inboxId => { + return inboxSignatures.value[inboxId] || null; + }; + + /** + * Checks if a specific inbox has a custom signature override. + */ + const hasInboxSignature = inboxId => { + return !!inboxSignatures.value[inboxId]; + }; + + return { + inboxSignatures: computed(() => inboxSignatures.value), + isFetching: computed(() => isFetching.value), + hasFetched: computed(() => hasFetched.value), + fetchInboxSignatures, + upsertInboxSignature, + deleteInboxSignature, + getSignatureForInbox, + getSignatureSettingsForInbox, + getInboxSignature, + hasInboxSignature, + _resetForTesting: () => { + inboxSignatures.value = {}; + isFetching.value = false; + hasFetched.value = false; + }, + }; +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index a38c9dc2f5e2a..91db5242a6c54 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -190,7 +190,9 @@ "ENABLE_SIGN_TOOLTIP": "Enable signature", "DISABLE_SIGN_TOOLTIP": "Disable signature", "SIGNATURE_LABEL_TOP": "↓ Signature", + "SIGNATURE_LABEL_TOP_TOOLTIP": "The signature will be sent at the top of the message", "SIGNATURE_LABEL_BOTTOM": "↑ Signature", + "SIGNATURE_LABEL_BOTTOM_TOOLTIP": "The signature will be sent at the bottom of the message", "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents", "MESSAGING_RESTRICTED": "You cannot reply to this conversation", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index ec0cccff1c54d..4893f2fa2ae7e 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -66,6 +66,13 @@ "BTN_TEXT": "Save message signature", "API_ERROR": "Couldn't save signature! Try again", "API_SUCCESS": "Signature saved successfully", + "RESET_TO_DEFAULT": "Reset to default", + "RESET_SUCCESS": "Inbox signature removed, using default signature", + "INBOX_SELECTOR": { + "LABEL": "Inbox", + "DEFAULT": "Default (all inboxes)", + "CUSTOM": "Custom" + }, "IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again", "IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature", "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json index 99fa128f714ee..a11d85947ccd9 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json @@ -185,7 +185,9 @@ "ENABLE_SIGN_TOOLTIP": "Ativar assinatura", "DISABLE_SIGN_TOOLTIP": "Desativar assinatura", "SIGNATURE_LABEL_TOP": "↓ Assinatura", + "SIGNATURE_LABEL_TOP_TOOLTIP": "A assinatura será enviada no início da mensagem", "SIGNATURE_LABEL_BOTTOM": "↑ Assinatura", + "SIGNATURE_LABEL_BOTTOM_TOOLTIP": "A assinatura será enviada no final da mensagem", "MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.", "PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes", "MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index 665081c6f84cb..ae5245969e268 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -66,6 +66,13 @@ "BTN_TEXT": "Salvar assinatura da mensagem", "API_ERROR": "Não foi possível salvar a assinatura! Tente novamente", "API_SUCCESS": "Assinatura salva com sucesso", + "RESET_TO_DEFAULT": "Restaurar para padrão", + "RESET_SUCCESS": "Assinatura da caixa de entrada removida, usando assinatura padrão", + "INBOX_SELECTOR": { + "LABEL": "Caixa de entrada", + "DEFAULT": "Padrão (todas as caixas de entrada)", + "CUSTOM": "Personalizada" + }, "IMAGE_UPLOAD_ERROR": "Não foi possível fazer o upload da imagem! Tente novamente", "IMAGE_UPLOAD_SUCCESS": "Imagem adicionada com sucesso. Por favor clique em salvar para salvar a assinatura", "IMAGE_UPLOAD_SIZE_ERROR": "O tamanho da imagem deve ser menor que {size}MB", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index cdfc9ccf96a2f..1cf5115ca0f5e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -2,6 +2,7 @@ import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; +import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures'; import { useFontSize } from 'dashboard/composables/useFontSize'; import { useBranding } from 'shared/composables/useBranding'; import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js'; @@ -46,6 +47,10 @@ export default { const { isEditorHotKeyEnabled, updateUISettings } = useUISettings(); const { currentFontSize, updateFontSize } = useFontSize(); const { replaceInstallationName } = useBranding(); + const { upsertInboxSignature, deleteInboxSignature, fetchInboxSignatures } = + useInboxSignatures(); + + fetchInboxSignatures(); return { currentFontSize, @@ -53,6 +58,8 @@ export default { isEditorHotKeyEnabled, updateUISettings, replaceInstallationName, + upsertInboxSignature, + deleteInboxSignature, }; }, data() { @@ -185,6 +192,37 @@ export default { ); } }, + async updateInboxSignature(inboxId, params, done) { + try { + await this.upsertInboxSignature(inboxId, params); + useAlert( + this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS') + ); + } catch (error) { + useAlert( + parseAPIErrorResponse(error) || + this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR') + ); + } finally { + if (done) done(); + } + }, + async handleDeleteInboxSignature(inboxId, done) { + try { + await this.deleteInboxSignature(inboxId); + useAlert( + this.$t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.RESET_SUCCESS' + ) + ); + } catch (error) { + useAlert( + this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR') + ); + } finally { + if (done) done(); + } + }, updateProfilePicture({ file, url }) { this.avatarFile = file; this.avatarUrl = url; @@ -274,6 +312,8 @@ export default { :signature-position="signaturePosition" :signature-separator="signatureSeparator" @update-signature="updateSignature" + @update-inbox-signature="updateInboxSignature" + @delete-inbox-signature="handleDeleteInboxSignature" />