feat: add per-inbox signature management (#226)
* feat: add per-inbox signature management - Introduced `InboxSignature` model to manage signatures specific to each inbox. - Added API endpoints for fetching, creating, updating, and deleting inbox signatures. - Updated UI components to support inbox-specific signatures, including overrides for signature position and separator. - Implemented a new composable `useInboxSignatures` for managing inbox signatures in the frontend. - Enhanced existing components to utilize inbox signatures, including the reply box and message signature settings. - Added tests for the new inbox signatures functionality, ensuring proper behavior of the API and model validations. - Updated translations for new UI elements related to inbox signatures. * feat: implement inbox access validation and add related tests * feat: enhance inbox signatures fetching and management logic
This commit is contained in:
parent
21007bd20b
commit
56c5609ca0
@ -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
|
||||||
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
@ -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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
import { useWindowSize } from '@vueuse/core';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
@ -84,6 +85,24 @@ const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
|||||||
const messageSignature = useMapGetter('getMessageSignature');
|
const messageSignature = useMapGetter('getMessageSignature');
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
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(() =>
|
const sendWithSignature = computed(() =>
|
||||||
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
||||||
);
|
);
|
||||||
@ -307,8 +326,9 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||||
:contact-conversations-ui-flags="uiFlags"
|
:contact-conversations-ui-flags="uiFlags"
|
||||||
:contacts-ui-flags="contactsUiFlags"
|
:contacts-ui-flags="contactsUiFlags"
|
||||||
:message-signature="messageSignature"
|
:message-signature="resolvedMessageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
|
:signature-settings="resolvedSignatureSettings"
|
||||||
@search-contacts="onContactSearch"
|
@search-contacts="onContactSearch"
|
||||||
@reset-contact-search="resetContacts"
|
@reset-contact-search="resetContacts"
|
||||||
@update-selected-contact="handleSelectedContact"
|
@update-selected-contact="handleSelectedContact"
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const props = defineProps({
|
|||||||
contactsUiFlags: { type: Object, default: null },
|
contactsUiFlags: { type: Object, default: null },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
signatureSettings: { type: Object, default: null },
|
||||||
formState: { type: Object, required: true },
|
formState: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ const newMessagePayload = () => {
|
|||||||
directUploadsEnabled: props.isDirectUploadsEnabled,
|
directUploadsEnabled: props.isDirectUploadsEnabled,
|
||||||
sendWithSignature: props.sendWithSignature,
|
sendWithSignature: props.sendWithSignature,
|
||||||
messageSignature: props.messageSignature,
|
messageSignature: props.messageSignature,
|
||||||
|
signatureSettings: props.signatureSettings,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -132,20 +132,15 @@ export const prepareNewMessagePayload = ({
|
|||||||
directUploadsEnabled = false,
|
directUploadsEnabled = false,
|
||||||
sendWithSignature = false,
|
sendWithSignature = false,
|
||||||
messageSignature = '',
|
messageSignature = '',
|
||||||
|
signatureSettings = null,
|
||||||
}) => {
|
}) => {
|
||||||
let finalMessage = message;
|
let finalMessage = message;
|
||||||
if (sendWithSignature && messageSignature) {
|
if (sendWithSignature && messageSignature) {
|
||||||
const { signature_position, signature_separator } =
|
const settings = signatureSettings || {
|
||||||
currentUser?.ui_settings || {};
|
position: currentUser?.ui_settings?.signature_position || 'top',
|
||||||
const signatureSettings = {
|
separator: currentUser?.ui_settings?.signature_separator || 'blank',
|
||||||
position: signature_position || 'top',
|
|
||||||
separator: signature_separator || 'blank',
|
|
||||||
};
|
};
|
||||||
finalMessage = appendSignature(
|
finalMessage = appendSignature(message, messageSignature, settings);
|
||||||
message,
|
|
||||||
messageSignature,
|
|
||||||
signatureSettings
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@ -87,6 +87,9 @@ const props = defineProps({
|
|||||||
// allowSignature is a kill switch, ensuring no signature methods
|
// allowSignature is a kill switch, ensuring no signature methods
|
||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
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: '' },
|
channelType: { type: String, default: '' },
|
||||||
conversationId: { type: Number, default: null },
|
conversationId: { type: Number, default: null },
|
||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
@ -322,11 +325,19 @@ const sendWithSignature = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const signaturePosition = 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(() => {
|
const signatureSeparator = computed(() => {
|
||||||
return currentUser.value?.ui_settings?.signature_separator || 'blank';
|
return (
|
||||||
|
props.signatureSeparatorOverride ||
|
||||||
|
currentUser.value?.ui_settings?.signature_separator ||
|
||||||
|
'blank'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowSignaturePreview = computed(() => {
|
const shouldShowSignaturePreview = computed(() => {
|
||||||
@ -850,6 +861,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
<!-- Signature preview at top -->
|
<!-- Signature preview at top -->
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
||||||
|
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP_TOOLTIP')"
|
||||||
class="signature-preview signature-preview--top"
|
class="signature-preview signature-preview--top"
|
||||||
>
|
>
|
||||||
<div class="signature-label">
|
<div class="signature-label">
|
||||||
@ -864,6 +876,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
<!-- Signature preview at bottom -->
|
<!-- Signature preview at bottom -->
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
||||||
|
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM_TOOLTIP')"
|
||||||
class="signature-preview signature-preview--bottom"
|
class="signature-preview signature-preview--bottom"
|
||||||
>
|
>
|
||||||
<div class="signature-label">
|
<div class="signature-label">
|
||||||
@ -899,7 +912,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||||
|
|
||||||
.signature-preview {
|
.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 {
|
&--top {
|
||||||
@apply border-b border-n-weak pb-1;
|
@apply border-b border-n-weak pb-1;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue';
|
|||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||||
@ -97,6 +98,14 @@ export default {
|
|||||||
fetchQuotedReplyFlagFromUISettings,
|
fetchQuotedReplyFlagFromUISettings,
|
||||||
} = useUISettings();
|
} = useUISettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchInboxSignatures,
|
||||||
|
getSignatureForInbox,
|
||||||
|
getSignatureSettingsForInbox,
|
||||||
|
} = useInboxSignatures();
|
||||||
|
|
||||||
|
fetchInboxSignatures();
|
||||||
|
|
||||||
const { formatMessage } = useMessageFormatter();
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
|
||||||
const replyEditor = useTemplateRef('replyEditor');
|
const replyEditor = useTemplateRef('replyEditor');
|
||||||
@ -109,6 +118,8 @@ export default {
|
|||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setQuotedReplyFlagForInbox,
|
setQuotedReplyFlagForInbox,
|
||||||
fetchQuotedReplyFlagFromUISettings,
|
fetchQuotedReplyFlagFromUISettings,
|
||||||
|
getSignatureForInbox,
|
||||||
|
getSignatureSettingsForInbox,
|
||||||
replyEditor,
|
replyEditor,
|
||||||
copilot,
|
copilot,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@ -147,7 +158,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
messageSignature: 'getMessageSignature',
|
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
lastEmail: 'getLastEmailInSelectedChat',
|
lastEmail: 'getLastEmailInSelectedChat',
|
||||||
globalConfig: 'globalConfig/get',
|
globalConfig: 'globalConfig/get',
|
||||||
@ -344,6 +354,9 @@ export default {
|
|||||||
isSignatureEnabledForInbox() {
|
isSignatureEnabledForInbox() {
|
||||||
return !this.isPrivate && this.sendWithSignature;
|
return !this.isPrivate && this.sendWithSignature;
|
||||||
},
|
},
|
||||||
|
messageSignature() {
|
||||||
|
return this.getSignatureForInbox(this.inboxId);
|
||||||
|
},
|
||||||
isSignatureAvailable() {
|
isSignatureAvailable() {
|
||||||
return !!this.messageSignature;
|
return !!this.messageSignature;
|
||||||
},
|
},
|
||||||
@ -449,10 +462,10 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
signaturePosition() {
|
signaturePosition() {
|
||||||
return this.currentUser?.ui_settings?.signature_position || 'top';
|
return this.getSignatureSettingsForInbox(this.inboxId).position;
|
||||||
},
|
},
|
||||||
signatureSeparator() {
|
signatureSeparator() {
|
||||||
return this.currentUser?.ui_settings?.signature_separator || 'blank';
|
return this.getSignatureSettingsForInbox(this.inboxId).separator;
|
||||||
},
|
},
|
||||||
formattedSignature() {
|
formattedSignature() {
|
||||||
if (!this.messageSignature) return '';
|
if (!this.messageSignature) return '';
|
||||||
@ -703,11 +716,9 @@ export default {
|
|||||||
if (!this.sendWithSignature || !this.messageSignature) {
|
if (!this.sendWithSignature || !this.messageSignature) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
const { signature_position, signature_separator } =
|
|
||||||
this.currentUser?.ui_settings || {};
|
|
||||||
const signatureSettings = {
|
const signatureSettings = {
|
||||||
position: signature_position || 'top',
|
position: this.signaturePosition,
|
||||||
separator: signature_separator || 'blank',
|
separator: this.signatureSeparator,
|
||||||
};
|
};
|
||||||
return appendSignature(message, this.messageSignature, signatureSettings);
|
return appendSignature(message, this.messageSignature, signatureSettings);
|
||||||
},
|
},
|
||||||
@ -1354,6 +1365,8 @@ export default {
|
|||||||
:variables="messageVariables"
|
:variables="messageVariables"
|
||||||
:signature="messageSignature"
|
:signature="messageSignature"
|
||||||
allow-signature
|
allow-signature
|
||||||
|
:signature-position-override="signaturePosition"
|
||||||
|
:signature-separator-override="signatureSeparator"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
:medium="inbox.medium"
|
:medium="inbox.medium"
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
|
|||||||
@ -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: '<p>Global Signature</p>',
|
||||||
|
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: '<p>Inbox 1 Sig</p>',
|
||||||
|
signature_position: 'top',
|
||||||
|
signature_separator: 'blank',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inbox_id: 2,
|
||||||
|
message_signature: '<p>Inbox 2 Sig</p>',
|
||||||
|
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(
|
||||||
|
'<p>Inbox 1 Sig</p>'
|
||||||
|
);
|
||||||
|
expect(inboxSignatures.value[2].message_signature).toBe(
|
||||||
|
'<p>Inbox 2 Sig</p>'
|
||||||
|
);
|
||||||
|
expect(hasFetched.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSignatureForInbox', () => {
|
||||||
|
it('returns inbox-specific signature when available', async () => {
|
||||||
|
mockInboxSignaturesAPI.getAll.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
inbox_id: 1,
|
||||||
|
message_signature: '<p>Inbox 1 Sig</p>',
|
||||||
|
signature_position: 'top',
|
||||||
|
signature_separator: 'blank',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchInboxSignatures, getSignatureForInbox } =
|
||||||
|
useInboxSignatures();
|
||||||
|
await fetchInboxSignatures();
|
||||||
|
|
||||||
|
expect(getSignatureForInbox(1)).toBe('<p>Inbox 1 Sig</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<p>Global Signature</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSignatureSettingsForInbox', () => {
|
||||||
|
it('returns inbox-specific settings when available', async () => {
|
||||||
|
mockInboxSignaturesAPI.getAll.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
inbox_id: 1,
|
||||||
|
message_signature: '<p>Sig</p>',
|
||||||
|
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: '<p>New Sig</p>',
|
||||||
|
signature_position: 'top',
|
||||||
|
signature_separator: 'blank',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchInboxSignatures,
|
||||||
|
upsertInboxSignature,
|
||||||
|
getSignatureForInbox,
|
||||||
|
} = useInboxSignatures();
|
||||||
|
await fetchInboxSignatures();
|
||||||
|
|
||||||
|
await upsertInboxSignature(3, {
|
||||||
|
message_signature: '<p>New Sig</p>',
|
||||||
|
signature_position: 'top',
|
||||||
|
signature_separator: 'blank',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSignatureForInbox(3)).toBe('<p>New Sig</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteInboxSignature', () => {
|
||||||
|
it('removes from cache after deleting', async () => {
|
||||||
|
mockInboxSignaturesAPI.getAll.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
inbox_id: 1,
|
||||||
|
message_signature: '<p>Sig</p>',
|
||||||
|
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('<p>Global Signature</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
116
app/javascript/dashboard/composables/useInboxSignatures.js
Normal file
116
app/javascript/dashboard/composables/useInboxSignatures.js
Normal file
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -190,7 +190,9 @@
|
|||||||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||||
"SIGNATURE_LABEL_TOP": "↓ 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": "↑ 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.",
|
"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",
|
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||||
|
|||||||
@ -66,6 +66,13 @@
|
|||||||
"BTN_TEXT": "Save message signature",
|
"BTN_TEXT": "Save message signature",
|
||||||
"API_ERROR": "Couldn't save signature! Try again",
|
"API_ERROR": "Couldn't save signature! Try again",
|
||||||
"API_SUCCESS": "Signature saved successfully",
|
"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_ERROR": "Couldn't upload image! Try again",
|
||||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
"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",
|
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
|
||||||
|
|||||||
@ -185,7 +185,9 @@
|
|||||||
"ENABLE_SIGN_TOOLTIP": "Ativar assinatura",
|
"ENABLE_SIGN_TOOLTIP": "Ativar assinatura",
|
||||||
"DISABLE_SIGN_TOOLTIP": "Desativar assinatura",
|
"DISABLE_SIGN_TOOLTIP": "Desativar assinatura",
|
||||||
"SIGNATURE_LABEL_TOP": "↓ 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": "↑ 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.",
|
"MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.",
|
||||||
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
|
"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.",
|
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",
|
||||||
|
|||||||
@ -66,6 +66,13 @@
|
|||||||
"BTN_TEXT": "Salvar assinatura da mensagem",
|
"BTN_TEXT": "Salvar assinatura da mensagem",
|
||||||
"API_ERROR": "Não foi possível salvar a assinatura! Tente novamente",
|
"API_ERROR": "Não foi possível salvar a assinatura! Tente novamente",
|
||||||
"API_SUCCESS": "Assinatura salva com sucesso",
|
"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_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_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",
|
"IMAGE_UPLOAD_SIZE_ERROR": "O tamanho da imagem deve ser menor que {size}MB",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||||
import { useBranding } from 'shared/composables/useBranding';
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
|
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
|
||||||
@ -46,6 +47,10 @@ export default {
|
|||||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||||
const { currentFontSize, updateFontSize } = useFontSize();
|
const { currentFontSize, updateFontSize } = useFontSize();
|
||||||
const { replaceInstallationName } = useBranding();
|
const { replaceInstallationName } = useBranding();
|
||||||
|
const { upsertInboxSignature, deleteInboxSignature, fetchInboxSignatures } =
|
||||||
|
useInboxSignatures();
|
||||||
|
|
||||||
|
fetchInboxSignatures();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentFontSize,
|
currentFontSize,
|
||||||
@ -53,6 +58,8 @@ export default {
|
|||||||
isEditorHotKeyEnabled,
|
isEditorHotKeyEnabled,
|
||||||
updateUISettings,
|
updateUISettings,
|
||||||
replaceInstallationName,
|
replaceInstallationName,
|
||||||
|
upsertInboxSignature,
|
||||||
|
deleteInboxSignature,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
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 }) {
|
updateProfilePicture({ file, url }) {
|
||||||
this.avatarFile = file;
|
this.avatarFile = file;
|
||||||
this.avatarUrl = url;
|
this.avatarUrl = url;
|
||||||
@ -274,6 +312,8 @@ export default {
|
|||||||
:signature-position="signaturePosition"
|
:signature-position="signaturePosition"
|
||||||
:signature-separator="signatureSeparator"
|
:signature-separator="signatureSeparator"
|
||||||
@update-signature="updateSignature"
|
@update-signature="updateSignature"
|
||||||
|
@update-inbox-signature="updateInboxSignature"
|
||||||
|
@delete-inbox-signature="handleDeleteInboxSignature"
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<FormSection
|
<FormSection
|
||||||
|
|||||||
@ -2,8 +2,11 @@
|
|||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messageSignature: {
|
messageSignature: {
|
||||||
@ -22,14 +25,60 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['updateSignature']);
|
const emit = defineEmits([
|
||||||
|
'updateSignature',
|
||||||
|
'updateInboxSignature',
|
||||||
|
'deleteInboxSignature',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INBOX_OPTION_DEFAULT = 'default';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { formatMessage } = useMessageFormatter();
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
const { fetchInboxSignatures, getInboxSignature, hasInboxSignature } =
|
||||||
|
useInboxSignatures();
|
||||||
|
|
||||||
|
const selectedInboxId = ref(INBOX_OPTION_DEFAULT);
|
||||||
|
const selectedInbox = ref(null);
|
||||||
const signature = ref(props.messageSignature);
|
const signature = ref(props.messageSignature);
|
||||||
const signaturePosition = ref(props.signaturePosition);
|
const signaturePosition = ref(props.signaturePosition);
|
||||||
const signatureSeparator = ref(props.signatureSeparator);
|
const signatureSeparator = ref(props.signatureSeparator);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const defaultOption = computed(() => ({
|
||||||
|
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(() => [
|
const positionOptions = computed(() => [
|
||||||
{
|
{
|
||||||
@ -83,10 +132,40 @@ const messagePreview = computed(() => {
|
|||||||
return `${sampleMessage.value}${separator}${formattedSignature.value}`;
|
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(
|
watch(
|
||||||
() => props.signaturePosition,
|
() => props.signaturePosition,
|
||||||
newValue => {
|
newValue => {
|
||||||
signaturePosition.value = newValue;
|
if (isDefaultSelected.value) signaturePosition.value = newValue;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@ -94,7 +173,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.signatureSeparator,
|
() => props.signatureSeparator,
|
||||||
newValue => {
|
newValue => {
|
||||||
signatureSeparator.value = newValue;
|
if (isDefaultSelected.value) signatureSeparator.value = newValue;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@ -102,33 +181,77 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.messageSignature ?? '',
|
() => props.messageSignature ?? '',
|
||||||
newValue => {
|
newValue => {
|
||||||
signature.value = newValue;
|
if (isDefaultSelected.value) signature.value = newValue;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateSignature = () => {
|
const updateSignature = () => {
|
||||||
emit(
|
if (isDefaultSelected.value) {
|
||||||
'updateSignature',
|
emit(
|
||||||
signature.value,
|
'updateSignature',
|
||||||
signaturePosition.value,
|
signature.value,
|
||||||
signatureSeparator.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 => {
|
const handlePositionChange = value => {
|
||||||
signaturePosition.value = value;
|
signaturePosition.value = value;
|
||||||
emit('updateSignature', signature.value, value, signatureSeparator.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeparatorChange = value => {
|
const handleSeparatorChange = value => {
|
||||||
signatureSeparator.value = value;
|
signatureSeparator.value = value;
|
||||||
emit('updateSignature', signature.value, signaturePosition.value, value);
|
};
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
isSaving.value = true;
|
||||||
|
emit('deleteInboxSignature', selectedInboxId.value, () => {
|
||||||
|
loadSignatureForSelection();
|
||||||
|
isSaving.value = false;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
|
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.LABEL'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SingleSelect
|
||||||
|
v-model="selectedInbox"
|
||||||
|
:options="inboxOptions"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.DEFAULT'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
disable-deselect
|
||||||
|
class="min-w-0 [&_button]:max-w-xs [&_button]:min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label
|
<label
|
||||||
@ -190,7 +313,7 @@ const handleSeparatorChange = value => {
|
|||||||
<WootMessageEditor
|
<WootMessageEditor
|
||||||
id="message-signature-input"
|
id="message-signature-input"
|
||||||
v-model="signature"
|
v-model="signature"
|
||||||
class="message-editor h-[10rem] !px-3"
|
class="message-editor h-40 !px-3"
|
||||||
is-format-mode
|
is-format-mode
|
||||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||||
channel-type="Context::MessageSignature"
|
channel-type="Context::MessageSignature"
|
||||||
@ -226,11 +349,22 @@ const handleSeparatorChange = value => {
|
|||||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.NOTE') }}
|
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.NOTE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<NextButton
|
<NextButton
|
||||||
type="submit"
|
type="submit"
|
||||||
|
:is-loading="isSaving"
|
||||||
:label="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT')"
|
:label="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT')"
|
||||||
/>
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="!isDefaultSelected && currentInboxHasOverride"
|
||||||
|
type="button"
|
||||||
|
variant="faded"
|
||||||
|
color="ruby"
|
||||||
|
:label="
|
||||||
|
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.RESET_TO_DEFAULT')
|
||||||
|
"
|
||||||
|
@click="resetToDefault"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -64,6 +64,7 @@ class Inbox < ApplicationRecord
|
|||||||
has_many :contacts, through: :contact_inboxes
|
has_many :contacts, through: :contact_inboxes
|
||||||
|
|
||||||
has_many :inbox_members, dependent: :destroy_async
|
has_many :inbox_members, dependent: :destroy_async
|
||||||
|
has_many :inbox_signatures, dependent: :destroy_async
|
||||||
has_many :members, through: :inbox_members, source: :user
|
has_many :members, through: :inbox_members, source: :user
|
||||||
has_many :conversations, dependent: :destroy_async
|
has_many :conversations, dependent: :destroy_async
|
||||||
has_many :messages, dependent: :destroy_async
|
has_many :messages, dependent: :destroy_async
|
||||||
|
|||||||
28
app/models/inbox_signature.rb
Normal file
28
app/models/inbox_signature.rb
Normal file
@ -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
|
||||||
@ -96,6 +96,7 @@ class User < ApplicationRecord
|
|||||||
has_many :participating_conversations, through: :conversation_participants, source: :conversation
|
has_many :participating_conversations, through: :conversation_participants, source: :conversation
|
||||||
|
|
||||||
has_many :inbox_members, dependent: :destroy_async
|
has_many :inbox_members, dependent: :destroy_async
|
||||||
|
has_many :inbox_signatures, dependent: :destroy_async
|
||||||
has_many :inboxes, through: :inbox_members, source: :inbox
|
has_many :inboxes, through: :inbox_members, source: :inbox
|
||||||
has_many :messages, as: :sender, dependent: :nullify
|
has_many :messages, as: :sender, dependent: :nullify
|
||||||
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
||||||
|
|||||||
@ -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
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
json.array! @inbox_signatures do |inbox_signature|
|
||||||
|
json.partial! 'inbox_signature', inbox_signature: inbox_signature
|
||||||
|
end
|
||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'inbox_signature', inbox_signature: @inbox_signature
|
||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'inbox_signature', inbox_signature: @inbox_signature
|
||||||
@ -390,6 +390,7 @@ Rails.application.routes.draw do
|
|||||||
post :verify
|
post :verify
|
||||||
post :backup_codes
|
post :backup_codes
|
||||||
end
|
end
|
||||||
|
resources :inbox_signatures, only: %i[index show update destroy], param: :inbox_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
14
db/migrate/20260226173647_create_inbox_signatures.rb
Normal file
14
db/migrate/20260226173647_create_inbox_signatures.rb
Normal file
@ -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
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
class AddInboxIdIndexToInboxSignatures < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_index :inbox_signatures, :inbox_id
|
||||||
|
end
|
||||||
|
end
|
||||||
14
db/schema.rb
14
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@ -857,6 +857,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_01_162122) do
|
|||||||
t.index ["inbox_id"], name: "index_inbox_members_on_inbox_id"
|
t.index ["inbox_id"], name: "index_inbox_members_on_inbox_id"
|
||||||
end
|
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|
|
create_table "inboxes", id: :serial, force: :cascade do |t|
|
||||||
t.integer "channel_id", null: false
|
t.integer "channel_id", null: false
|
||||||
t.integer "account_id", null: false
|
t.integer "account_id", null: false
|
||||||
|
|||||||
@ -22,10 +22,15 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
|
|||||||
{
|
{
|
||||||
'channel_type' => conversation.inbox.channel_type,
|
'channel_type' => conversation.inbox.channel_type,
|
||||||
'agent_name' => user.name,
|
'agent_name' => user.name,
|
||||||
'agent_signature' => user.message_signature.presence
|
'agent_signature' => resolved_signature
|
||||||
}
|
}
|
||||||
end
|
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 = {})
|
def render_liquid_template(template_content, variables = {})
|
||||||
Liquid::Template.parse(template_content).render(variables)
|
Liquid::Template.parse(template_content).render(variables)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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: '<p>Custom Signature</p>',
|
||||||
|
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('<p>Custom Signature</p>')
|
||||||
|
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('<p>Custom Signature</p>')
|
||||||
|
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: '<p>Custom Signature</p>',
|
||||||
|
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
|
||||||
11
spec/factories/inbox_signatures.rb
Normal file
11
spec/factories/inbox_signatures.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :inbox_signature do
|
||||||
|
user
|
||||||
|
inbox
|
||||||
|
message_signature { '<p>Best regards,<br>Test Agent</p>' }
|
||||||
|
signature_position { 'top' }
|
||||||
|
signature_separator { 'blank' }
|
||||||
|
end
|
||||||
|
end
|
||||||
23
spec/models/inbox_signature_spec.rb
Normal file
23
spec/models/inbox_signature_spec.rb
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user