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,6 @@
export const getAssignee = message => message?.conversation?.assignee_id;
export const isConversationUnassigned = message => !getAssignee(message);
export const isConversationAssignedToMe = (message, currentUserId) =>
getAssignee(message) === currentUserId;
export const isMessageFromCurrentUser = (message, currentUserId) =>
message?.sender?.id === currentUserId;

View File

@@ -0,0 +1,47 @@
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
class AudioNotificationStore {
constructor(store) {
this.store = store;
}
hasUnreadConversation = () => {
const mineConversation = this.store.getters.getMineChats({
assigneeType: 'me',
status: 'open',
});
return mineConversation.some(conv => conv.unread_count > 0);
};
isMessageFromPendingConversation = (message = {}) => {
const { conversation_id: conversationId } = message || {};
if (!conversationId) return false;
const activeConversation =
this.store.getters.getConversationById(conversationId);
return activeConversation?.status === wootConstants.STATUS_TYPE.PENDING;
};
isMessageFromCurrentConversation = message => {
return this.store.getters.getSelectedChat?.id === message.conversation_id;
};
hasConversationPermission = user => {
const currentAccountId = this.store.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(user, currentAccountId);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
};
}
export default AudioNotificationStore;

View File

@@ -0,0 +1,217 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { showBadgeOnFavicon } from './faviconHelper';
import { initFaviconSwitcher } from './faviconHelper';
import { EVENT_TYPES } from 'dashboard/routes/dashboard/settings/profile/constants.js';
import GlobalStore from 'dashboard/store';
import AudioNotificationStore from './AudioNotificationStore';
import {
isConversationAssignedToMe,
isConversationUnassigned,
isMessageFromCurrentUser,
} from './AudioMessageHelper';
import WindowVisibilityHelper from './WindowVisibilityHelper';
import { useAlert } from 'dashboard/composables';
const NOTIFICATION_TIME = 30000;
const ALERT_DURATION = 10000;
const ALERT_PATH_PREFIX = '/audio/dashboard/';
const DEFAULT_TONE = 'ding';
const DEFAULT_ALERT_TYPE = ['none'];
export class DashboardAudioNotificationHelper {
constructor(store) {
if (!store) {
throw new Error('store is required');
}
this.store = new AudioNotificationStore(store);
this.notificationConfig = {
audioAlertType: DEFAULT_ALERT_TYPE,
playAlertOnlyWhenHidden: true,
alertIfUnreadConversationExist: false,
};
this.recurringNotificationTimer = null;
this.audioConfig = {
audio: null,
tone: DEFAULT_TONE,
hasSentSoundPermissionsRequest: false,
};
this.currentUser = null;
}
intializeAudio = () => {
const resourceUrl = `${ALERT_PATH_PREFIX}${this.audioConfig.tone}.mp3`;
this.audioConfig.audio = new Audio(resourceUrl);
return this.audioConfig.audio.load();
};
playAudioAlert = async () => {
try {
await this.audioConfig.audio.play();
} catch (error) {
if (
error.name === 'NotAllowedError' &&
!this.hasSentSoundPermissionsRequest
) {
this.hasSentSoundPermissionsRequest = true;
useAlert(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.SOUND_PERMISSION_ERROR',
{ usei18n: true, duration: ALERT_DURATION }
);
}
}
};
set = ({
currentUser,
alwaysPlayAudioAlert,
alertIfUnreadConversationExist,
audioAlertType = DEFAULT_ALERT_TYPE,
audioAlertTone = DEFAULT_TONE,
}) => {
this.notificationConfig = {
...this.notificationConfig,
audioAlertType: audioAlertType.split('+').filter(Boolean),
playAlertOnlyWhenHidden: !alwaysPlayAudioAlert,
alertIfUnreadConversationExist: alertIfUnreadConversationExist,
};
this.currentUser = currentUser;
const previousAudioTone = this.audioConfig.tone;
this.audioConfig = {
...this.audioConfig,
tone: audioAlertTone,
};
if (previousAudioTone !== audioAlertTone) {
this.intializeAudio();
}
initFaviconSwitcher();
this.clearRecurringTimer();
this.playAudioEvery30Seconds();
};
shouldPlayAlert = () => {
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return !WindowVisibilityHelper.isWindowVisible();
}
return true;
};
executeRecurringNotification = () => {
if (this.store.hasUnreadConversation() && this.shouldPlayAlert()) {
this.playAudioAlert();
showBadgeOnFavicon();
}
this.resetRecurringTimer();
};
clearRecurringTimer = () => {
if (this.recurringNotificationTimer) {
clearTimeout(this.recurringNotificationTimer);
}
};
resetRecurringTimer = () => {
this.clearRecurringTimer();
this.recurringNotificationTimer = setTimeout(
this.executeRecurringNotification,
NOTIFICATION_TIME
);
};
playAudioEvery30Seconds = () => {
const { audioAlertType, alertIfUnreadConversationExist } =
this.notificationConfig;
// Audio alert is disabled dismiss the timer
if (audioAlertType.includes('none')) return;
// If unread conversation flag is disabled, dismiss the timer
if (!alertIfUnreadConversationExist) return;
this.resetRecurringTimer();
};
shouldNotifyOnMessage = message => {
const { audioAlertType } = this.notificationConfig;
if (audioAlertType.includes('none')) return false;
if (audioAlertType.includes('all')) return true;
const assignedToMe = isConversationAssignedToMe(
message,
this.currentUser.id
);
const isUnassigned = isConversationUnassigned(message);
const shouldPlayAudio = [];
if (
audioAlertType.includes(EVENT_TYPES.ASSIGNED) ||
audioAlertType.includes('mine')
) {
shouldPlayAudio.push(assignedToMe);
}
if (audioAlertType.includes(EVENT_TYPES.UNASSIGNED)) {
shouldPlayAudio.push(isUnassigned);
}
if (audioAlertType.includes(EVENT_TYPES.NOTME)) {
shouldPlayAudio.push(!isUnassigned && !assignedToMe);
}
return shouldPlayAudio.some(Boolean);
};
onNewMessage = message => {
// If the user does not have the permission to view the conversation, then dismiss the alert
// FIX ME: There shouldn't be a new message if the user has no access to the conversation.
if (!this.store.hasConversationPermission(this.currentUser)) {
return;
}
// If the conversation status is pending, then dismiss the alert
// This case is common for all audio event types
if (this.store.isMessageFromPendingConversation(message)) {
return;
}
// If the message is sent by the current user then dismiss the alert
if (isMessageFromCurrentUser(message, this.currentUser.id)) {
return;
}
if (!this.shouldNotifyOnMessage(message)) {
return;
}
// If the message type is not incoming or private, then dismiss the alert
const { message_type: messageType, private: isPrivate } = message;
if (messageType !== MESSAGE_TYPE.INCOMING && !isPrivate) {
return;
}
if (WindowVisibilityHelper.isWindowVisible()) {
// If the user looking at the conversation, then dismiss the alert
if (this.store.isMessageFromCurrentConversation(message)) {
return;
}
// If the user has disabled alerts when active on the dashboard, the dismiss the alert
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return;
}
}
this.playAudioAlert();
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
}
export default new DashboardAudioNotificationHelper(GlobalStore);

