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);
@@ -864,6 +876,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@@ -899,7 +912,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @import '@chatwoot/prosemirror-schema/src/styles/base.scss'; .signature-preview { - @apply px-1 py-1 text-n-slate-10 text-sm pointer-events-none select-none opacity-70; + @apply px-1 py-1 text-n-slate-10 text-sm select-none opacity-70 cursor-default; &--top { @apply border-b border-n-weak pb-1; diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 442e2648b65f9..77a3198e6df37 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -3,6 +3,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue'; import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; +import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures'; import { useTrack } from 'dashboard/composables'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; @@ -97,6 +98,14 @@ export default { fetchQuotedReplyFlagFromUISettings, } = useUISettings(); + const { + fetchInboxSignatures, + getSignatureForInbox, + getSignatureSettingsForInbox, + } = useInboxSignatures(); + + fetchInboxSignatures(); + const { formatMessage } = useMessageFormatter(); const replyEditor = useTemplateRef('replyEditor'); @@ -109,6 +118,8 @@ export default { fetchSignatureFlagFromUISettings, setQuotedReplyFlagForInbox, fetchQuotedReplyFlagFromUISettings, + getSignatureForInbox, + getSignatureSettingsForInbox, replyEditor, copilot, shortcutKey, @@ -147,7 +158,6 @@ export default { computed: { ...mapGetters({ currentChat: 'getSelectedChat', - messageSignature: 'getMessageSignature', currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', @@ -344,6 +354,9 @@ export default { isSignatureEnabledForInbox() { return !this.isPrivate && this.sendWithSignature; }, + messageSignature() { + return this.getSignatureForInbox(this.inboxId); + }, isSignatureAvailable() { return !!this.messageSignature; }, @@ -449,10 +462,10 @@ export default { ); }, signaturePosition() { - return this.currentUser?.ui_settings?.signature_position || 'top'; + return this.getSignatureSettingsForInbox(this.inboxId).position; }, signatureSeparator() { - return this.currentUser?.ui_settings?.signature_separator || 'blank'; + return this.getSignatureSettingsForInbox(this.inboxId).separator; }, formattedSignature() { if (!this.messageSignature) return ''; @@ -703,11 +716,9 @@ export default { if (!this.sendWithSignature || !this.messageSignature) { return message; } - const { signature_position, signature_separator } = - this.currentUser?.ui_settings || {}; const signatureSettings = { - position: signature_position || 'top', - separator: signature_separator || 'blank', + position: this.signaturePosition, + separator: this.signatureSeparator, }; return appendSignature(message, this.messageSignature, signatureSettings); }, @@ -1354,6 +1365,8 @@ export default { :variables="messageVariables" :signature="messageSignature" allow-signature + :signature-position-override="signaturePosition" + :signature-separator-override="signatureSeparator" :channel-type="channelType" :medium="inbox.medium" @typing-off="onTypingOff" diff --git a/app/javascript/dashboard/composables/spec/useInboxSignatures.spec.js b/app/javascript/dashboard/composables/spec/useInboxSignatures.spec.js new file mode 100644 index 0000000000000..d09e8c795ea53 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useInboxSignatures.spec.js @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useInboxSignatures } from '../useInboxSignatures'; + +const mockInboxSignaturesAPI = vi.hoisted(() => ({ + getAll: vi.fn(), + get: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), +})); + +vi.mock('dashboard/api/inboxSignatures', () => ({ + default: mockInboxSignaturesAPI, +})); + +vi.mock('dashboard/composables/store', () => ({ + useStoreGetters: () => ({ + getCurrentUser: { + value: { + message_signature: '

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" /> ({ + id: INBOX_OPTION_DEFAULT, + name: t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.DEFAULT' + ), +})); + +const inboxOptions = computed(() => { + const customLabel = t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.CUSTOM' + ); + const items = inboxes.value.map(inbox => ({ + ...inbox, + icon: hasInboxSignature(inbox.id) ? 'i-lucide-pen-line' : undefined, + name: hasInboxSignature(inbox.id) + ? `${inbox.name} (${customLabel})` + : inbox.name, + })); + return [defaultOption.value, ...items]; +}); + +const isDefaultSelected = computed( + () => selectedInboxId.value === INBOX_OPTION_DEFAULT +); + +// Initialize the selected inbox object +selectedInbox.value = defaultOption.value; + +const currentInboxHasOverride = computed(() => { + if (isDefaultSelected.value) return false; + return hasInboxSignature(selectedInboxId.value); +}); const positionOptions = computed(() => [ { @@ -83,10 +132,40 @@ const messagePreview = computed(() => { return `${sampleMessage.value}${separator}${formattedSignature.value}`; }); +const loadSignatureForSelection = () => { + if (isDefaultSelected.value) { + signature.value = props.messageSignature; + signaturePosition.value = props.signaturePosition; + signatureSeparator.value = props.signatureSeparator; + return; + } + + const inboxSig = getInboxSignature(selectedInboxId.value); + if (inboxSig) { + signature.value = inboxSig.message_signature; + signaturePosition.value = inboxSig.signature_position || 'top'; + signatureSeparator.value = inboxSig.signature_separator || 'blank'; + } else { + // Pre-fill with global signature for convenience + signature.value = props.messageSignature; + signaturePosition.value = props.signaturePosition; + signatureSeparator.value = props.signatureSeparator; + } +}; + +// Keep selectedInboxId in sync with the SingleSelect object model +watch(selectedInbox, newVal => { + selectedInboxId.value = newVal?.id ?? INBOX_OPTION_DEFAULT; + loadSignatureForSelection(); +}); + +// Fetch inbox signatures on mount, then reload form values +fetchInboxSignatures().then(() => loadSignatureForSelection()); + watch( () => props.signaturePosition, newValue => { - signaturePosition.value = newValue; + if (isDefaultSelected.value) signaturePosition.value = newValue; }, { immediate: true } ); @@ -94,7 +173,7 @@ watch( watch( () => props.signatureSeparator, newValue => { - signatureSeparator.value = newValue; + if (isDefaultSelected.value) signatureSeparator.value = newValue; }, { immediate: true } ); @@ -102,33 +181,77 @@ watch( watch( () => props.messageSignature ?? '', newValue => { - signature.value = newValue; + if (isDefaultSelected.value) signature.value = newValue; }, { immediate: true } ); const updateSignature = () => { - emit( - 'updateSignature', - signature.value, - signaturePosition.value, - signatureSeparator.value - ); + if (isDefaultSelected.value) { + emit( + 'updateSignature', + signature.value, + signaturePosition.value, + signatureSeparator.value + ); + } else { + isSaving.value = true; + emit( + 'updateInboxSignature', + selectedInboxId.value, + { + message_signature: signature.value, + signature_position: signaturePosition.value, + signature_separator: signatureSeparator.value, + }, + () => { + isSaving.value = false; + } + ); + } }; const handlePositionChange = value => { signaturePosition.value = value; - emit('updateSignature', signature.value, value, signatureSeparator.value); }; const handleSeparatorChange = value => { signatureSeparator.value = value; - emit('updateSignature', signature.value, signaturePosition.value, value); +}; + +const resetToDefault = () => { + isSaving.value = true; + emit('deleteInboxSignature', selectedInboxId.value, () => { + loadSignatureForSelection(); + isSaving.value = false; + }); }; diff --git a/app/models/inbox.rb b/app/models/inbox.rb index ce45a8987645f..c751becf5c824 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -64,6 +64,7 @@ class Inbox < ApplicationRecord has_many :contacts, through: :contact_inboxes has_many :inbox_members, dependent: :destroy_async + has_many :inbox_signatures, dependent: :destroy_async has_many :members, through: :inbox_members, source: :user has_many :conversations, dependent: :destroy_async has_many :messages, dependent: :destroy_async diff --git a/app/models/inbox_signature.rb b/app/models/inbox_signature.rb new file mode 100644 index 0000000000000..911662d8625f0 --- /dev/null +++ b/app/models/inbox_signature.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: inbox_signatures +# +# id :bigint not null, primary key +# message_signature :text not null +# signature_position :string default("top"), not null +# signature_separator :string default("blank"), not null +# created_at :datetime not null +# updated_at :datetime not null +# inbox_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_inbox_signatures_on_inbox_id (inbox_id) +# index_inbox_signatures_on_user_id_and_inbox_id (user_id,inbox_id) UNIQUE +# + +class InboxSignature < ApplicationRecord + belongs_to :user + belongs_to :inbox + + validates :message_signature, presence: true + validates :user_id, uniqueness: { scope: :inbox_id } + validates :signature_position, inclusion: { in: %w[top bottom] } + validates :signature_separator, inclusion: { in: %w[blank --] } +end diff --git a/app/models/user.rb b/app/models/user.rb index 156452322af86..b2808fc372ee6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -96,6 +96,7 @@ class User < ApplicationRecord has_many :participating_conversations, through: :conversation_participants, source: :conversation has_many :inbox_members, dependent: :destroy_async + has_many :inbox_signatures, dependent: :destroy_async has_many :inboxes, through: :inbox_members, source: :inbox has_many :messages, as: :sender, dependent: :nullify has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify diff --git a/app/views/api/v1/profile/inbox_signatures/_inbox_signature.json.jbuilder b/app/views/api/v1/profile/inbox_signatures/_inbox_signature.json.jbuilder new file mode 100644 index 0000000000000..f9d5ce73418b2 --- /dev/null +++ b/app/views/api/v1/profile/inbox_signatures/_inbox_signature.json.jbuilder @@ -0,0 +1,5 @@ +json.id inbox_signature.id +json.inbox_id inbox_signature.inbox_id +json.message_signature inbox_signature.message_signature +json.signature_position inbox_signature.signature_position +json.signature_separator inbox_signature.signature_separator diff --git a/app/views/api/v1/profile/inbox_signatures/index.json.jbuilder b/app/views/api/v1/profile/inbox_signatures/index.json.jbuilder new file mode 100644 index 0000000000000..e004d9b2b5908 --- /dev/null +++ b/app/views/api/v1/profile/inbox_signatures/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @inbox_signatures do |inbox_signature| + json.partial! 'inbox_signature', inbox_signature: inbox_signature +end diff --git a/app/views/api/v1/profile/inbox_signatures/show.json.jbuilder b/app/views/api/v1/profile/inbox_signatures/show.json.jbuilder new file mode 100644 index 0000000000000..1ed2cb43b1f70 --- /dev/null +++ b/app/views/api/v1/profile/inbox_signatures/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'inbox_signature', inbox_signature: @inbox_signature diff --git a/app/views/api/v1/profile/inbox_signatures/update.json.jbuilder b/app/views/api/v1/profile/inbox_signatures/update.json.jbuilder new file mode 100644 index 0000000000000..1ed2cb43b1f70 --- /dev/null +++ b/app/views/api/v1/profile/inbox_signatures/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'inbox_signature', inbox_signature: @inbox_signature diff --git a/config/routes.rb b/config/routes.rb index ab665962482e4..eb0f58aa2fd33 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -390,6 +390,7 @@ post :verify post :backup_codes end + resources :inbox_signatures, only: %i[index show update destroy], param: :inbox_id end end diff --git a/db/migrate/20260226173647_create_inbox_signatures.rb b/db/migrate/20260226173647_create_inbox_signatures.rb new file mode 100644 index 0000000000000..08ce98bd8a3a6 --- /dev/null +++ b/db/migrate/20260226173647_create_inbox_signatures.rb @@ -0,0 +1,14 @@ +class CreateInboxSignatures < ActiveRecord::Migration[7.1] + def change + create_table :inbox_signatures do |t| + t.references :user, null: false, index: false + t.references :inbox, null: false, index: false + t.text :message_signature, null: false + t.string :signature_position, default: 'top', null: false + t.string :signature_separator, default: 'blank', null: false + t.timestamps + end + + add_index :inbox_signatures, %i[user_id inbox_id], unique: true + end +end diff --git a/db/migrate/20260226194714_add_inbox_id_index_to_inbox_signatures.rb b/db/migrate/20260226194714_add_inbox_id_index_to_inbox_signatures.rb new file mode 100644 index 0000000000000..4b0eba2ec345e --- /dev/null +++ b/db/migrate/20260226194714_add_inbox_id_index_to_inbox_signatures.rb @@ -0,0 +1,5 @@ +class AddInboxIdIndexToInboxSignatures < ActiveRecord::Migration[7.1] + def change + add_index :inbox_signatures, :inbox_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 1cb3389463619..0994c7b7d9377 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_02_01_162122) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_194714) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -857,6 +857,18 @@ t.index ["inbox_id"], name: "index_inbox_members_on_inbox_id" end + create_table "inbox_signatures", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "inbox_id", null: false + t.text "message_signature", null: false + t.string "signature_position", default: "top", null: false + t.string "signature_separator", default: "blank", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["inbox_id"], name: "index_inbox_signatures_on_inbox_id" + t.index ["user_id", "inbox_id"], name: "index_inbox_signatures_on_user_id_and_inbox_id", unique: true + end + create_table "inboxes", id: :serial, force: :cascade do |t| t.integer "channel_id", null: false t.integer "account_id", null: false diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb index 2daf0615c7ac1..d7265afe05e1a 100644 --- a/lib/captain/reply_suggestion_service.rb +++ b/lib/captain/reply_suggestion_service.rb @@ -22,10 +22,15 @@ def prompt_variables { 'channel_type' => conversation.inbox.channel_type, 'agent_name' => user.name, - 'agent_signature' => user.message_signature.presence + 'agent_signature' => resolved_signature } end + def resolved_signature + inbox_signature = user.inbox_signatures.find_by(inbox_id: conversation.inbox_id) + inbox_signature&.message_signature || user.message_signature.presence + end + def render_liquid_template(template_content, variables = {}) Liquid::Template.parse(template_content).render(variables) end diff --git a/spec/controllers/api/v1/profile/inbox_signatures_controller_spec.rb b/spec/controllers/api/v1/profile/inbox_signatures_controller_spec.rb new file mode 100644 index 0000000000000..a2eacaac5c0f3 --- /dev/null +++ b/spec/controllers/api/v1/profile/inbox_signatures_controller_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Profile Inbox Signatures API', type: :request do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + + before do + create(:inbox_member, user: agent, inbox: inbox) + end + + describe 'GET /api/v1/profile/inbox_signatures' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get '/api/v1/profile/inbox_signatures' + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns all inbox signatures for the current account' do + inbox_signature = create(:inbox_signature, user: agent, inbox: inbox) + + get '/api/v1/profile/inbox_signatures', + params: { account_id: account.id }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response.length).to eq(1) + expect(json_response[0]['inbox_id']).to eq(inbox.id) + expect(json_response[0]['message_signature']).to eq(inbox_signature.message_signature) + end + + it 'does not return signatures for inboxes from other accounts' do + other_account = create(:account) + other_inbox = create(:inbox, account: other_account) + create(:inbox_signature, user: agent, inbox: other_inbox) + + get '/api/v1/profile/inbox_signatures', + params: { account_id: account.id }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response.length).to eq(0) + end + + it 'returns unauthorized when filtering by an account the user does not belong to' do + other_account = create(:account) + + get '/api/v1/profile/inbox_signatures', + params: { account_id: other_account.id }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/profile/inbox_signatures/:inbox_id' do + context 'when the inbox signature exists' do + it 'returns the inbox signature' do + inbox_signature = create(:inbox_signature, user: agent, inbox: inbox) + + get "/api/v1/profile/inbox_signatures/#{inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['inbox_id']).to eq(inbox.id) + expect(json_response['message_signature']).to eq(inbox_signature.message_signature) + expect(json_response['signature_position']).to eq('top') + expect(json_response['signature_separator']).to eq('blank') + end + end + + context 'when the inbox signature does not exist' do + it 'returns not found' do + get "/api/v1/profile/inbox_signatures/#{inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when the user is not a member of the inbox' do + let(:non_member_inbox) { create(:inbox, account: account) } + + it 'returns unauthorized' do + get "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/profile/inbox_signatures/:inbox_id' do + let(:signature_params) do + { + inbox_signature: { + message_signature: '

Custom Signature

', + signature_position: 'bottom', + signature_separator: '--' + } + } + end + + context 'when the inbox signature does not exist' do + it 'creates a new inbox signature' do + expect do + put "/api/v1/profile/inbox_signatures/#{inbox.id}", + params: signature_params, + headers: agent.create_new_auth_token, + as: :json + end.to change(InboxSignature, :count).by(1) + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['inbox_id']).to eq(inbox.id) + expect(json_response['message_signature']).to eq('

Custom Signature

') + expect(json_response['signature_position']).to eq('bottom') + expect(json_response['signature_separator']).to eq('--') + end + end + + context 'when the inbox signature already exists' do + it 'updates the existing inbox signature' do + create(:inbox_signature, user: agent, inbox: inbox) + + expect do + put "/api/v1/profile/inbox_signatures/#{inbox.id}", + params: signature_params, + headers: agent.create_new_auth_token, + as: :json + end.not_to change(InboxSignature, :count) + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['message_signature']).to eq('

Custom Signature

') + expect(json_response['signature_position']).to eq('bottom') + end + end + + context 'when the user is not a member of the inbox' do + let(:non_member_inbox) { create(:inbox, account: account) } + + it 'returns unauthorized' do + put "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}", + params: signature_params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when params are invalid' do + let(:invalid_params) do + { + inbox_signature: { + message_signature: '

Custom Signature

', + signature_position: 'invalid' + } + } + end + + it 'returns unprocessable entity and does not create a signature' do + expect do + put "/api/v1/profile/inbox_signatures/#{inbox.id}", + params: invalid_params, + headers: agent.create_new_auth_token, + as: :json + end.not_to change(InboxSignature, :count) + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['attributes']).to include('signature_position') + end + end + end + + describe 'DELETE /api/v1/profile/inbox_signatures/:inbox_id' do + it 'deletes the inbox signature' do + create(:inbox_signature, user: agent, inbox: inbox) + + expect do + delete "/api/v1/profile/inbox_signatures/#{inbox.id}", + headers: agent.create_new_auth_token, + as: :json + end.to change(InboxSignature, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'returns no content even when signature does not exist' do + delete "/api/v1/profile/inbox_signatures/#{inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:no_content) + end + + context 'when the user is not a member of the inbox' do + let(:non_member_inbox) { create(:inbox, account: account) } + + it 'returns unauthorized' do + delete "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/factories/inbox_signatures.rb b/spec/factories/inbox_signatures.rb new file mode 100644 index 0000000000000..2316a22ea239d --- /dev/null +++ b/spec/factories/inbox_signatures.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :inbox_signature do + user + inbox + message_signature { '

Best regards,
Test Agent

' } + signature_position { 'top' } + signature_separator { 'blank' } + end +end diff --git a/spec/models/inbox_signature_spec.rb b/spec/models/inbox_signature_spec.rb new file mode 100644 index 0000000000000..15573d43c0059 --- /dev/null +++ b/spec/models/inbox_signature_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe InboxSignature do + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:inbox) } + end + + describe 'validations' do + subject { create(:inbox_signature, user: user, inbox: inbox) } + + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + + it { is_expected.to validate_presence_of(:message_signature) } + it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:inbox_id) } + it { is_expected.to validate_inclusion_of(:signature_position).in_array(%w[top bottom]) } + it { is_expected.to validate_inclusion_of(:signature_separator).in_array(%w[blank --]) } + end +end