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 { 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"
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
<!-- Signature preview at top -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
||||
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP_TOOLTIP')"
|
||||
class="signature-preview signature-preview--top"
|
||||
>
|
||||
<div class="signature-label">
|
||||
@ -864,6 +876,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
<!-- Signature preview at bottom -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
||||
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM_TOOLTIP')"
|
||||
class="signature-preview signature-preview--bottom"
|
||||
>
|
||||
<div class="signature-label">
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
"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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
|
||||
@ -2,8 +2,11 @@
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||
|
||||
const props = defineProps({
|
||||
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 { 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 signaturePosition = ref(props.signaturePosition);
|
||||
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(() => [
|
||||
{
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="flex flex-col gap-2">
|
||||
<label
|
||||
@ -190,7 +313,7 @@ const handleSeparatorChange = value => {
|
||||
<WootMessageEditor
|
||||
id="message-signature-input"
|
||||
v-model="signature"
|
||||
class="message-editor h-[10rem] !px-3"
|
||||
class="message-editor h-40 !px-3"
|
||||
is-format-mode
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
channel-type="Context::MessageSignature"
|
||||
@ -226,11 +349,22 @@ const handleSeparatorChange = value => {
|
||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:is-loading="isSaving"
|
||||
: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>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@ -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
|
||||
|
||||
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 :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
|
||||
|
||||
@ -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 :backup_codes
|
||||
end
|
||||
resources :inbox_signatures, only: %i[index show update destroy], param: :inbox_id
|
||||
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.
|
||||
|
||||
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 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_01_162122) do
|
||||
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
|
||||
|
||||
@ -22,10 +22,15 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
{
|
||||
'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
|
||||
|
||||
@ -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