diff --git a/.changeset/seven-emus-pay.md b/.changeset/seven-emus-pay.md new file mode 100644 index 000000000000..169c42d5ab54 --- /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 ebdcdfd43d9b..e12ebc2d47e9 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 4f3b4eb6234d..b4779ce9e7be 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 3eaa91c37c7c..c32ecaff3415 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 52cd8738bec9..94df06ba418c 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 000000000000..4d0789252b50 --- /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 7d9167966953..b6f4e98af6db 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 b208c9fb5e85..2e1a77ca7114 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 0d25616bda60..a23fd5ef7702 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 aed0061e808e..fffeae4d2f29 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 f2fd7010eb12..e9c173d86913 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 0c12d0df5275..38b58b9d2d42 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 17007d7da8c2..3433b4a33ae8 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 16ee1abc6191..64a32c24638c 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 dc3bf7fdceb4..9a2c0c1ea206 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 000000000000..e6ced140e1b5 --- /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 000000000000..4de83506f5a9 --- /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 d9eee0ac4b00..6e7cf38f8cd5 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 f275ff2800d8..4c51b8a3615b 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 000000000000..32fff81d7bb3 --- /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 e7dec5f3506a..c4538166bba0 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 000000000000..53cbd0340339 --- /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 d82498ff1e50..0194290432cd 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 bd89a3d72a60..2df567e77fb0 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 649f9a9a4264..5ba9b580e109 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 fc6b693e0441..422e453b591c 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 000000000000..5db68f559fdb --- /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 1f5bd2e7c539..0c9f40f7c955 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 05d6895d5dfc..90d6a4523cb7 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 1a41402368d5..782511de3c4e 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 3c1dfa304f79..bcb6a7d9949f 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<{