Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,238 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
};
export const generateLabelForContactableInboxesList = ({
name,
email,
channelType,
phoneNumber,
}) => {
if (channelType === INBOX_TYPES.EMAIL) {
return `${name} (${email})`;
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return phoneNumber ? `${name} (${phoneNumber})` : name;
}
return name;
};
const transformInbox = ({
name,
id,
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
medium,
...rest,
});
export const compareInboxes = (a, b) => {
// Channels that have no priority defined should come at the end.
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(transformInbox).sort(compareInboxes);
};
export const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const processContactableInboxes = inboxes => {
return inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
}));
};
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
) => {
const files = [];
attachedFiles.forEach(attachment => {
if (directUploadsEnabled) {
files.push(attachment.blobSignedId);
} else {
files.push(attachment.resource.file);
}
});
return files;
};
export const prepareNewMessagePayload = ({
targetInbox,
selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
}) => {
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
assigneeId: currentUser.id,
};
if (attachedFiles?.length) {
payload.files = prepareAttachmentPayload(
attachedFiles,
directUploadsEnabled
);
}
if (subject) {
payload.mailSubject = subject;
}
if (ccEmails) {
payload.message.cc_emails = ccEmails;
}
if (bccEmails) {
payload.message.bcc_emails = bccEmails;
}
return payload;
};
export const prepareWhatsAppMessagePayload = ({
targetInbox,
selectedContact,
message,
templateParams,
currentUser,
}) => {
return {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: selectedContact.id,
message: { content: message, template_params: templateParams },
assigneeId: currentUser.id,
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async input => {
const payload = {
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
return camelcaseKeys(newContact, { deep: true });
};
export const fetchContactableInboxes = async contactId => {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contactId);
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
return processContactableInboxes(convertInboxesToCamelKeys);
};

View File

@@ -0,0 +1,705 @@
import { describe, it, expect, vi } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import ContactAPI from 'dashboard/api/contacts';
import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
it('generates label for email inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
});
});
describe('buildContactableInboxesList', () => {
it('returns empty array if no contact inboxes', () => {
expect(helpers.buildContactableInboxesList(null)).toEqual([]);
expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
});
it('builds list of contactable inboxes with correct format', () => {
const inboxes = [
{
id: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
phoneNumber: null,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-woot-mail',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
describe('getCapitalizedNameFromEmail', () => {
it('extracts and capitalizes name from email', () => {
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
'John.doe'
);
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
'Jane'
);
});
});
describe('processContactableInboxes', () => {
it('processes inboxes with correct structure', () => {
const inboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
const result = helpers.processContactableInboxes(inboxes);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
});
});
describe('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
'signed1',
]);
});
it('prepares regular files', () => {
const files = [{ resource: { file: 'file1' } }];
expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
});
});
describe('prepareNewMessagePayload', () => {
const baseParams = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: '2' },
message: 'Hello',
currentUser: { id: 3 },
};
it('prepares basic message payload', () => {
const result = helpers.prepareNewMessagePayload(baseParams);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: { content: 'Hello' },
assigneeId: 3,
});
});
it('includes optional fields when provided', () => {
const result = helpers.prepareNewMessagePayload({
...baseParams,
subject: 'Test',
ccEmails: 'cc@test.com',
bccEmails: 'bcc@test.com',
attachedFiles: [{ blobSignedId: 'file1' }],
directUploadsEnabled: true,
});
expect(result).toMatchObject({
mailSubject: 'Test',
message: {
content: 'Hello',
cc_emails: 'cc@test.com',
bcc_emails: 'bcc@test.com',
},
files: ['file1'],
});
});
});
describe('prepareWhatsAppMessagePayload', () => {
it('prepares whatsapp message payload', () => {
const params = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: 2 },
message: 'Hello',
templateParams: { param1: 'value1' },
currentUser: { id: 3 },
};
const result = helpers.prepareWhatsAppMessagePayload(params);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: {
content: 'Hello',
template_params: { param1: 'value1' },
},
assigneeId: 3,
});
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('transforms nested objects to camelCase', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
source_id: 'source1',
created_at: '2023-01-01',
},
],
custom_attributes: {
custom_field_name: 'value',
},
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
sourceId: 'source1',
createdAt: '2023-01-01',
},
],
customAttributes: {
customFieldName: 'value',
},
},
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('john@example.com');
expect(result).toEqual(mockContact);
expect(ContactAPI.create).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
describe('fetchContactableInboxes', () => {
it('fetches and processes contactable inboxes', async () => {
const mockInboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: mockInboxes },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
});
it('returns empty array when no inboxes found', async () => {
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result).toEqual([]);
});
});
});
});
describe('compareInboxes', () => {
it('should sort inboxes by channel priority', () => {
const inboxes = [
{ channelType: 'Channel::Api', name: 'API Inbox' },
{ channelType: 'Channel::Email', name: 'Email Inbox' },
{ channelType: 'Channel::WebWidget', name: 'Widget' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Email');
expect(sorted[1].channelType).toBe('Channel::Whatsapp');
expect(sorted[2].channelType).toBe('Channel::WebWidget');
expect(sorted[3].channelType).toBe('Channel::Api');
});
it('should sort SMS channels correctly', () => {
const inboxes = [
{ channelType: 'Channel::TwilioSms', name: 'Twilio' },
{ channelType: 'Channel::Sms', name: 'Regular SMS' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Sms');
expect(sorted[1].channelType).toBe('Channel::TwilioSms');
});
it('should sort by name when channel types are same', () => {
const inboxes = [
{ channelType: 'Channel::Email', name: 'Support' },
{ channelType: 'Channel::Email', name: 'Marketing' },
{ channelType: 'Channel::Email', name: 'Billing' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(inbox => inbox.name)).toEqual([
'Billing',
'Marketing',
'Support',
]);
});
it('should put channels without priority at the end', () => {
const inboxes = [
{ channelType: 'Channel::Unknown', name: 'Unknown' },
{ channelType: 'Channel::Email', name: 'Email' },
{ channelType: 'Channel::LineChannel', name: 'Line' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(i => i.channelType)).toEqual([
'Channel::Email',
'Channel::Whatsapp',
'Channel::LineChannel',
'Channel::Unknown',
]);
});
it('should handle empty array', () => {
const inboxes = [];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted).toEqual([]);
});
});