View File

@@ -0,0 +1,21 @@
export class WindowVisibilityHelper {
constructor() {
this.isVisible = true;
this.initializeEvent();
}
initializeEvent = () => {
window.addEventListener('blur', () => {
this.isVisible = false;
});
window.addEventListener('focus', () => {
this.isVisible = true;
});
};
isWindowVisible() {
return !document.hidden && this.isVisible;
}
}
export default new WindowVisibilityHelper();

View File

@@ -0,0 +1,21 @@
export const showBadgeOnFavicon = () => {
const favicons = document.querySelectorAll('.favicon');
favicons.forEach(favicon => {
const newFileName = `/favicon-badge-${favicon.sizes[[0]]}.png`;
favicon.href = newFileName;
});
};
export const initFaviconSwitcher = () => {
const favicons = document.querySelectorAll('.favicon');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
favicons.forEach(favicon => {
const oldFileName = `/favicon-${favicon.sizes[[0]]}.png`;
favicon.href = oldFileName;
});
}
});
};

View File

@@ -0,0 +1,79 @@
import {
getAssignee,
isConversationUnassigned,
isConversationAssignedToMe,
isMessageFromCurrentUser,
} from '../AudioMessageHelper';
describe('getAssignee', () => {
it('should return assignee_id when present', () => {
const message = { conversation: { assignee_id: 1 } };
expect(getAssignee(message)).toBe(1);
});
it('should return undefined when no assignee_id', () => {
const message = { conversation: null };
expect(getAssignee(message)).toBeUndefined();
});
it('should handle null message', () => {
expect(getAssignee(null)).toBeUndefined();
});
});
describe('isConversationUnassigned', () => {
it('should return true when no assignee', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationUnassigned(message)).toBe(true);
});
it('should return false when has assignee', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationUnassigned(message)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationUnassigned(null)).toBe(true);
});
});
describe('isConversationAssignedToMe', () => {
const currentUserId = 1;
it('should return true when assigned to current user', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(true);
});
it('should return false when assigned to different user', () => {
const message = { conversation: { assignee_id: 2 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should return false when unassigned', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationAssignedToMe(null, currentUserId)).toBe(false);
});
});
describe('isMessageFromCurrentUser', () => {
const currentUserId = 1;
it('should return true when message is from current user', () => {
const message = { sender: { id: 1 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(true);
});
it('should return false when message is from different user', () => {
const message = { sender: { id: 2 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isMessageFromCurrentUser(null, currentUserId)).toBe(false);
});
});

View File

@@ -0,0 +1,191 @@
import AudioNotificationStore from '../AudioNotificationStore';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
describe('AudioNotificationStore', () => {
let store;
let audioNotificationStore;
beforeEach(() => {
store = {
getters: {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
getConversationById: vi.fn(),
},
};
audioNotificationStore = new AudioNotificationStore(store);
});
describe('hasUnreadConversation', () => {
it('should return true when there are unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 2 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(true);
});
it('should return false when there are no unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 0 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should return false when there are no conversations', () => {
store.getters.getMineChats.mockReturnValue([]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should call getMineChats with correct parameters', () => {
store.getters.getMineChats.mockReturnValue([]);
audioNotificationStore.hasUnreadConversation();
expect(store.getters.getMineChats).toHaveBeenCalledWith({
assigneeType: 'me',
status: 'open',
});
});
});
describe('isMessageFromPendingConversation', () => {
it('should return true when conversation status is pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.PENDING,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(true);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation status is not pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.OPEN,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation is not found', () => {
store.getters.getConversationById.mockReturnValue(null);
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when message has no conversation_id', () => {
const message = {};
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
it('should return false when message is null or undefined', () => {
expect(
audioNotificationStore.isMessageFromPendingConversation(null)
).toBe(false);
expect(
audioNotificationStore.isMessageFromPendingConversation(undefined)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
});
describe('isMessageFromCurrentConversation', () => {
it('should return true when message is from selected chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(true);
});
it('should return false when message is from different chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 1337 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
it('should return false when no chat is selected', () => {
store.getters.getSelectedChat = null;
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
});
describe('hasConversationPermission', () => {
const mockUser = { id: 'user123' };
beforeEach(() => {
getUserPermissions.mockReset();
});
it('should return true when user has a required role', () => {
getUserPermissions.mockReturnValue([ROLES[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
expect(getUserPermissions).toHaveBeenCalledWith(mockUser, 1);
});
it('should return true when user has a conversation permission', () => {
getUserPermissions.mockReturnValue([CONVERSATION_PERMISSIONS[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
});
it('should return false when user has no required permissions', () => {
getUserPermissions.mockReturnValue(['some-other-permission']);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
it('should return false when user has no permissions', () => {
getUserPermissions.mockReturnValue([]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { WindowVisibilityHelper } from '../WindowVisibilityHelper';
describe('WindowVisibilityHelper', () => {
let blurCallback;
let focusCallback;
let windowEventListeners;
let documentHiddenValue = false;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
// Reset event listeners before each test
windowEventListeners = {};
// Mock window.addEventListener
window.addEventListener = vi.fn((event, callback) => {
windowEventListeners[event] = callback;
if (event === 'blur') blurCallback = callback;
if (event === 'focus') focusCallback = callback;
});
// Mock document.hidden with a getter that returns our controlled value
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => documentHiddenValue,
});
});
afterEach(() => {
vi.clearAllMocks();
documentHiddenValue = false;
});
describe('initialization', () => {
it('should add blur and focus event listeners', () => {
const helper = new WindowVisibilityHelper();
expect(helper.isVisible).toBe(true);
expect(window.addEventListener).toHaveBeenCalledTimes(2);
expect(window.addEventListener).toHaveBeenCalledWith(
'blur',
expect.any(Function)
);
expect(window.addEventListener).toHaveBeenCalledWith(
'focus',
expect.any(Function)
);
});
});
describe('window events', () => {
it('should set isVisible to false on blur', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
});
it('should set isVisible to true on focus', () => {
const helper = new WindowVisibilityHelper();
blurCallback(); // First blur the window
focusCallback(); // Then focus it
expect(helper.isVisible).toBe(true);
});
it('should handle multiple blur/focus events', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
focusCallback();
expect(helper.isVisible).toBe(true);
blurCallback();
expect(helper.isVisible).toBe(false);
});
});
describe('isWindowVisible', () => {
it('should return true when document is visible and window is focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(true);
});
it('should return false when document is hidden', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when both document is hidden and window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
});
});