diff --git a/.changeset/seven-emus-pay.md b/.changeset/seven-emus-pay.md new file mode 100644 index 0000000000000..169c42d5ab542 --- /dev/null +++ b/.changeset/seven-emus-pay.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-services": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/ddp-client": patch +--- + +feat: Improve UI when MAC limits are reached +feat: Limit endpoints on MAC limit reached diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index ebdcdfd43d9bd..e12ebc2d47e92 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -82,7 +82,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast({ - sendMessage(message, previewUrls) { + async sendMessage(message, previewUrls) { check(message, Object); const uid = Meteor.userId(); @@ -118,7 +118,7 @@ Meteor.methods({ } try { - return executeSendMessage(uid, message, previewUrls); + return await executeSendMessage(uid, message, previewUrls); } catch (error: any) { if ((error.error || error.message) === 'error-not-allowed') { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 4f3b4eb6234d3..b4779ce9e7bef 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, Users, LivechatRooms, Subscriptions, Messages } from '@rocket.chat/models'; @@ -326,6 +327,10 @@ API.v1.addRoute( throw new Error('This_conversation_is_already_closed'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); if (!guest) { throw new Error('error-invalid-visitor'); @@ -412,6 +417,10 @@ API.v1.addRoute( throw new Error('error-invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + if (!(await canAccessRoomAsync(room, user))) { throw new Error('error-not-allowed'); } diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index 3eaa91c37c7cf..c32ecaff34159 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms, Users } from '@rocket.chat/models'; import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings'; @@ -34,8 +35,8 @@ API.v1.addRoute( { async delete() { const { rid } = this.urlParams; - const room = await LivechatRooms.findOneById>(rid, { - projection: { open: 1, transcriptRequest: 1 }, + const room = await LivechatRooms.findOneById>(rid, { + projection: { open: 1, transcriptRequest: 1, v: 1 }, }); if (!room?.open) { @@ -45,6 +46,10 @@ API.v1.addRoute( throw new Error('error-transcript-not-requested'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid); return API.v1.success(); diff --git a/apps/meteor/app/livechat/server/api/v1/videoCall.ts b/apps/meteor/app/livechat/server/api/v1/videoCall.ts index 52cd8738bec99..94df06ba418cb 100644 --- a/apps/meteor/app/livechat/server/api/v1/videoCall.ts +++ b/apps/meteor/app/livechat/server/api/v1/videoCall.ts @@ -1,4 +1,5 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Messages, Settings, Rooms } from '@rocket.chat/models'; import { isGETWebRTCCall, isPUTWebRTCCallId } from '@rocket.chat/rest-typings'; @@ -27,6 +28,10 @@ API.v1.addRoute( throw new Error('invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) { + throw new Error('error-mac-limit-reached'); + } + const webrtcCallingAllowed = rcSettings.get('WebRTC_Enabled') === true && rcSettings.get('Omnichannel_call_provider') === 'WebRTC'; if (!webrtcCallingAllowed) { throw new Error('webRTC calling not enabled'); @@ -79,6 +84,10 @@ API.v1.addRoute( throw new Error('invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) { + throw new Error('error-mac-limit-reached'); + } + const call = await Messages.findOneById(callId); if (!call || call.t !== 'livechat_webrtc_video_call') { throw new Error('invalid-callId'); diff --git a/apps/meteor/app/livechat/server/hooks/checkMAC.ts b/apps/meteor/app/livechat/server/hooks/checkMAC.ts new file mode 100644 index 0000000000000..4d0789252b50a --- /dev/null +++ b/apps/meteor/app/livechat/server/hooks/checkMAC.ts @@ -0,0 +1,30 @@ +import { Omnichannel } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; + +import { callbacks } from '../../../../lib/callbacks'; + +callbacks.add('beforeSaveMessage', async (message, room) => { + if (!room || room.t !== 'l') { + return message; + } + + if (isEditedMessage(message)) { + return message; + } + + if (message.token) { + return message; + } + + if (message.t) { + return message; + } + + const canSendMessage = await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom); + if (!canSendMessage) { + throw new Error('error-mac-limit-reached'); + } + + return message; +}); diff --git a/apps/meteor/app/livechat/server/index.ts b/apps/meteor/app/livechat/server/index.ts index 7d91679669531..b6f4e98af6dbb 100644 --- a/apps/meteor/app/livechat/server/index.ts +++ b/apps/meteor/app/livechat/server/index.ts @@ -16,6 +16,7 @@ import './hooks/saveContactLastChat'; import './hooks/saveLastMessageToInquiry'; import './hooks/afterUserActions'; import './hooks/afterAgentRemoved'; +import './hooks/checkMAC'; import './methods/addAgent'; import './methods/addManager'; import './methods/changeLivechatStatus'; diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index b208c9fb5e85f..2e1a77ca7114e 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -1,6 +1,6 @@ // Note: Please don't add any new methods to this file, since its still in js and we are migrating to ts // Please add new methods to LivechatTyped.ts -import { Message } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, @@ -411,6 +411,10 @@ export const Livechat = { throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const { _id, username, name, utcOffset } = user; const transcriptRequest = { requestedAt: new Date(), diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 0d25616bda60b..a23fd5ef7702e 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -1,7 +1,7 @@ import dns from 'dns'; import * as util from 'util'; -import { Message, VideoConf, api } from '@rocket.chat/core-services'; +import { Message, VideoConf, api, Omnichannel } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, IOmnichannelRoomClosingInfo, @@ -521,6 +521,10 @@ class LivechatClass { throw new Error('error-invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const showAgentInfo = settings.get('Livechat_show_agent_info'); const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); const ignoredMessageTypes: MessageTypesValues[] = [ diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index aed0061e808ef..fffeae4d2f298 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatInquiryRecord, ILivechatVisitor, IMessage, IOmnichannelRoom, SelectedAgent } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; @@ -20,6 +21,14 @@ export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`); await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); + const room = await LivechatRooms.findOneById(inquiry.rid, { projection: { v: 1 } }); + if (!room || !(await Omnichannel.isWithinMACLimit(room))) { + logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry }); + // We'll queue these inquiries so when new license is applied, they just start rolling again + // Minimizing disruption + await saveQueueInquiry(inquiry); + return; + } const dbInquiry = await LivechatInquiry.findOneById(inquiry._id); if (!dbInquiry) { diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index f2fd7010eb12e..e9c173d86913b 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -156,6 +156,11 @@ export const RoutingManager: Routing = { await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]); } + if (!room) { + logger.debug(`Cannot assign agent to inquiry ${inquiry._id}: Room not found`); + throw new Meteor.Error('error-room-not-found', 'Room not found'); + } + await dispatchAgentDelegated(rid, agent.agentId); logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`); @@ -173,6 +178,10 @@ export const RoutingManager: Routing = { return false; } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + if (departmentId && departmentId !== department) { logger.debug(`Switching department for inquiry ${inquiry._id} [Current: ${department} | Next: ${departmentId}]`); await updateChatDepartment({ @@ -260,6 +269,11 @@ export const RoutingManager: Routing = { }, async transferRoom(room, guest, transferData) { + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + logger.debug(`Transfering room ${room._id} by ${transferData.transferredBy._id}`); if (transferData.departmentId) { logger.debug(`Transfering room ${room._id} to department ${transferData.departmentId}`); return forwardRoomToDepartment(room, guest, transferData); diff --git a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts index 0c12d0df52756..38b58b9d2d426 100644 --- a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatDepartment, IRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -29,6 +30,10 @@ Meteor.methods({ }); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:returnAsInquiry' }); + } + if (!room.open) { throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnAsInquiry' }); } diff --git a/apps/meteor/app/livechat/server/methods/takeInquiry.ts b/apps/meteor/app/livechat/server/methods/takeInquiry.ts index 17007d7da8c2a..3433b4a33ae86 100644 --- a/apps/meteor/app/livechat/server/methods/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/takeInquiry.ts @@ -1,4 +1,5 @@ -import { LivechatInquiry, Users } from '@rocket.chat/models'; +import { Omnichannel } from '@rocket.chat/core-services'; +import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -48,6 +49,11 @@ export const takeInquiry = async ( }); } + const room = await LivechatRooms.findOneById(inquiry.rid); + if (!room || !(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const agent = { agentId: user._id, username: user.username, diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index 16ee1abc61915..64a32c24638c3 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -49,6 +50,10 @@ Meteor.methods({ throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:transfer' }); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:transfer' }); + } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { projection: { _id: 1 }, }); diff --git a/apps/meteor/client/contexts/OmnichannelContext.ts b/apps/meteor/client/contexts/OmnichannelContext.ts index dc3bf7fdceb4e..9a2c0c1ea206e 100644 --- a/apps/meteor/client/contexts/OmnichannelContext.ts +++ b/apps/meteor/client/contexts/OmnichannelContext.ts @@ -8,6 +8,7 @@ export type OmnichannelContextValue = { agentAvailable: boolean; routeConfig?: OmichannelRoutingConfig; showOmnichannelQueueLink: boolean; + isOverMacLimit: boolean; livechatPriorities: { data: Serialized[]; isLoading: boolean; @@ -22,6 +23,7 @@ export const OmnichannelContext = createContext({ isEnterprise: false, agentAvailable: false, showOmnichannelQueueLink: false, + isOverMacLimit: false, livechatPriorities: { data: [], isLoading: false, diff --git a/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx b/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx new file mode 100644 index 0000000000000..e6ced140e1b55 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx @@ -0,0 +1,6 @@ +import { useOmnichannel } from './useOmnichannel'; + +export const useIsOverMacLimit = (): boolean => { + const { isOverMacLimit } = useOmnichannel(); + return isOverMacLimit; +}; diff --git a/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx b/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx new file mode 100644 index 0000000000000..4de83506f5a96 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx @@ -0,0 +1,23 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, type IOmnichannelGenericRoom, isVoipRoom } from '@rocket.chat/core-typings'; + +import { useIsOverMacLimit } from './useIsOverMacLimit'; + +const getPeriod = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + +export const useIsRoomOverMacLimit = (room: IRoom) => { + const isOverMacLimit = useIsOverMacLimit(); + + if (!isOmnichannelRoom(room) && !isVoipRoom(room)) { + return false; + } + + if (!room.open) { + return false; + } + + const { v: { activity = [] } = {} } = room as IOmnichannelGenericRoom; + + const currentPeriod = getPeriod(new Date()); + return isOverMacLimit && !activity.includes(currentPeriod); +}; diff --git a/apps/meteor/client/providers/OmnichannelProvider.tsx b/apps/meteor/client/providers/OmnichannelProvider.tsx index d9eee0ac4b00a..6e7cf38f8cd58 100644 --- a/apps/meteor/client/providers/OmnichannelProvider.tsx +++ b/apps/meteor/client/providers/OmnichannelProvider.tsx @@ -19,6 +19,7 @@ import { useHasLicenseModule } from '../../ee/client/hooks/useHasLicenseModule'; import { ClientLogger } from '../../lib/ClientLogger'; import type { OmnichannelContextValue } from '../contexts/OmnichannelContext'; import { OmnichannelContext } from '../contexts/OmnichannelContext'; +import { useLicense } from '../hooks/useLicense'; import { useReactiveValue } from '../hooks/useReactiveValue'; const emptyContextValue: OmnichannelContextValue = { @@ -27,6 +28,7 @@ const emptyContextValue: OmnichannelContextValue = { isEnterprise: false, agentAvailable: false, showOmnichannelQueueLink: false, + isOverMacLimit: false, livechatPriorities: { enabled: false, data: [], @@ -63,6 +65,7 @@ const OmnichannelProvider: FC = ({ children }) => { const subscribe = useStream('notify-logged'); const queryClient = useQueryClient(); const isPrioritiesEnabled = isEnterprise && accessible; + const enabled = accessible && !!user && !!routeConfig; const { data: { priorities = [] } = {}, @@ -73,6 +76,10 @@ const OmnichannelProvider: FC = ({ children }) => { enabled: isPrioritiesEnabled, }); + const { data: { preventedActions } = {} } = useLicense(); + + const isOverMacLimit = Boolean(preventedActions?.monthlyActiveContacts); + useEffect(() => { if (!isPrioritiesEnabled) { return; @@ -102,7 +109,6 @@ const OmnichannelProvider: FC = ({ children }) => { } }, [accessible, getRoutingConfig, iceServersSetting, omnichannelRouting, setRouteConfig, voipCallAvailable]); - const enabled = accessible && !!user && !!routeConfig; const manuallySelected = enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable; @@ -167,6 +173,7 @@ const OmnichannelProvider: FC = ({ children }) => { voipCallAvailable, routeConfig, livechatPriorities, + isOverMacLimit, }; } @@ -185,6 +192,7 @@ const OmnichannelProvider: FC = ({ children }) => { : { enabled: false }, showOmnichannelQueueLink: showOmnichannelQueueLink && !!agentAvailable, livechatPriorities, + isOverMacLimit, }; }, [ enabled, @@ -199,6 +207,7 @@ const OmnichannelProvider: FC = ({ children }) => { routeConfig, queue, showOmnichannelQueueLink, + isOverMacLimit, ]); return ; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index f275ff2800d82..4c51b8a3615b8 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -7,10 +7,10 @@ import type { AllHTMLAttributes, ComponentType, ReactElement, ReactNode } from ' import React, { memo, useMemo } from 'react'; import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; -import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import RoomMenu from '../RoomMenu'; +import { OmnichannelBadges } from '../badges/OmnichannelBadges'; import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; import { normalizeSidebarMessage } from './normalizeSidebarMessage'; @@ -170,7 +170,7 @@ function SideBarItemTemplateWithData({ {unread + tunread?.length} )} - {isOmnichannelRoom(room) && isPriorityEnabled && } + {isOmnichannelRoom(room) && } ); diff --git a/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx b/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx new file mode 100644 index 0000000000000..32fff81d7bb38 --- /dev/null +++ b/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx @@ -0,0 +1,22 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { RoomActivityIcon } from '../../../ee/client/omnichannel/components/RoomActivityIcon'; +import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; +import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon'; + +export const OmnichannelBadges = ({ room }: { room: ISubscription & IRoom }) => { + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + if (!isOmnichannelRoom(room)) { + return null; + } + + return ( + <> + {isPriorityEnabled ? : null} + + + ); +}; diff --git a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx index e7dec5f3506a5..c4538166bba0f 100644 --- a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx +++ b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx @@ -4,7 +4,9 @@ import { useLayout, useRoute, usePermission, useTranslation } from '@rocket.chat import React, { memo } from 'react'; import { useIsCallEnabled, useIsCallReady } from '../../contexts/CallContext'; +import { useIsOverMacLimit } from '../../hooks/omnichannel/useIsOverMacLimit'; import { useOmnichannelShowQueueLink } from '../../hooks/omnichannel/useOmnichannelShowQueueLink'; +import { OverMacLimitSection } from './OverMacLimitSection'; import { OmniChannelCallDialPad, OmnichannelCallToggle, OmnichannelLivechatToggle } from './actions'; const OmnichannelSection = () => { @@ -16,6 +18,7 @@ const OmnichannelSection = () => { const { sidebar } = useLayout(); const directoryRoute = useRoute('omnichannel-directory'); const queueListRoute = useRoute('livechat-queue'); + const isWorkspaceOverMacLimit = useIsOverMacLimit(); const handleRoute = useMutableCallback((route) => { sidebar.toggle(); @@ -32,25 +35,29 @@ const OmnichannelSection = () => { // The className is a paliative while we make TopBar.ToolBox optional on fuselage return ( - - {t('Omnichannel')} - - {showOmnichannelQueueLink && ( - handleRoute('queue')} /> - )} - {isCallEnabled && } - - {hasPermissionToSeeContactCenter && ( - handleRoute('directory')} - /> - )} - {isCallReady && } - - + <> + {isWorkspaceOverMacLimit && } + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + ); }; diff --git a/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx new file mode 100644 index 0000000000000..53cbd0340339d --- /dev/null +++ b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx @@ -0,0 +1,21 @@ +import { Icon, SidebarBanner } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +export const OverMacLimitSection = (): ReactElement => { + const t = useTranslation(); + + const handleClick = () => { + window.open('https://rocket.chat/pricing', '_blank'); + }; + + return ( + } + onClick={handleClick} + /> + ); +}; diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index d82498ff1e504..0194290432cdb 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -1,4 +1,4 @@ -import { Pagination } from '@rocket.chat/fuselage'; +import { Banner, Icon, Pagination } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings'; import { usePermission, useTranslation } from '@rocket.chat/ui-contexts'; @@ -7,6 +7,7 @@ import moment from 'moment'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react'; +import { RoomActivityIcon } from '../../../../ee/client/omnichannel/components/RoomActivityIcon'; import { useOmnichannelPriorities } from '../../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; import { PriorityIcon } from '../../../../ee/client/omnichannel/priorities/PriorityIcon'; import GenericNoResults from '../../../components/GenericNoResults'; @@ -22,6 +23,7 @@ import { import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../components/GenericTable/hooks/useSort'; import Page from '../../../components/Page'; +import { useIsOverMacLimit } from '../../../hooks/omnichannel/useIsOverMacLimit'; import CustomFieldsList from './CustomFieldsList'; import FilterByText from './FilterByText'; import RemoveChatButton from './RemoveChatButton'; @@ -118,6 +120,7 @@ const currentChatQuery: useQueryType = ( }; const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: string) => void }): ReactElement => { + const isWorkspaceOverMacLimit = useIsOverMacLimit(); const { sortBy, sortDirection, setSort } = useSort<'fname' | 'departmentId' | 'servedBy' | 'priorityWeight' | 'ts' | 'lm' | 'open'>( 'ts', 'desc', @@ -165,7 +168,8 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s }); const renderRow = useCallback( - ({ _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight }) => { + (room) => { + const { _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight } = room; const getStatusText = (open: boolean, onHold: boolean): string => { if (!open) return t('Closed'); return onHold ? t('On_Hold_Chats') : t('Open'); @@ -194,7 +198,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s {moment(lm).format('L LTS')} - {getStatusText(open, onHold)} + {getStatusText(open, onHold)} {canRemoveClosedChats && !open && } @@ -301,6 +305,17 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s hasCustomFields={hasCustomFields} /> )} + {isWorkspaceOverMacLimit && ( + } + title={t('The_workspace_has_exceeded_the_monthly_limit_of_active_contacts')} + style={{ marginBlock: '2rem' }} + > + {t('Talk_to_your_workspace_admin_to_address_this_issue')} + + )} {isSuccess && data?.rooms.length === 0 && queryHasChanged && } {isSuccess && data?.rooms.length === 0 && !queryHasChanged && ( { switch (id) { case QuickActionsEnum.MoveQueue: - return !!roomOpen && canMoveQueue; + return !isRoomOverMacLimit && !!roomOpen && canMoveQueue; case QuickActionsEnum.ChatForward: - return !!roomOpen && canForwardGuest; + return !isRoomOverMacLimit && !!roomOpen && canForwardGuest; case QuickActionsEnum.Transcript: - return canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF); + return !isRoomOverMacLimit && (canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF)); case QuickActionsEnum.TranscriptEmail: - return canSendTranscriptEmail; + return !isRoomOverMacLimit && canSendTranscriptEmail; case QuickActionsEnum.TranscriptPDF: - return hasLicense && canSendTranscriptPDF; + return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF; case QuickActionsEnum.CloseChat: return !!roomOpen && (canCloseRoom || canCloseOthersRoom); case QuickActionsEnum.OnHoldChat: diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index bd89a3d72a60a..2df567e77fb07 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import type React from 'react'; import { useCallback, useMemo } from 'react'; +import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useChat } from '../../contexts/ChatContext'; @@ -24,6 +25,8 @@ export const useFileUploadDropTarget = (): readonly [ const room = useRoom(); const { triggerProps, overlayProps } = useDropTarget(); + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + const t = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled') as boolean; @@ -46,7 +49,7 @@ export const useFileUploadDropTarget = (): readonly [ }); const allOverlayProps = useMemo(() => { - if (!fileUploadEnabled) { + if (!fileUploadEnabled || isRoomOverMacLimit) { return { enabled: false, reason: t('FileUpload_Disabled'), @@ -67,7 +70,7 @@ export const useFileUploadDropTarget = (): readonly [ onFileDrop, ...overlayProps, } as const; - }, [fileUploadAllowedForUser, fileUploadEnabled, onFileDrop, overlayProps, t]); + }, [fileUploadAllowedForUser, fileUploadEnabled, isRoomOverMacLimit, onFileDrop, overlayProps, t]); return [triggerProps, allOverlayProps] as const; }; diff --git a/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx b/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx index 649f9a9a4264b..5ba9b580e1092 100644 --- a/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx +++ b/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx @@ -3,6 +3,7 @@ import { useTranslation, useUserId } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit'; import { useOmnichannelRoom, useUserIsSubscribed } from '../../contexts/RoomContext'; import type { ComposerMessageProps } from '../ComposerMessage'; import ComposerMessage from '../ComposerMessage'; @@ -11,7 +12,8 @@ import { ComposerOmnichannelJoin } from './ComposerOmnichannelJoin'; import { ComposerOmnichannelOnHold } from './ComposerOmnichannelOnHold'; const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => { - const { servedBy, queuedAt, open, onHold } = useOmnichannelRoom(); + const room = useOmnichannelRoom(); + const { servedBy, queuedAt, open, onHold } = room; const userId = useUserId(); const isSubscribed = useUserIsSubscribed(); @@ -22,8 +24,14 @@ const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => { const isSameAgent = servedBy?._id === userId; + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + if (!open) { - return {t('This_conversation_is_already_closed')}; + return {t('This_conversation_is_already_closed')}; + } + + if (isRoomOverMacLimit) { + return {t('Workspace_exceeded_MAC_limit_disclaimer')}; } if (onHold) { diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index fc6b693e0441d..422e453b591ce 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -1,8 +1,9 @@ import { api } from '@rocket.chat/core-services'; import type { LicenseLimitKind } from '@rocket.chat/license'; import { License } from '@rocket.chat/license'; -import { Subscriptions, Users, Settings } from '@rocket.chat/models'; +import { Subscriptions, Users, Settings, LivechatVisitors } from '@rocket.chat/models'; import { wrapExceptions } from '@rocket.chat/tools'; +import moment from 'moment'; import { syncWorkspace } from '../../../../app/cloud/server/functions/syncWorkspace'; import { settings } from '../../../../app/settings/server'; @@ -122,5 +123,4 @@ License.setLicenseLimitCounter('guestUsers', () => Users.getActiveLocalGuestCoun License.setLicenseLimitCounter('roomsPerGuest', async (context) => (context?.userId ? Subscriptions.countByUserId(context.userId) : 0)); License.setLicenseLimitCounter('privateApps', () => getAppCount('private')); License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace')); -// #TODO: Get real value -License.setLicenseLimitCounter('monthlyActiveContacts', async () => 0); +License.setLicenseLimitCounter('monthlyActiveContacts', async () => LivechatVisitors.countVisitorsOnPeriod(moment.utc().format('YYYY-MM'))); diff --git a/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx b/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx new file mode 100644 index 0000000000000..5db68f559fdb4 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx @@ -0,0 +1,20 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useIsRoomOverMacLimit } from '../../../../../client/hooks/omnichannel/useIsRoomOverMacLimit'; + +type RoomActivityIconProps = { + room: IOmnichannelRoom; +}; + +export const RoomActivityIcon = ({ room }: RoomActivityIconProps): ReactElement | null => { + const t = useTranslation(); + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + + return isRoomOverMacLimit ? ( + + ) : null; +}; diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx index 1f5bd2e7c5399..0c9f40f7c955c 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx @@ -17,7 +17,7 @@ export default { export const Default: ComponentStory = (args) => ; Default.storyName = 'CannedResponse'; Default.args = { - canEdit: true, + allowEdit: true, data: { shortcut: 'test3 long long long long long long long long long', text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long', diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx index 05d6895d5dfcc..90d6a4523cb7f 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx @@ -15,7 +15,8 @@ import { import { useScopeDict } from '../../../hooks/useScopeDict'; const CannedResponse: FC<{ - canEdit: boolean; + allowEdit: boolean; + allowUse: boolean; data: { departmentName: ILivechatDepartment['name']; shortcut: IOmnichannelCannedResponse['shortcut']; @@ -26,7 +27,7 @@ const CannedResponse: FC<{ onClickBack: MouseEventHandler; onClickEdit: MouseEventHandler; onClickUse: MouseEventHandler; -}> = ({ canEdit, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => { +}> = ({ allowEdit, allowUse, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => { const t = useTranslation(); const scope = useScopeDict(dataScope, departmentName); @@ -84,8 +85,8 @@ const CannedResponse: FC<{ - {canEdit && } - } + diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx index 1a41402368d56..782511de3c4e7 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx @@ -30,6 +30,7 @@ const CannedResponseList: FC<{ setText: FormEventHandler; type: string; setType: Dispatch>; + isRoomOverMacLimit: boolean; onClickItem: (data: any) => void; onClickCreate: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; @@ -45,6 +46,7 @@ const CannedResponseList: FC<{ setText, type, setType, + isRoomOverMacLimit, onClickItem, onClickCreate, onClickUse, @@ -98,6 +100,7 @@ const CannedResponseList: FC<{ itemContent={(_index, data): ReactElement => ( { onClickItem(data); }} @@ -112,6 +115,7 @@ const CannedResponseList: FC<{ {cannedId && ( canned._id === (cannedId as unknown))} onClickBack={onClickItem} onClickUse={onClickUse} diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx index 3c1dfa304f79f..bcb6a7d9949f6 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx @@ -9,9 +9,10 @@ import { useScopeDict } from '../../../hooks/useScopeDict'; const Item: FC<{ data: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; + allowUse?: boolean; onClickItem: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; -}> = ({ data, onClickItem, onClickUse }) => { +}> = ({ data, allowUse, onClickItem, onClickUse }) => { const t = useTranslation(); const scope = useScopeDict(data.scope, data.departmentName); @@ -47,7 +48,7 @@ const Item: FC<{