From e0a350cff7e31d5e80bb54244b6a2899b012ccbb Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 2 Jul 2024 13:05:51 -0300 Subject: [PATCH 001/173] feat: Current Chats deprecation warning (#32675) * feat: add warning deprecation * chore: changeset --- .changeset/silly-birds-mix.md | 6 ++++++ .../omnichannel/currentChats/CurrentChatsPage.tsx | 14 ++++++++++++-- packages/i18n/src/locales/en.i18n.json | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/silly-birds-mix.md diff --git a/.changeset/silly-birds-mix.md b/.changeset/silly-birds-mix.md new file mode 100644 index 000000000000..c7444bbfc713 --- /dev/null +++ b/.changeset/silly-birds-mix.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds deprecation warning in omnichannel current chats page diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index 95fb9a54c3ce..e449aa07a3ba 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -1,11 +1,12 @@ import { Callout, 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'; +import { usePermission } from '@rocket.chat/ui-contexts'; import { hashQueryKey } from '@tanstack/react-query'; import moment from 'moment'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import GenericNoResults from '../../../components/GenericNoResults'; import { @@ -127,7 +128,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s ); const [customFields, setCustomFields] = useState<{ [key: string]: string }>(); - const t = useTranslation(); + const { t } = useTranslation(); const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); @@ -297,6 +298,15 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s + + + Manage conversations in the + + contact center + + . + + {((isSuccess && data?.rooms.length > 0) || queryHasChanged) && ( ['setFilter']} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 46b91babb6fd..9007b9582d9d 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4349,6 +4349,7 @@ "Read_Receipts": "Read receipts", "Readability": "Readability", "This_room_is_read_only": "This room is read only", + "This_page_will_be_deprecated_soon": "This page will be deprecated soon", "Only_people_with_permission_can_send_messages_here": "Only people with permission can send messages here", "Read_only_changed_successfully": "Read only changed successfully", "Read_only_channel": "Read Only Channel", @@ -6374,6 +6375,7 @@ "trial": "trial", "Subscription": "Subscription", "Manage_subscription": "Manage subscription", + "Manage_conversations_in_the_contact_center": "Manage conversations in the <1>contact center.", "ActiveSessionsPeak": "Active sessions peak", "ActiveSessionsPeak_InfoText": "Highest amount of active connections in the past 30 days", "ActiveSessions": "Active sessions", From 9f7654fc431fb78598ff79f44f086eb919fb6b2e Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 4 Jul 2024 13:00:16 -0300 Subject: [PATCH 002/173] feat: improve contact center routing (#32718) --- .../useContactProfileRoomAction.ts | 4 +- apps/meteor/client/startup/routes.tsx | 12 ++--- .../directory/CallsContextualBarDirectory.tsx | 15 +++--- .../directory/ChatsContextualBar.tsx | 35 ++++++-------- .../directory/ContactContextualBar.tsx | 27 +++++------ ...textualBar.tsx => ContextualBarRouter.tsx} | 13 ++---- .../directory/OmnichannelDirectoryPage.tsx | 41 ++++++++--------- .../directory/OmnichannelDirectoryRouter.tsx | 17 +++++++ .../omnichannel/directory/calls/CallTable.tsx | 4 +- .../omnichannel/directory/chats/ChatTable.tsx | 4 +- .../chats/contextualBar/ChatInfoDirectory.js | 18 ++------ .../directory/contacts/ContactTable.tsx | 8 ++-- .../ContactInfo.tsx | 46 ++++++------------- .../ContactInfoRouter.tsx} | 12 ++--- .../EditContactInfo.tsx} | 10 ++-- .../contactInfo/EditContactInfoWithData.tsx | 30 ++++++++++++ .../contextualBar/ContactEditWithData.js | 33 ------------- .../directory/hooks/useContactRoute.ts | 45 ++++++++++++++++++ .../room/Header/Omnichannel/BackButton.tsx | 2 +- 19 files changed, 193 insertions(+), 183 deletions(-) rename apps/meteor/client/views/omnichannel/directory/{ContextualBar.tsx => ContextualBarRouter.tsx} (66%) create mode 100644 apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryRouter.tsx rename apps/meteor/client/views/omnichannel/directory/contacts/{contextualBar => contactInfo}/ContactInfo.tsx (89%) rename apps/meteor/client/views/omnichannel/directory/contacts/{contextualBar/ContactsContextualBar.tsx => contactInfo/ContactInfoRouter.tsx} (82%) rename apps/meteor/client/views/omnichannel/directory/contacts/{contextualBar/ContactNewEdit.tsx => contactInfo/EditContactInfo.tsx} (97%) create mode 100644 apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/EditContactInfoWithData.tsx delete mode 100644 apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactEditWithData.js create mode 100644 apps/meteor/client/views/omnichannel/directory/hooks/useContactRoute.ts diff --git a/apps/meteor/client/hooks/roomActions/useContactProfileRoomAction.ts b/apps/meteor/client/hooks/roomActions/useContactProfileRoomAction.ts index 17dbc3425493..a78b9f4be261 100644 --- a/apps/meteor/client/hooks/roomActions/useContactProfileRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useContactProfileRoomAction.ts @@ -2,7 +2,7 @@ import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -const ContactsContextualBar = lazy(() => import('../../views/omnichannel/directory/contacts/contextualBar/ContactsContextualBar')); +const ContactInfoRouter = lazy(() => import('../../views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter')); export const useContactProfileRoomAction = () => { return useMemo( @@ -11,7 +11,7 @@ export const useContactProfileRoomAction = () => { groups: ['live' /* , 'voip'*/], title: 'Contact_Info', icon: 'user', - tabComponent: ContactsContextualBar, + tabComponent: ContactInfoRouter, order: 1, }), [], diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 5b204a7cf18e..e25a6795da53 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -8,7 +8,7 @@ const IndexRoute = lazy(() => import('../views/root/IndexRoute')); const MeetRoute = lazy(() => import('../views/meet/MeetRoute')); const HomePage = lazy(() => import('../views/home/HomePage')); const DirectoryPage = lazy(() => import('../views/directory')); -const OmnichannelDirectoryPage = lazy(() => import('../views/omnichannel/directory/OmnichannelDirectoryPage')); +const OmnichannelDirectoryRouter = lazy(() => import('../views/omnichannel/directory/OmnichannelDirectoryRouter')); const OmnichannelQueueList = lazy(() => import('../views/omnichannel/queueList')); const CMSPage = lazy(() => import('@rocket.chat/web-ui-registration').then(({ CMSPage }) => ({ default: CMSPage }))); const SecretURLPage = lazy(() => import('../views/invite/SecretURLPage')); @@ -48,10 +48,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/directory/:tab?'; }; 'omnichannel-directory': { - pathname: `/omnichannel-directory${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${ - | `/${string}` - | ''}`; - pattern: '/omnichannel-directory/:page?/:bar?/:id?/:tab?/:context?'; + pathname: `/omnichannel-directory${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`; + pattern: '/omnichannel-directory/:tab?/:context?/:id?/'; }; 'livechat-queue': { pathname: '/livechat-queue'; @@ -153,11 +151,11 @@ router.defineRoutes([ ), }, { - path: '/omnichannel-directory/:page?/:bar?/:id?/:tab?/:context?', + path: '/omnichannel-directory/:tab?/:context?/:id?/', id: 'omnichannel-directory', element: appLayout.wrap( - + , ), }, diff --git a/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx b/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx index bf35c50a6bf3..1cbac4917d0c 100644 --- a/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx +++ b/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx @@ -1,7 +1,6 @@ import type { IVoipRoom } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { useRoute, useRouteParameter, useSearchParameter, useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React, { useMemo } from 'react'; import { Contextualbar } from '../../../components/Contextualbar'; @@ -11,17 +10,17 @@ import Call from './calls/Call'; import { VoipInfo } from './calls/contextualBar/VoipInfo'; import { FormSkeleton } from './components/FormSkeleton'; -const CallsContextualBarDirectory: FC = () => { - const directoryRoute = useRoute('omnichannel-directory'); +const CallsContextualBarDirectory = () => { + const t = useTranslation(); - const bar = useRouteParameter('bar') || 'info'; const id = useRouteParameter('id'); const token = useSearchParameter('token'); + const context = useRouteParameter('context'); - const t = useTranslation(); + const directoryRoute = useRoute('omnichannel-directory'); const handleClose = (): void => { - directoryRoute.push({ page: 'calls' }); + directoryRoute.push({ tab: 'calls' }); }; const query = useMemo( @@ -34,7 +33,7 @@ const CallsContextualBarDirectory: FC = () => { const { value: data, phase: state, error } = useEndpointData(`/v1/voip/room`, { params: query }); - if (bar === 'view' && id) { + if (context === 'view' && id) { return ; } @@ -52,7 +51,7 @@ const CallsContextualBarDirectory: FC = () => { const room = data.room as unknown as IVoipRoom; // TODO Check why types are incompatible even though the endpoint returns an IVoipRooms - return {bar === 'info' && }; + return {context === 'info' && }; }; export default CallsContextualBarDirectory; diff --git a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx index 298551c61142..b7bad83f87e1 100644 --- a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx @@ -1,6 +1,5 @@ import { Box } from '@rocket.chat/fuselage'; import { useRoute, useRouteParameter, useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import { @@ -17,29 +16,23 @@ import { RoomEditWithData } from './chats/contextualBar/RoomEdit'; import { FormSkeleton } from './components'; import { useOmnichannelRoomInfo } from './hooks/useOmnichannelRoomInfo'; -const ChatsContextualBar: FC<{ chatReload?: () => void }> = ({ chatReload }) => { +const ChatsContextualBar = ({ chatReload }: { chatReload?: () => void }) => { + const t = useTranslation(); + const directoryRoute = useRoute('omnichannel-directory'); - const bar = useRouteParameter('bar') || 'info'; + const context = useRouteParameter('context'); const id = useRouteParameter('id') || ''; - const t = useTranslation(); - - const openInRoom = (): void => { - id && directoryRoute.push({ page: 'chats', id, bar: 'view' }); - }; + const openInRoom = () => id && directoryRoute.push({ tab: 'chats', id, context: 'view' }); - const handleChatsContextualbarCloseButtonClick = (): void => { - directoryRoute.push({ page: 'chats' }); - }; + const handleClose = () => directoryRoute.push({ tab: 'chats' }); - const handleChatsContextualbarBackButtonClick = (): void => { - id && directoryRoute.push({ page: 'chats', id, bar: 'info' }); - }; + const handleCancel = () => id && directoryRoute.push({ tab: 'chats', id, context: 'info' }); const { data: room, isLoading, isError, refetch: reloadInfo } = useOmnichannelRoomInfo(id); - if (bar === 'view' && id) { + if (context === 'view' && id) { return ; } @@ -58,25 +51,23 @@ const ChatsContextualBar: FC<{ chatReload?: () => void }> = ({ chatReload }) => return ( - {bar === 'info' && ( + {context === 'info' && ( <> {t('Room_Info')} )} - {bar === 'edit' && ( + {context === 'edit' && ( <> {t('edit-room')} )} - + - {bar === 'info' && } - {bar === 'edit' && ( - - )} + {context === 'info' && } + {context === 'edit' && id && } ); }; diff --git a/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx index ee43c29a9404..2ab1694ef165 100644 --- a/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx @@ -8,9 +8,9 @@ import { ContextualbarTitle, ContextualbarClose, } from '../../../components/Contextualbar'; -import ContactEditWithData from './contacts/contextualBar/ContactEditWithData'; -import ContactInfo from './contacts/contextualBar/ContactInfo'; -import ContactNewEdit from './contacts/contextualBar/ContactNewEdit'; +import ContactInfo from './contacts/contactInfo/ContactInfo'; +import EditContactInfo from './contacts/contactInfo/EditContactInfo'; +import EditContactInfoWithData from './contacts/contactInfo/EditContactInfoWithData'; const HEADER_OPTIONS = { new: { icon: 'user', title: 'New_contact' }, @@ -21,18 +21,19 @@ const HEADER_OPTIONS = { type BarOptions = keyof typeof HEADER_OPTIONS; const ContactContextualBar = () => { + const t = useTranslation(); + const directoryRoute = useRoute('omnichannel-directory'); const bar = (useRouteParameter('bar') || 'info') as BarOptions; const contactId = useRouteParameter('id') || ''; + const context = useRouteParameter('context'); - const t = useTranslation(); - - const handleContactsContextualbarCloseButtonClick = () => { - directoryRoute.push({ page: 'contacts' }); + const handleClose = () => { + directoryRoute.push({ tab: 'contacts' }); }; - const handleContactsContextualbarBackButtonClick = () => { - directoryRoute.push({ page: 'contacts', id: contactId, bar: 'info' }); + const handleCancel = () => { + directoryRoute.push({ tab: 'contacts', context: 'info', id: contactId }); }; const header = useMemo(() => HEADER_OPTIONS[bar] || HEADER_OPTIONS.info, [bar]); @@ -42,11 +43,11 @@ const ContactContextualBar = () => { {t(header.title)} - + - {bar === 'new' && } - {bar === 'info' && } - {bar === 'edit' && } + {context === 'new' && } + {context === 'edit' && } + {context !== 'new' && context !== 'edit' && } ); }; diff --git a/apps/meteor/client/views/omnichannel/directory/ContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx similarity index 66% rename from apps/meteor/client/views/omnichannel/directory/ContextualBar.tsx rename to apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx index d0596ef8e7ec..6ffd6383c9df 100644 --- a/apps/meteor/client/views/omnichannel/directory/ContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx @@ -1,19 +1,14 @@ import { useRouteParameter } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import CallsContextualBarDirectory from './CallsContextualBarDirectory'; import ChatsContextualBar from './ChatsContextualBar'; import ContactContextualBar from './ContactContextualBar'; -type ContextualBarProps = { - chatReload?: () => void; -}; - -const ContextualBar: FC = ({ chatReload }) => { - const page = useRouteParameter('page'); +const ContextualBarRouter = ({ chatReload }: { chatReload?: () => void }) => { + const tab = useRouteParameter('tab'); - switch (page) { + switch (tab) { case 'contacts': return ; case 'chats': @@ -25,4 +20,4 @@ const ContextualBar: FC = ({ chatReload }) => { } }; -export default ContextualBar; +export default ContextualBarRouter; diff --git a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx index 08b344ae3a9f..dff1cd467b02 100644 --- a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx +++ b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx @@ -1,71 +1,66 @@ import { Tabs } from '@rocket.chat/fuselage'; -import { useRouteParameter, usePermission, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import { useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; import React, { useEffect, useCallback } from 'react'; import { ContextualbarDialog } from '../../../components/Contextualbar'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import { queryClient } from '../../../lib/queryClient'; -import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; -import ContextualBar from './ContextualBar'; +import ContextualBarRouter from './ContextualBarRouter'; import CallTab from './calls/CallTab'; import ChatTab from './chats/ChatTab'; import ContactTab from './contacts/ContactTab'; const DEFAULT_TAB = 'contacts'; -const OmnichannelDirectoryPage = (): ReactElement => { +const OmnichannelDirectoryPage = () => { const t = useTranslation(); const router = useRouter(); - const page = useRouteParameter('page'); - const bar = useRouteParameter('bar'); - const canViewDirectory = usePermission('view-omnichannel-contact-center'); + const tab = useRouteParameter('tab'); + const context = useRouteParameter('context'); useEffect( () => router.subscribeToRouteChange(() => { - if (router.getRouteName() !== 'omnichannel-directory' || !!router.getRouteParameters().page) { + if (router.getRouteName() !== 'omnichannel-directory' || !!router.getRouteParameters().tab) { return; } router.navigate({ name: 'omnichannel-directory', - params: { page: DEFAULT_TAB }, + params: { tab: DEFAULT_TAB }, }); }), [router], ); - const handleTabClick = useCallback((tab) => () => router.navigate({ name: 'omnichannel-directory', params: { tab } }), [router]); + const handleTabClick = useCallback((tab) => router.navigate({ name: 'omnichannel-directory', params: { tab } }), [router]); const chatReload = () => queryClient.invalidateQueries({ queryKey: ['current-chats'] }); - if (!canViewDirectory) { - return ; - } - return ( - + handleTabClick('contacts')}> {t('Contacts')} - - {t('Chats' as 'color')} + handleTabClick('chats')}> + {t('Chats' as any /* TODO: this is going to change to Conversations */)} - - {t('Calls' as 'color')} + handleTabClick('calls')}> + {t('Calls')} - {(page === 'contacts' && ) || (page === 'chats' && ) || (page === 'calls' && )} + {tab === 'contacts' && } + {tab === 'chats' && } + {tab === 'calls' && } - {bar && ( + {context && ( - + )} diff --git a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryRouter.tsx b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryRouter.tsx new file mode 100644 index 000000000000..3061db9f18eb --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryRouter.tsx @@ -0,0 +1,17 @@ +import { usePermission } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import OmnichannelDirectoryPage from './OmnichannelDirectoryPage'; + +const OmnichannelDirectoryRouter = () => { + const canViewDirectory = usePermission('view-omnichannel-contact-center'); + + if (!canViewDirectory) { + return ; + } + + return ; +}; + +export default OmnichannelDirectoryRouter; diff --git a/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx b/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx index 916a866b0431..671bf659d778 100644 --- a/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx @@ -41,8 +41,8 @@ const CallTable = () => { const onRowClick = useMutableCallback((id, token) => { directoryRoute.push( { - page: 'calls', - bar: 'info', + tab: 'calls', + context: 'info', id, }, { token }, diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx index e11a1c332066..a26ce88521e8 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx @@ -43,8 +43,8 @@ const ChatTable = () => { const onRowClick = useMutableCallback((id) => directoryRoute.push({ - page: 'chats', - bar: 'info', + tab: 'chats', + context: 'info', id, }), ); diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js index 0e47a47d9c9d..25a2374d177f 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js @@ -77,19 +77,11 @@ function ChatInfoDirectory({ id, route = undefined, room }) { return dispatchToastMessage({ type: 'error', message: t('Not_authorized') }); } - routePath.push( - route - ? { - tab: 'room-info', - context: 'edit', - id, - } - : { - page: 'chats', - id, - bar: 'edit', - }, - ); + routePath.push({ + tab: route ? 'room-info' : 'chats', + context: 'edit', + id, + }); }); return ( diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx index 76fdce05c9bb..6b4b9fde00ea 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx @@ -51,17 +51,17 @@ function ContactTable(): ReactElement { const onButtonNewClick = useMutableCallback(() => directoryRoute.push({ - page: 'contacts', - bar: 'new', + tab: 'contacts', + context: 'new', }), ); const onRowClick = useMutableCallback( (id) => (): void => directoryRoute.push({ - page: 'contacts', id, - bar: 'info', + tab: 'contacts', + context: 'info', }), ); diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactInfo.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfo.tsx similarity index 89% rename from apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactInfo.tsx rename to apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfo.tsx index 184006a745c4..8adda1e5afd3 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfo.tsx @@ -1,8 +1,8 @@ import { Box, Margins, ButtonGroup, Button, Divider } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { RouteName } from '@rocket.chat/ui-contexts'; -import { useToastMessageDispatch, useRoute, useTranslation, useEndpoint, usePermission, useRouter } from '@rocket.chat/ui-contexts'; +import { useRoute, useTranslation, useEndpoint, usePermission, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import React, { useCallback } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; @@ -20,6 +20,7 @@ import Info from '../../../components/Info'; import Label from '../../../components/Label'; import { VoipInfoCallButton } from '../../calls/contextualBar/VoipInfoCallButton'; import { FormSkeleton } from '../../components/FormSkeleton'; +import { useContactRoute } from '../../hooks/useContactRoute'; type ContactInfoProps = { id: string; @@ -27,12 +28,11 @@ type ContactInfoProps = { route?: RouteName; }; -const ContactInfo = ({ id: contactId, rid: roomId = '', route }: ContactInfoProps) => { +const ContactInfo = ({ id: contactId }: ContactInfoProps) => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const router = useRouter(); const liveRoute = useRoute('live'); + const dispatchToastMessage = useToastMessageDispatch(); const currentRouteName = useSyncExternalStore( router.subscribeToRouteChange, @@ -44,6 +44,15 @@ const ContactInfo = ({ id: contactId, rid: roomId = '', route }: ContactInfoProp const canViewCustomFields = usePermission('view-livechat-room-customfields'); const canEditContact = usePermission('edit-omnichannel-contact'); + const handleNavigate = useContactRoute(); + + const onEditButtonClick = useEffectEvent(() => { + if (!canEditContact) { + return dispatchToastMessage({ type: 'error', message: t('Not_authorized') }); + } + + handleNavigate({ context: 'edit' }); + }); const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); const { data: { customFields } = {} } = useQuery(['/v1/livechat/custom-fields'], () => getCustomFields()); @@ -57,33 +66,6 @@ const ContactInfo = ({ id: contactId, rid: roomId = '', route }: ContactInfoProp enabled: canViewCustomFields && !!contactId, }); - const onEditButtonClick = useMutableCallback(() => { - if (!canEditContact) { - return dispatchToastMessage({ type: 'error', message: t('Not_authorized') }); - } - - if (route) { - router.navigate({ - name: route, - params: { - tab: 'contact-profile', - context: 'edit', - id: roomId, - }, - }); - return; - } - - router.navigate({ - name: 'omnichannel-directory', - params: { - page: 'contacts', - id: contactId, - bar: 'edit', - }, - }); - }); - if (isInitialLoading) { return ( diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactsContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx similarity index 82% rename from apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactsContextualBar.tsx rename to apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx index ac3322026eb7..9bd7a6efbe5a 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactsContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx @@ -4,22 +4,20 @@ import React from 'react'; import { ContextualbarHeader, ContextualbarIcon, ContextualbarTitle, ContextualbarClose } from '../../../../../components/Contextualbar'; import { useOmnichannelRoom } from '../../../../room/contexts/RoomContext'; import { useRoomToolbox } from '../../../../room/contexts/RoomToolboxContext'; -import ContactEditWithData from './ContactEditWithData'; import ContactInfo from './ContactInfo'; +import ContactEditWithData from './EditContactInfoWithData'; const PATH = 'live'; -const ContactsContextualBar = () => { +const ContactInfoRouter = () => { const t = useTranslation(); - const room = useOmnichannelRoom(); const { closeTab } = useRoomToolbox(); const directoryRoute = useRoute(PATH); - const context = useRouteParameter('context'); - const handleContactEditBarCloseButtonClick = (): void => { + const handleCloseEdit = (): void => { directoryRoute.push({ id: room._id, tab: 'contact-profile' }); }; @@ -45,7 +43,7 @@ const ContactsContextualBar = () => { {context === 'edit' ? ( - + ) : ( )} @@ -53,4 +51,4 @@ const ContactsContextualBar = () => { ); }; -export default ContactsContextualBar; +export default ContactInfoRouter; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/EditContactInfo.tsx similarity index 97% rename from apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx rename to apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/EditContactInfo.tsx index 4d025f1f25b5..c5d8891af820 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/EditContactInfo.tsx @@ -18,7 +18,7 @@ import { useCustomFieldsMetadata } from '../../hooks/useCustomFieldsMetadata'; type ContactNewEditProps = { id: string; data?: { contact: Serialized | null }; - close(): void; + onCancel: () => void; }; type ContactFormData = { @@ -56,7 +56,7 @@ const getInitialValues = (data: ContactNewEditProps['data']): ContactFormData => }; }; -const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement => { +const EditContactInfo = ({ id, data, onCancel }: ContactNewEditProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const queryClient = useQueryClient(); @@ -167,7 +167,7 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement await saveContact(payload); dispatchToastMessage({ type: 'success', message: t('Saved') }); await queryClient.invalidateQueries({ queryKey: ['current-contacts'] }); - close(); + onCancel(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } @@ -210,7 +210,7 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement - - )} - - - - - ); -}; - -export default ContactInfo; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx deleted file mode 100644 index 9bd7a6efbe5a..000000000000 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contactInfo/ContactInfoRouter.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useRoute, useRouteParameter, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import { ContextualbarHeader, ContextualbarIcon, ContextualbarTitle, ContextualbarClose } from '../../../../../components/Contextualbar'; -import { useOmnichannelRoom } from '../../../../room/contexts/RoomContext'; -import { useRoomToolbox } from '../../../../room/contexts/RoomToolboxContext'; -import ContactInfo from './ContactInfo'; -import ContactEditWithData from './EditContactInfoWithData'; - -const PATH = 'live'; - -const ContactInfoRouter = () => { - const t = useTranslation(); - const room = useOmnichannelRoom(); - const { closeTab } = useRoomToolbox(); - - const directoryRoute = useRoute(PATH); - const context = useRouteParameter('context'); - - const handleCloseEdit = (): void => { - directoryRoute.push({ id: room._id, tab: 'contact-profile' }); - }; - - const { - v: { _id }, - } = room; - - return ( - <> - - {(context === 'info' || !context) && ( - <> - - {t('Contact_Info')} - - )} - {context === 'edit' && ( - <> - - {t('Edit_Contact_Profile')} - - )} - - - {context === 'edit' ? ( - - ) : ( - - )} - - ); -}; - -export default ContactInfoRouter; diff --git a/apps/meteor/client/views/omnichannel/directory/hooks/useContactRoute.ts b/apps/meteor/client/views/omnichannel/hooks/useContactRoute.ts similarity index 95% rename from apps/meteor/client/views/omnichannel/directory/hooks/useContactRoute.ts rename to apps/meteor/client/views/omnichannel/hooks/useContactRoute.ts index 21de6c8f379f..1349efd48a02 100644 --- a/apps/meteor/client/views/omnichannel/directory/hooks/useContactRoute.ts +++ b/apps/meteor/client/views/omnichannel/hooks/useContactRoute.ts @@ -37,7 +37,7 @@ export const useContactRoute = () => { useEffect(() => { if (!currentParams.context) { - handleNavigate({ context: 'info' }); + handleNavigate({ context: 'details' }); } }, [currentParams.context, handleNavigate]); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index d0e7b7133423..4482b482ba37 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -37,7 +37,7 @@ const URL = { return `${this.contactCenter}/edit/${NEW_CONTACT.id}`; }, get contactInfo() { - return `${this.contactCenter}/info/${NEW_CONTACT.id}`; + return `${this.contactCenter}/details/${NEW_CONTACT.id}`; }, }; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 12c400611d2d..875481c13cb9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -208,7 +208,7 @@ export class HomeContent { } get btnContactEdit(): Locator { - return this.page.locator('.rcx-vertical-bar button:has-text("Edit")'); + return this.page.getByRole('dialog').getByRole('button', { name: 'Edit', exact: true }); } get inputModalClosingComment(): Locator { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9007b9582d9d..608b787fa530 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3839,7 +3839,8 @@ "No_channels_in_team": "No Channels on this Team", "No_agents_yet": "No agents yet", "No_agents_yet_description": "Add agents to engage with your audience and provide optimized customer service.", - "No_channels_yet": "You aren't part of any channels yet", + "No_channels_yet": "No channels yet", + "No_channels_yet_description": "Channels associated to this contact will appear here.", "No_chats_yet": "No chats yet", "No_chats_yet_description": "All your chats will appear here.", "No_calls_yet": "No calls yet", @@ -3852,6 +3853,8 @@ "No_departments_yet_description": "Organize agents into departments, set how tickets get forwarded and monitor their performance.", "No_managers_yet": "No managers yet", "No_managers_yet_description": "Managers have access to all omnichannel controls, being able to monitor and take actions.", + "No_history_yet": "No history yet", + "No_history_yet_description": "The entire message history with this contact will appear here.", "No_content_was_provided": "No content was provided", "No_data_found": "No data found", "No_data_available_for_the_selected_period": "No data available for the selected period", From 93977798a93bfbf99b4c6f85ebb0466f59e1286f Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 23 Jul 2024 21:57:14 -0300 Subject: [PATCH 004/173] feat: Displays chat history on chats contextual bar (#32845) * wip * test: replace e2e locator --- .../roomActions/useRoomInfoRoomAction.ts | 2 +- .../contactHistory/ContactHistory.tsx | 2 +- .../ContactHistoryMessagesList.tsx | 45 +++-- .../directory/ChatsContextualBar.tsx | 74 +------- .../directory/ContextualBarRouter.tsx | 4 +- .../directory/OmnichannelDirectoryPage.tsx | 7 +- .../{contextualBar => ChatInfo}/ChatInfo.js | 0 .../ChatInfoRouter.tsx} | 4 +- .../DepartmentField.tsx | 0 .../RoomEdit/RoomEdit.tsx | 0 .../RoomEdit/RoomEditWithData.tsx | 0 .../chats/ChatInfo/RoomEdit/index.ts | 1 + .../VisitorClientInfo.js | 0 .../chats/contextualBar/ChatInfoDirectory.js | 178 ------------------ .../chats/contextualBar/RoomEdit/index.ts | 1 - .../omnichannel-send-pdf-transcript.spec.ts | 2 +- .../page-objects/omnichannel-transcript.ts | 4 +- packages/i18n/src/locales/en.i18n.json | 2 + 18 files changed, 55 insertions(+), 271 deletions(-) rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar => ChatInfo}/ChatInfo.js (100%) rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar/ChatsContextualBar.tsx => ChatInfo/ChatInfoRouter.tsx} (91%) rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar => ChatInfo}/DepartmentField.tsx (100%) rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar => ChatInfo}/RoomEdit/RoomEdit.tsx (100%) rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar => ChatInfo}/RoomEdit/RoomEditWithData.tsx (100%) create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/index.ts rename apps/meteor/client/views/omnichannel/directory/chats/{contextualBar => ChatInfo}/VisitorClientInfo.js (100%) delete mode 100644 apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js delete mode 100644 apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/index.ts diff --git a/apps/meteor/client/hooks/roomActions/useRoomInfoRoomAction.ts b/apps/meteor/client/hooks/roomActions/useRoomInfoRoomAction.ts index 1b7608ff5212..573211b6fbd8 100644 --- a/apps/meteor/client/hooks/roomActions/useRoomInfoRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useRoomInfoRoomAction.ts @@ -2,7 +2,7 @@ import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -const ChatsContextualBar = lazy(() => import('../../views/omnichannel/directory/chats/contextualBar/ChatsContextualBar')); +const ChatsContextualBar = lazy(() => import('../../views/omnichannel/directory/chats/ChatInfo/ChatInfoRouter')); export const useRoomInfoRoomAction = () => { return useMemo( diff --git a/apps/meteor/client/views/omnichannel/contactHistory/ContactHistory.tsx b/apps/meteor/client/views/omnichannel/contactHistory/ContactHistory.tsx index 955c1918be05..5375132c455f 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/ContactHistory.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/ContactHistory.tsx @@ -11,7 +11,7 @@ const ContactHistory = () => { return ( <> {chatId && chatId !== '' ? ( - + ) : ( )} diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx index 3bf6385a19a0..70e2619be58e 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx @@ -1,12 +1,24 @@ -import { Box, Icon, Margins, States, StatesIcon, StatesSubtitle, StatesTitle, TextInput, Throbber } from '@rocket.chat/fuselage'; +import { + Box, + Button, + ButtonGroup, + ContextualbarFooter, + Icon, + Margins, + States, + StatesIcon, + StatesSubtitle, + StatesTitle, + TextInput, + Throbber, +} from '@rocket.chat/fuselage'; import { useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, Dispatch, ReactElement, SetStateAction } from 'react'; +import type { ChangeEvent, ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import { Virtuoso } from 'react-virtuoso'; import { ContextualbarHeader, - ContextualbarAction, ContextualbarIcon, ContextualbarTitle, ContextualbarClose, @@ -21,18 +33,17 @@ import { isMessageSequential } from '../../../room/MessageList/lib/isMessageSequ import ContactHistoryMessage from './ContactHistoryMessage'; import { useHistoryMessageList } from './useHistoryMessageList'; -const ContactHistoryMessagesList = ({ - chatId, - setChatId, - close, -}: { +type ContactHistoryMessagesListProps = { chatId: string; - setChatId: Dispatch>; - close: () => void; -}): ReactElement => { - const [text, setText] = useState(''); + onClose: () => void; + onOpenRoom?: () => void; +}; + +const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHistoryMessagesListProps) => { const t = useTranslation(); + const [text, setText] = useState(''); const showUserAvatar = !!useUserPreference('displayAvatars'); + const { itemsList: messageList, loadMoreItems } = useHistoryMessageList( useMemo(() => ({ roomId: chatId, filter: text }), [chatId, text]), ); @@ -47,10 +58,9 @@ const ContactHistoryMessagesList = ({ return ( <> - setChatId('')} title={t('Back')} name='arrow-back' /> {t('Chat_History')} - + @@ -111,6 +121,13 @@ const ContactHistoryMessagesList = ({ )} + {onOpenRoom && ( + + + + + + )} ); }; diff --git a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx index b7bad83f87e1..7493a72becb2 100644 --- a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx @@ -1,75 +1,21 @@ -import { Box } from '@rocket.chat/fuselage'; -import { useRoute, useRouteParameter, useTranslation } from '@rocket.chat/ui-contexts'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import React from 'react'; -import { - Contextualbar, - ContextualbarHeader, - ContextualbarIcon, - ContextualbarTitle, - ContextualbarAction, - ContextualbarClose, -} from '../../../components/Contextualbar'; -import Chat from './chats/Chat'; -import ChatInfoDirectory from './chats/contextualBar/ChatInfoDirectory'; -import { RoomEditWithData } from './chats/contextualBar/RoomEdit'; -import { FormSkeleton } from './components'; -import { useOmnichannelRoomInfo } from './hooks/useOmnichannelRoomInfo'; - -const ChatsContextualBar = ({ chatReload }: { chatReload?: () => void }) => { - const t = useTranslation(); - - const directoryRoute = useRoute('omnichannel-directory'); +import ContactHistoryMessagesList from '../contactHistory/MessageList/ContactHistoryMessagesList'; +const ChatsContextualBar = () => { + const router = useRouter(); const context = useRouteParameter('context'); - const id = useRouteParameter('id') || ''; - - const openInRoom = () => id && directoryRoute.push({ tab: 'chats', id, context: 'view' }); - - const handleClose = () => directoryRoute.push({ tab: 'chats' }); - - const handleCancel = () => id && directoryRoute.push({ tab: 'chats', id, context: 'info' }); + const id = useRouteParameter('id'); - const { data: room, isLoading, isError, refetch: reloadInfo } = useOmnichannelRoomInfo(id); - - if (context === 'view' && id) { - return ; - } - - if (isLoading) { - return ( - - - - ); - } + const handleOpenRoom = () => id && router.navigate(`/live/${id}`); + const handleClose = () => router.navigate('/omnichannel-directory/chats'); - if (isError || !room) { - return {t('Room_not_found')}; + if (context === 'info' && id) { + return ; } - return ( - - - {context === 'info' && ( - <> - - {t('Room_Info')} - - - )} - {context === 'edit' && ( - <> - - {t('edit-room')} - - )} - - - {context === 'info' && } - {context === 'edit' && id && } - - ); + return null; }; export default ChatsContextualBar; diff --git a/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx b/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx index 6ffd6383c9df..0ab0cdb59162 100644 --- a/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ContextualBarRouter.tsx @@ -5,14 +5,14 @@ import CallsContextualBarDirectory from './CallsContextualBarDirectory'; import ChatsContextualBar from './ChatsContextualBar'; import ContactContextualBar from './ContactContextualBar'; -const ContextualBarRouter = ({ chatReload }: { chatReload?: () => void }) => { +const ContextualBarRouter = () => { const tab = useRouteParameter('tab'); switch (tab) { case 'contacts': return ; case 'chats': - return ; + return ; case 'calls': return ; default: diff --git a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx index dff1cd467b02..34d52bbfb399 100644 --- a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx +++ b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useCallback } from 'react'; import { ContextualbarDialog } from '../../../components/Contextualbar'; import { Page, PageHeader, PageContent } from '../../../components/Page'; -import { queryClient } from '../../../lib/queryClient'; import ContextualBarRouter from './ContextualBarRouter'; import CallTab from './calls/CallTab'; import ChatTab from './chats/ChatTab'; @@ -35,8 +34,6 @@ const OmnichannelDirectoryPage = () => { const handleTabClick = useCallback((tab) => router.navigate({ name: 'omnichannel-directory', params: { tab } }), [router]); - const chatReload = () => queryClient.invalidateQueries({ queryKey: ['current-chats'] }); - return ( @@ -46,7 +43,7 @@ const OmnichannelDirectoryPage = () => { {t('Contacts')} handleTabClick('chats')}> - {t('Chats' as any /* TODO: this is going to change to Conversations */)} + {t('Chats')} handleTabClick('calls')}> {t('Calls')} @@ -60,7 +57,7 @@ const OmnichannelDirectoryPage = () => { {context && ( - + )} diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfo.js b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.js similarity index 100% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfo.js rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.js diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatsContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfoRouter.tsx similarity index 91% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatsContextualBar.tsx rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfoRouter.tsx index d78f7e9a4ab9..28d938de8271 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatsContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfoRouter.tsx @@ -5,7 +5,7 @@ import { ContextualbarHeader, ContextualbarIcon, ContextualbarTitle, Contextualb import { useRoom } from '../../../../room/contexts/RoomContext'; import { useRoomToolbox } from '../../../../room/contexts/RoomToolboxContext'; import ChatInfo from './ChatInfo'; -import { RoomEditWithData } from './RoomEdit'; +import RoomEdit from './RoomEdit'; const PATH = 'live'; @@ -36,7 +36,7 @@ const ChatsContextualBar = () => { {context === 'edit' ? ( - + ) : ( )} diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/DepartmentField.tsx similarity index 100% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/DepartmentField.tsx rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/DepartmentField.tsx diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/RoomEdit.tsx similarity index 100% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/RoomEdit.tsx diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEditWithData.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/RoomEditWithData.tsx similarity index 100% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEditWithData.tsx rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/RoomEditWithData.tsx diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/index.ts b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/index.ts new file mode 100644 index 000000000000..99205f80da8b --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/RoomEdit/index.ts @@ -0,0 +1 @@ +export { default } from './RoomEditWithData'; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/VisitorClientInfo.js b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/VisitorClientInfo.js similarity index 100% rename from apps/meteor/client/views/omnichannel/directory/chats/contextualBar/VisitorClientInfo.js rename to apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/VisitorClientInfo.js diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js deleted file mode 100644 index ed7b5fdafb86..000000000000 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js +++ /dev/null @@ -1,178 +0,0 @@ -import { Box, Margins, Tag, Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useRoute, useUserSubscription, useTranslation } from '@rocket.chat/ui-contexts'; -import { Meteor } from 'meteor/meteor'; -import moment from 'moment'; -import React, { useEffect, useMemo, useState } from 'react'; - -import { hasPermission } from '../../../../../../app/authorization/client'; -import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../../../components/Contextualbar'; -import { InfoPanelField, InfoPanelLabel, InfoPanelText } from '../../../../../components/InfoPanel'; -import MarkdownText from '../../../../../components/MarkdownText'; -import { useEndpointData } from '../../../../../hooks/useEndpointData'; -import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime'; -import { useFormatDuration } from '../../../../../hooks/useFormatDuration'; -import CustomField from '../../../components/CustomField'; -import { AgentField, ContactField, SlaField } from '../../components'; -import PriorityField from '../../components/PriorityField'; -import { formatQueuedAt } from '../../utils/formatQueuedAt'; -import DepartmentField from './DepartmentField'; -import VisitorClientInfo from './VisitorClientInfo'; - -function ChatInfoDirectory({ id, route = undefined, room }) { - const t = useTranslation(); - - const formatDateAndTime = useFormatDateAndTime(); - const { value: allCustomFields, phase: stateCustomFields } = useEndpointData('/v1/livechat/custom-fields'); - const [customFields, setCustomFields] = useState([]); - const formatDuration = useFormatDuration(); - - const { - ts, - tags, - closedAt, - departmentId, - v, - servedBy, - metrics, - topic, - waitingResponse, - responseBy, - slaId, - priorityId, - livechatData, - queuedAt, - } = room || { room: { v: {} } }; - - const routePath = useRoute(route || 'omnichannel-directory'); - const canViewCustomFields = () => hasPermission('view-livechat-room-customfields'); - const subscription = useUserSubscription(id); - const hasGlobalEditRoomPermission = hasPermission('save-others-livechat-room-info'); - const hasLocalEditRoomPermission = servedBy?._id === Meteor.userId(); - const visitorId = v?._id; - const queueStartedAt = queuedAt || ts; - - const queueTime = useMemo(() => formatQueuedAt(room), [room]); - - const dispatchToastMessage = useToastMessageDispatch(); - useEffect(() => { - if (allCustomFields) { - const { customFields: customFieldsAPI } = allCustomFields; - setCustomFields(customFieldsAPI); - } - }, [allCustomFields, stateCustomFields]); - - const checkIsVisibleAndScopeRoom = (key) => { - const field = customFields.find(({ _id }) => _id === key); - if (field && field.visibility === 'visible' && field.scope === 'room') { - return true; - } - return false; - }; - - const onEditClick = useMutableCallback(() => { - const hasEditAccess = !!subscription || hasLocalEditRoomPermission || hasGlobalEditRoomPermission; - if (!hasEditAccess) { - return dispatchToastMessage({ type: 'error', message: t('Not_authorized') }); - } - - routePath.push({ - tab: route ? 'room-info' : 'chats', - context: 'edit', - id, - }); - }); - - return ( - <> - - - {room && v && } - {visitorId && } - {servedBy && } - {departmentId && } - {tags && tags.length > 0 && ( - - {t('Tags')} - - {tags.map((tag) => ( - - - {tag} - - - ))} - - - )} - {topic && ( - - {t('Topic')} - - - - - )} - {queueStartedAt && ( - - {t('Queue_Time')} - {queueTime} - - )} - {closedAt && ( - - {t('Chat_Duration')} - {moment(closedAt).from(moment(ts), true)} - - )} - {ts && ( - - {t('Created_at')} - {formatDateAndTime(ts)} - - )} - {closedAt && ( - - {t('Closed_At')} - {formatDateAndTime(closedAt)} - - )} - {servedBy?.ts && ( - - {t('Taken_at')} - {formatDateAndTime(servedBy.ts)} - - )} - {metrics?.response?.avg && formatDuration(metrics.response.avg) && ( - - {t('Avg_response_time')} - {formatDuration(metrics.response.avg)} - - )} - {!waitingResponse && responseBy?.lastMessageTs && ( - - {t('Inactivity_Time')} - {moment(responseBy.lastMessageTs).fromNow(true)} - - )} - {canViewCustomFields() && - livechatData && - Object.keys(livechatData).map( - (key) => checkIsVisibleAndScopeRoom(key) && livechatData[key] && , - )} - {slaId && } - {priorityId && } - - - - - - - - - ); -} - -export default ChatInfoDirectory; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/index.ts b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/index.ts deleted file mode 100644 index dcfd1c14d2d9..000000000000 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as RoomEditWithData } from './RoomEditWithData'; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts index 323d41550592..b1f64b17b94c 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts @@ -79,7 +79,7 @@ test.describe('omnichannel- export chat transcript as PDF', () => { await agent.poHomeChannel.transcript.contactCenterSearch.type(newUser.name); await page.waitForTimeout(3000); await agent.poHomeChannel.transcript.firstRow.click(); - await agent.poHomeChannel.transcript.viewFullConversation.click(); + await agent.poHomeChannel.transcript.btnOpenChat.click(); await agent.poHomeChannel.content.btnSendTranscript.click(); await expect(agent.poHomeChannel.content.btnSendTranscriptAsPDF).toHaveAttribute('aria-disabled', 'false'); await agent.poHomeChannel.content.btnSendTranscriptAsPDF.click(); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-transcript.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-transcript.ts index d0249933d438..d7ece60a5ef6 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-transcript.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-transcript.ts @@ -36,8 +36,8 @@ export class OmnichannelTranscript { return this.page.locator('//tr[1]//td[1]'); } - get viewFullConversation(): Locator { - return this.page.locator('//button[@title="View full conversation"]/i'); + get btnOpenChat(): Locator { + return this.page.getByRole('dialog').getByRole('button', { name: 'Open chat', exact: true }); } get DownloadedPDF(): Locator { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 91b1dab69ee9..142691224076 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1006,6 +1006,7 @@ "Chatops_Enabled": "Enable Chatops", "Chatops_Title": "Chatops Panel", "Chatops_Username": "Chatops Username", + "Chats": "Chats", "Chat_Duration": "Chat Duration", "Chats_removed": "Chats Removed", "Check_All": "Check All", @@ -4040,6 +4041,7 @@ "Open_call": "Open call", "Open_call_in_new_tab": "Open call in new tab", "Open_channel_user_search": "`%s` - Open Channel / User search", + "Open_chat": "Open chat", "Open_conversations": "Open Conversations", "Open_Days": "Open days", "Open_days_of_the_week": "Open Days of the Week", From 81c9ea0624e1d8e36bbaecd8aa65fc76f81b661c Mon Sep 17 00:00:00 2001 From: Gustavo Reis Bauer Date: Thu, 25 Jul 2024 19:20:18 -0300 Subject: [PATCH 005/173] feat: Add contact identification settings (#32893) --- .changeset/smooth-beans-itch.md | 6 +++ .../livechat-enterprise/server/settings.ts | 45 +++++++++++++++++++ packages/i18n/src/locales/en.i18n.json | 5 +++ 3 files changed, 56 insertions(+) create mode 100644 .changeset/smooth-beans-itch.md diff --git a/.changeset/smooth-beans-itch.md b/.changeset/smooth-beans-itch.md new file mode 100644 index 000000000000..e5ddefd70146 --- /dev/null +++ b/.changeset/smooth-beans-itch.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/i18n": patch +--- + +Added the `Livechat_Block_Unknown_Contacts`, `Livechat_Block_Unverified_Contacts`, `Livechat_Contact_Verification_App`, `Livechat_Request_Verification_On_First_Contact_Only` settings to control how the app is going to verify a contact's identity diff --git a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts index 0c54c8d0cfe9..f6f8048d39e7 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts @@ -166,6 +166,51 @@ export const createSettings = async (): Promise => { invalidValue: '', }); }); + + await this.add('Livechat_Block_Unknown_Contacts', false, { + type: 'boolean', + public: true, + hidden: true, + enableQuery: omnichannelEnabledQuery, + invalidValue: false, + section: 'Contact_identification', + enterprise: true, + modules: ['livechat-enterprise'], + }); + + await this.add('Livechat_Block_Unverified_Contacts', false, { + type: 'boolean', + public: true, + hidden: true, + enableQuery: omnichannelEnabledQuery, + invalidValue: false, + section: 'Contact_identification', + enterprise: true, + modules: ['livechat-enterprise'], + }); + + await this.add('Livechat_Contact_Verification_App', '', { + type: 'select', + public: true, + hidden: true, + values: [{ key: 'VerifyChat', i18nLabel: 'VerifyChat' }], + enableQuery: omnichannelEnabledQuery, + invalidValue: '', + section: 'Contact_identification', + enterprise: true, + modules: ['livechat-enterprise'], + }); + + await this.add('Livechat_Request_Verification_On_First_Contact_Only', false, { + type: 'boolean', + public: true, + hidden: true, + enableQuery: omnichannelEnabledQuery, + invalidValue: false, + section: 'Contact_identification', + enterprise: true, + modules: ['livechat-enterprise'], + }); }); await settingsRegistry.add('Livechat_AdditionalWidgetScripts', '', { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 142691224076..abbb4faf12df 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3256,6 +3256,10 @@ "Omnichannel_placed_chat_on_hold": "Chat On Hold: {{comment}}", "Omnichannel_hide_conversation_after_closing": "Hide conversation after closing", "Omnichannel_hide_conversation_after_closing_description": "After closing the conversation you will be redirected to Home.", + "Livechat_Block_Unknown_Contacts": "Block unknown contacts", + "Livechat_Block_Unverified_Contacts": "Block unverified contacts", + "Livechat_Contact_Verification_App": "Contact verification app", + "Livechat_Request_Verification_On_First_Contact_Only": "Request verification on first contact only", "Livechat_Queue": "Omnichannel Queue", "Livechat_registration_form": "Registration Form", "Livechat_registration_form_message": "Registration Form Message", @@ -6274,6 +6278,7 @@ "Omnichannel_transcript_pdf": "Export chat transcript as PDF.", "Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description": "Always export the transcript as PDF at the end of conversations.", "Contact_email": "Contact email", + "Contact_identification": "Contact identification", "Customer": "Customer", "Time": "Time", "Omnichannel_Agent": "Omnichannel Agent", From d667dee171e90937bff97bd27a23110aee4810be Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 21 Aug 2024 17:08:58 -0300 Subject: [PATCH 006/173] feat: Omnichannel's security and privacy settings page (#32988) * chore: remove hidden prop in new settings * feat: new security and privacy page * fix: review * chore: changeset --- .changeset/lazy-icons-draw.md | 6 +++++ .../client/views/livechatSideNavItems.ts | 7 ++++++ apps/meteor/client/omnichannel/routes.ts | 9 +++++++ .../securityPrivacy/SecurityPrivacyPage.tsx | 22 ++++++++++++++++ .../securityPrivacy/SecurityPrivacyRoute.tsx | 25 +++++++++++++++++++ .../livechat-enterprise/server/settings.ts | 4 --- packages/i18n/src/locales/en.i18n.json | 3 ++- 7 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 .changeset/lazy-icons-draw.md create mode 100644 apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyPage.tsx create mode 100644 apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyRoute.tsx diff --git a/.changeset/lazy-icons-draw.md b/.changeset/lazy-icons-draw.md new file mode 100644 index 000000000000..cb8752dd6c1f --- /dev/null +++ b/.changeset/lazy-icons-draw.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Renders the security and privacy settings page inside Omnichannel administration diff --git a/apps/meteor/app/livechat-enterprise/client/views/livechatSideNavItems.ts b/apps/meteor/app/livechat-enterprise/client/views/livechatSideNavItems.ts index 6d9d9f31e24c..fa9e98286098 100644 --- a/apps/meteor/app/livechat-enterprise/client/views/livechatSideNavItems.ts +++ b/apps/meteor/app/livechat-enterprise/client/views/livechatSideNavItems.ts @@ -49,3 +49,10 @@ registerOmnichannelSidebarItem({ i18nLabel: 'Priorities', permissionGranted: () => hasAtLeastOnePermission('manage-livechat-priorities'), }); + +registerOmnichannelSidebarItem({ + href: '/omnichannel/security-privacy', + icon: 'shield-check', + i18nLabel: 'Security_and_privacy', + permissionGranted: () => hasAtLeastOnePermission(['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings']), +}); diff --git a/apps/meteor/client/omnichannel/routes.ts b/apps/meteor/client/omnichannel/routes.ts index ffb225c76ac8..1453c07533fd 100644 --- a/apps/meteor/client/omnichannel/routes.ts +++ b/apps/meteor/client/omnichannel/routes.ts @@ -24,6 +24,10 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/omnichannel/reports'; pathname: `/omnichannel/reports`; }; + 'omnichannel-security-privacy': { + pattern: '/omnichannel/security-privacy'; + pathname: `/omnichannel/security-privacy`; + }; } } @@ -51,3 +55,8 @@ registerOmnichannelRoute('/reports', { name: 'omnichannel-reports', component: lazy(() => import('./reports/ReportsPage')), }); + +registerOmnichannelRoute('/security-privacy', { + name: 'omnichannel-security-privacy', + component: lazy(() => import('./securityPrivacy/SecurityPrivacyRoute')), +}); diff --git a/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyPage.tsx b/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyPage.tsx new file mode 100644 index 000000000000..0ab741b3e166 --- /dev/null +++ b/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyPage.tsx @@ -0,0 +1,22 @@ +import { useIsPrivilegedSettingsContext } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { useEditableSettingsGroupSections } from '../../views/admin/EditableSettingsContext'; +import GenericGroupPage from '../../views/admin/settings/groups/GenericGroupPage'; +import NotAuthorizedPage from '../../views/notAuthorized/NotAuthorizedPage'; + +const GROUP_ID = 'Omnichannel'; +const SECTION_ID = 'Contact_identification'; + +const SecurityPrivacyPage = () => { + const hasPermission = useIsPrivilegedSettingsContext(); + const sections = useEditableSettingsGroupSections(GROUP_ID).filter((id) => id === SECTION_ID); + + if (!hasPermission) { + return ; + } + + return ; +}; + +export default SecurityPrivacyPage; diff --git a/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyRoute.tsx b/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyRoute.tsx new file mode 100644 index 000000000000..6c72f746a1a4 --- /dev/null +++ b/apps/meteor/client/omnichannel/securityPrivacy/SecurityPrivacyRoute.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import SettingsProvider from '../../providers/SettingsProvider'; +import EditableSettingsProvider from '../../views/admin/settings/EditableSettingsProvider'; +import NotAuthorizedPage from '../../views/notAuthorized/NotAuthorizedPage'; +import SecurityPrivacyPage from './SecurityPrivacyPage'; + +const SecurityPrivacyRoute = () => { + const isEnterprise = useHasLicenseModule('livechat-enterprise'); + + if (!isEnterprise) { + return ; + } + + return ( + + + + + + ); +}; + +export default SecurityPrivacyRoute; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts index f6f8048d39e7..38023db73b11 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts @@ -170,7 +170,6 @@ export const createSettings = async (): Promise => { await this.add('Livechat_Block_Unknown_Contacts', false, { type: 'boolean', public: true, - hidden: true, enableQuery: omnichannelEnabledQuery, invalidValue: false, section: 'Contact_identification', @@ -181,7 +180,6 @@ export const createSettings = async (): Promise => { await this.add('Livechat_Block_Unverified_Contacts', false, { type: 'boolean', public: true, - hidden: true, enableQuery: omnichannelEnabledQuery, invalidValue: false, section: 'Contact_identification', @@ -192,7 +190,6 @@ export const createSettings = async (): Promise => { await this.add('Livechat_Contact_Verification_App', '', { type: 'select', public: true, - hidden: true, values: [{ key: 'VerifyChat', i18nLabel: 'VerifyChat' }], enableQuery: omnichannelEnabledQuery, invalidValue: '', @@ -204,7 +201,6 @@ export const createSettings = async (): Promise => { await this.add('Livechat_Request_Verification_On_First_Contact_Only', false, { type: 'boolean', public: true, - hidden: true, enableQuery: omnichannelEnabledQuery, invalidValue: false, section: 'Contact_identification', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 77c7c102a2d0..ae0bb1d0aa72 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6514,5 +6514,6 @@ "Sidebar_Sections_Order_Description": "Select the categories in your preferred order", "Incoming_Calls": "Incoming calls", "Advanced_settings": "Advanced settings", - "Security_and_permissions": "Security and permissions" + "Security_and_permissions": "Security and permissions", + "Security_and_privacy": "Security and privacy" } From 8bdc3ae7381758b43ede0c88aaa52a240108ac69 Mon Sep 17 00:00:00 2001 From: dougfabris Date: Fri, 23 Aug 2024 15:04:22 -0300 Subject: [PATCH 007/173] chore: remove duplicated translation key --- packages/i18n/src/locales/en.i18n.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 334a44add274..fb791150c633 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1010,7 +1010,6 @@ "Chatops_Enabled": "Enable Chatops", "Chatops_Title": "Chatops Panel", "Chatops_Username": "Chatops Username", - "Chats": "Chats", "Chat_Duration": "Chat Duration", "Chats_removed": "Chats Removed", "Check_All": "Check All", From 2d8479538787c7aa7d15d9f4328392f42d308dde Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 9 Oct 2024 09:33:38 -0300 Subject: [PATCH 008/173] chore: Adds reactivity to contact history message list (#33505) --- .../ContactHistoryMessagesList.tsx | 23 +++++++++++++++---- .../MessageList/useHistoryMessageList.ts | 13 +++++++++-- .../omnichannel/directory/chats/ChatTable.tsx | 1 - 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx index 70e2619be58e..975190d59650 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx @@ -12,7 +12,8 @@ import { TextInput, Throbber, } from '@rocket.chat/fuselage'; -import { useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useSetting, useTranslation, useUserPreference, useUserId } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import { Virtuoso } from 'react-virtuoso'; @@ -43,9 +44,15 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist const t = useTranslation(); const [text, setText] = useState(''); const showUserAvatar = !!useUserPreference('displayAvatars'); + const userId = useUserId(); + + const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ + debounceDelay: 200, + }); const { itemsList: messageList, loadMoreItems } = useHistoryMessageList( useMemo(() => ({ roomId: chatId, filter: text }), [chatId, text]), + userId, ); const handleSearchChange = (event: ChangeEvent): void => { @@ -59,7 +66,7 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist <> - {t('Chat_History')} + {t('Conversation')} @@ -97,14 +104,22 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist )} {phase !== AsyncStatePhase.LOADING && totalItemCount === 0 && } - + {!error && totalItemCount > 0 && history.length > 0 && ( undefined - : (start): unknown => loadMoreItems(start, Math.min(50, totalItemCount - start)) + : (start): void => { + loadMoreItems(start, Math.min(50, totalItemCount - start)); + } } overscan={25} data={messages} diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts index cd231b84e8dd..ba450cda05e3 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts @@ -1,9 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; import { useComponentDidUpdate } from '../../../../hooks/useComponentDidUpdate'; import { MessageList } from '../../../../lib/lists/MessageList'; +import { getConfig } from '../../../../lib/utils/getConfig'; type HistoryMessageListOptions = { filter: string; @@ -12,6 +15,7 @@ type HistoryMessageListOptions = { export const useHistoryMessageList = ( options: HistoryMessageListOptions, + uid: IUser['_id'] | null, ): { itemsList: MessageList; initialItemCount: number; @@ -42,7 +46,12 @@ export const useHistoryMessageList = ( [getMessages, options.filter], ); - const { loadMoreItems, initialItemCount } = useScrollableMessageList(itemsList, fetchMessages, 25); + const { loadMoreItems, initialItemCount } = useScrollableMessageList( + itemsList, + fetchMessages, + useMemo(() => parseInt(`${getConfig('historyMessageListSize', 10)}`), []), + ); + useStreamUpdatesForMessageList(itemsList, uid, options.roomId); return { itemsList, diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx index 95766c2887f8..46b5b6784ce0 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx @@ -32,7 +32,6 @@ const ChatTable = () => { const query = useMemo( () => ({ sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, - open: false, roomName: text || '', agents: userIdLoggedIn ? [userIdLoggedIn] : [], ...(itemsPerPage && { count: itemsPerPage }), From 61c8cec2649460fac68267e632ab6afd38380060 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 14 Oct 2024 08:59:12 -0300 Subject: [PATCH 009/173] feat: Omnichannel directory chats filters (#33016) --- .changeset/giant-singers-melt.md | 8 + .../currentChats/CurrentChatsPage.tsx | 14 +- .../currentChats/RemoveChatButton.tsx | 27 +-- .../directory/ChatsContextualBar.tsx | 5 + .../directory/OmnichannelDirectoryPage.tsx | 10 +- .../directory/chats/ChatFilterByText.tsx | 93 +++++++++ .../chats/ChatFiltersContextualBar.tsx | 186 ++++++++++++++++++ .../omnichannel/directory/chats/ChatTable.tsx | 148 +++++--------- .../directory/chats/ChatTableRow.tsx | 88 +++++++++ .../directory/chats/useChatsFilters.ts | 86 ++++++++ .../directory/chats/useChatsQuery.ts | 94 +++++++++ .../omnichannel-contact-center.spec.ts | 1 + .../e2e/page-objects/omnichannel-section.ts | 4 + packages/core-typings/src/IRoom.ts | 3 + packages/rest-typings/src/v1/omnichannel.ts | 3 +- 15 files changed, 645 insertions(+), 125 deletions(-) create mode 100644 .changeset/giant-singers-melt.md create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/ChatFilterByText.tsx create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/ChatTableRow.tsx create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts create mode 100644 apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts diff --git a/.changeset/giant-singers-melt.md b/.changeset/giant-singers-melt.md new file mode 100644 index 000000000000..266c18bb13c8 --- /dev/null +++ b/.changeset/giant-singers-melt.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds filters options to the omnichannel directory chats tab diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index f0ea3c5b40ed..614231b003ef 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -1,7 +1,7 @@ import { Callout, Pagination } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings'; -import { usePermission } from '@rocket.chat/ui-contexts'; +import { usePermission, useRouter } from '@rocket.chat/ui-contexts'; import { hashQueryKey } from '@tanstack/react-query'; import moment from 'moment'; import type { ComponentProps, ReactElement } from 'react'; @@ -131,6 +131,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s const [customFields, setCustomFields] = useState<{ [key: string]: string }>(); const { t } = useTranslation(); + const directoryPath = useRouter().buildRoutePath('/omnichannel-directory'); const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); @@ -204,7 +205,11 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s {getStatusText(open, onHold, !!servedBy?.username)} - {canRemoveClosedChats && !open && } + {canRemoveClosedChats && ( + + {!open && } + + )} ); }, @@ -304,10 +309,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s Manage conversations in the - - contact center - - . + contact center. {((isSuccess && data?.rooms.length > 0) || queryHasChanged) && ( diff --git a/apps/meteor/client/views/omnichannel/currentChats/RemoveChatButton.tsx b/apps/meteor/client/views/omnichannel/currentChats/RemoveChatButton.tsx index f487eed3599f..796028d024b0 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/RemoveChatButton.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/RemoveChatButton.tsx @@ -1,27 +1,27 @@ import { IconButton } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import GenericModal from '../../../components/GenericModal'; -import { GenericTableCell } from '../../../components/GenericTable'; import { useRemoveCurrentChatMutation } from './hooks/useRemoveCurrentChatMutation'; type RemoveChatButtonProps = { _id: string }; const RemoveChatButton = ({ _id }: RemoveChatButtonProps) => { - const removeCurrentChatMutation = useRemoveCurrentChatMutation(); + const t = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const t = useTranslation(); - const handleRemoveClick = useMutableCallback(async () => { + const removeCurrentChatMutation = useRemoveCurrentChatMutation(); + + const handleRemoveClick = useEffectEvent(async () => { removeCurrentChatMutation.mutate(_id); }); - const handleDelete = useMutableCallback((e) => { + const handleDelete = useEffectEvent((e) => { e.stopPropagation(); - const onDeleteAgent = async (): Promise => { + const onDeleteAgent = async () => { try { await handleRemoveClick(); dispatchToastMessage({ type: 'success', message: t('Chat_removed') }); @@ -31,27 +31,18 @@ const RemoveChatButton = ({ _id }: RemoveChatButtonProps) => { setModal(null); }; - const handleClose = (): void => { - setModal(null); - }; - setModal( setModal(null)} confirmText={t('Delete')} />, ); }); - return ( - - - - ); + return ; }; export default RemoveChatButton; diff --git a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx index 7493a72becb2..8be6e809f6c2 100644 --- a/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ChatsContextualBar.tsx @@ -2,6 +2,7 @@ import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import React from 'react'; import ContactHistoryMessagesList from '../contactHistory/MessageList/ContactHistoryMessagesList'; +import ChatFiltersContextualBar from './chats/ChatFiltersContextualBar'; const ChatsContextualBar = () => { const router = useRouter(); @@ -11,6 +12,10 @@ const ChatsContextualBar = () => { const handleOpenRoom = () => id && router.navigate(`/live/${id}`); const handleClose = () => router.navigate('/omnichannel-directory/chats'); + if (context === 'filters') { + return ; + } + if (context === 'info' && id) { return ; } diff --git a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx index 34d52bbfb399..faf2ed82eee8 100644 --- a/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx +++ b/apps/meteor/client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx @@ -9,7 +9,7 @@ import CallTab from './calls/CallTab'; import ChatTab from './chats/ChatTab'; import ContactTab from './contacts/ContactTab'; -const DEFAULT_TAB = 'contacts'; +const DEFAULT_TAB = 'chats'; const OmnichannelDirectoryPage = () => { const t = useTranslation(); @@ -39,19 +39,19 @@ const OmnichannelDirectoryPage = () => { - handleTabClick('contacts')}> - {t('Contacts')} - handleTabClick('chats')}> {t('Chats')} + handleTabClick('contacts')}> + {t('Contacts')} + handleTabClick('calls')}> {t('Calls')} - {tab === 'contacts' && } {tab === 'chats' && } + {tab === 'contacts' && } {tab === 'calls' && } diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatFilterByText.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatFilterByText.tsx new file mode 100644 index 000000000000..cd03a70c015f --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatFilterByText.tsx @@ -0,0 +1,93 @@ +import { Box, Button, Chip } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useMethod, useRoute, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +import FilterByText from '../../../../components/FilterByText'; +import GenericModal from '../../../../components/GenericModal'; +import { useChatsFilters } from './useChatsFilters'; + +const ChatFilterByText = () => { + const t = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const directoryRoute = useRoute('omnichannel-directory'); + const removeClosedChats = useMethod('livechat:removeAllClosedRooms'); + const queryClient = useQueryClient(); + + const { displayFilters, setFiltersQuery, removeFilter } = useChatsFilters(); + + const handleRemoveAllClosed = useEffectEvent(async () => { + const onDeleteAll = async () => { + try { + await removeClosedChats(); + queryClient.invalidateQueries(['current-chats']); + dispatchToastMessage({ type: 'success', message: t('Chat_removed') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setModal(null); + } + }; + + setModal( + setModal(null)} + confirmText={t('Delete')} + />, + ); + }); + + const menuItems = [ + { + items: [ + { + id: 'delete-all-closed-chats', + variant: 'danger', + icon: 'trash', + content: t('Delete_all_closed_chats'), + onClick: handleRemoveAllClosed, + } as const, + ], + }, + ]; + + return ( + <> + setFiltersQuery((prevState) => ({ ...prevState, guest: text }))}> + + + + + {Object.entries(displayFilters).map(([value, label], index) => { + if (!label) { + return null; + } + + return ( + removeFilter(value)}> + {label} + + ); + })} + + + ); +}; + +export default ChatFilterByText; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx new file mode 100644 index 000000000000..0fb0e9917505 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx @@ -0,0 +1,186 @@ +import { Button, ButtonGroup, Field, FieldLabel, FieldRow, InputBox, Select, TextInput } from '@rocket.chat/fuselage'; +import { useEndpoint, usePermission, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import AutoCompleteAgent from '../../../../components/AutoCompleteAgent'; +import AutoCompleteDepartment from '../../../../components/AutoCompleteDepartment'; +import { + ContextualbarHeader, + ContextualbarIcon, + ContextualbarTitle, + ContextualbarClose, + ContextualbarScrollableContent, + ContextualbarFooter, +} from '../../../../components/Contextualbar'; +import { CurrentChatTags } from '../../additionalForms'; +import type { ChatsFiltersQuery } from './useChatsFilters'; +import { useChatsFilters } from './useChatsFilters'; + +type ChatFiltersContextualBarProps = { + onClose: () => void; +}; + +const ChatFiltersContextualBar = ({ onClose }: ChatFiltersContextualBarProps) => { + const t = useTranslation(); + const canViewLivechatRooms = usePermission('view-livechat-rooms'); + const canViewCustomFields = usePermission('view-livechat-room-customfields'); + + const allCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); + const { data } = useQuery(['livechat/custom-fields'], async () => allCustomFields()); + const contactCustomFields = data?.customFields.filter((customField) => customField.scope !== 'visitor'); + + const { filtersQuery, setFiltersQuery, resetFiltersQuery } = useChatsFilters(); + const queryClient = useQueryClient(); + + const { + formState: { isDirty }, + handleSubmit, + control, + reset, + } = useForm({ + values: filtersQuery, + }); + + const statusOptions: [string, string][] = [ + ['all', t('All')], + ['closed', t('Closed')], + ['opened', t('Room_Status_Open')], + ['onhold', t('On_Hold_Chats')], + ['queued', t('Queued')], + ]; + + const handleSubmitFilters = (data: ChatsFiltersQuery) => { + setFiltersQuery(({ guest }) => ({ ...data, guest })); + queryClient.invalidateQueries(['current-chats']); + }; + + const handleResetFilters = () => { + resetFiltersQuery(); + reset(); + }; + + return ( + <> + + + {t('Filters')} + + + + + {t('From')} + + } + /> + + + + {t('To')} + + } + /> + + + {canViewLivechatRooms && ( + + {t('Served_By')} + + } + /> + + + )} + + {t('Status')} + [item, item])} + /> + )} + /> + + + ); + } + + return ( + + {customField.label} + + } + /> + + + ); + })} + + + + + + + + + ); +}; + +export default ChatFiltersContextualBar; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx index 46b5b6784ce0..f24f4779bee3 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx @@ -1,139 +1,93 @@ -import { Tag, Box, Pagination, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import { Pagination, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; +import { usePermission, useTranslation } from '@rocket.chat/ui-contexts'; import { hashQueryKey } from '@tanstack/react-query'; -import moment from 'moment'; -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo } from 'react'; -import FilterByText from '../../../../components/FilterByText'; import GenericNoResults from '../../../../components/GenericNoResults/GenericNoResults'; import { GenericTable, GenericTableBody, - GenericTableCell, GenericTableHeader, GenericTableHeaderCell, GenericTableLoadingTable, - GenericTableRow, } from '../../../../components/GenericTable'; import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import { useOmnichannelPriorities } from '../../../../omnichannel/hooks/useOmnichannelPriorities'; import { useCurrentChats } from '../../currentChats/hooks/useCurrentChats'; +import ChatFilterByText from './ChatFilterByText'; +import ChatTableRow from './ChatTableRow'; +import { useChatsFilters } from './useChatsFilters'; +import { useChatsQuery } from './useChatsQuery'; const ChatTable = () => { const t = useTranslation(); - const [text, setText] = useState(''); - const userIdLoggedIn = useUserId(); - const directoryRoute = useRoute('omnichannel-directory'); + const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); + + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + const { filtersQuery: filters } = useChatsFilters(); + const chatsQuery = useChatsQuery(); const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); - const { sortBy, sortDirection, setSort } = useSort<'fname' | 'department' | 'ts' | 'chatDuration' | 'closedAt'>('fname'); + const { sortBy, sortDirection, setSort } = useSort<'fname' | 'priorityWeight' | 'department.name' | 'servedBy' | 'ts' | 'lm' | 'status'>( + 'fname', + ); const query = useMemo( - () => ({ - sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, - roomName: text || '', - agents: userIdLoggedIn ? [userIdLoggedIn] : [], - ...(itemsPerPage && { count: itemsPerPage }), - ...(current && { offset: current }), - }), - [sortBy, current, sortDirection, itemsPerPage, userIdLoggedIn, text], + () => chatsQuery(filters, [sortBy, sortDirection], current, itemsPerPage), + [itemsPerPage, filters, sortBy, sortDirection, current, chatsQuery], ); - const onRowClick = useMutableCallback((id) => - directoryRoute.push({ - tab: 'chats', - context: 'info', - id, - }), - ); + const { data, isLoading, isSuccess, isError, refetch } = useCurrentChats(query); + + const [defaultQuery] = useState(hashQueryKey([query])); + const queryHasChanged = defaultQuery !== hashQueryKey([query]); const headers = ( <> - + {t('Contact_Name')} + {isPriorityEnabled && ( + + {t('Priority')} + + )} {t('Department')} - + + {t('Served_By')} + + {t('Started_At')} - - {t('Chat_Duration')} + + {t('Last_Message')} - - {t('Closed_At')} + + {t('Status')} + {canRemoveClosedChats && } ); - const { data, isLoading, isSuccess, isError, refetch } = useCurrentChats(query); - - const [defaultQuery] = useState(hashQueryKey([query])); - const queryHasChanged = defaultQuery !== hashQueryKey([query]); - - const renderRow = useCallback( - ({ _id, fname, ts, closedAt, department, tags }) => ( - onRowClick(_id)} action qa-user-id={_id}> - - - {fname} - {tags && ( - - {tags.map((tag: string) => ( - 10 ? 'hidden' : 'visible', - textOverflow: 'ellipsis', - }} - key={tag} - mie={4} - > - - {tag} - - - ))} - - )} - - - {department ? department.name : ''} - {moment(ts).format('L LTS')} - {moment(closedAt).from(moment(ts), true)} - {moment(closedAt).format('L LTS')} - - ), - [onRowClick], - ); - return ( <> - {((isSuccess && data?.rooms.length > 0) || queryHasChanged) && } + {isLoading && ( {headers} @@ -156,7 +110,11 @@ const ChatTable = () => { <> {headers} - {data?.rooms.map((room) => renderRow(room))} + + {data?.rooms.map((room) => ( + + ))} + { + const t = useTranslation(); + const { _id, fname, tags, servedBy, ts, lm, department, open, onHold, priorityWeight } = room; + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); + + const directoryRoute = useRoute('omnichannel-directory'); + + const getStatusText = (open = false, onHold = false): string => { + if (!open) { + return t('Closed'); + } + + if (open && !servedBy) { + return t('Queued'); + } + + return onHold ? t('On_Hold_Chats') : t('Room_Status_Open'); + }; + + const onRowClick = useEffectEvent((id) => + directoryRoute.push({ + tab: 'chats', + context: 'info', + id, + }), + ); + + return ( + onRowClick(_id)} action qa-user-id={_id}> + + + {fname} + {tags && ( + + {tags.map((tag: string) => ( + 10 ? 'hidden' : 'visible', + textOverflow: 'ellipsis', + }} + key={tag} + mie={4} + > + + {tag} + + + ))} + + )} + + + {isPriorityEnabled && ( + + + + )} + {department?.name} + {servedBy?.username} + {moment(ts).format('L LTS')} + {moment(lm).format('L LTS')} + + + {getStatusText(open, onHold)} + + {canRemoveClosedChats && {!open && }} + + ); +}; + +export default ChatTableRow; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts new file mode 100644 index 000000000000..0727e08abf98 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts @@ -0,0 +1,86 @@ +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +import { useFormatDate } from '../../../../hooks/useFormatDate'; + +export type ChatsFiltersQuery = { + guest: string; + servedBy: string; + status: string; + department: string; + from: string; + to: string; + tags: { _id: string; label: string; value: string }[]; + [key: string]: unknown; +}; + +const statusTextMap: { [key: string]: string } = { + all: 'All', + closed: 'Closed', + opened: 'Room_Status_Open', + onhold: 'On_Hold_Chats', + queued: 'Queued', +}; + +const initialValues: ChatsFiltersQuery = { + guest: '', + servedBy: 'all', + status: 'all', + department: 'all', + from: '', + to: '', + tags: [], +}; + +const useDisplayFilters = (filtersQuery: ChatsFiltersQuery) => { + const t = useTranslation(); + const formatDate = useFormatDate(); + + const { guest, servedBy, status, department, from, to, tags, ...customFields } = filtersQuery; + + const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: department }); + const getAgent = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: servedBy }); + + const { data: departmentData } = useQuery(['getDepartmentDataForFilter', department], () => getDepartment({})); + const { data: agentData } = useQuery(['getAgentDataForFilter', servedBy], () => getAgent()); + + const displayCustomFields = Object.entries(customFields).reduce((acc, [key, value]) => { + acc[key] = value ? `${key}: ${value}` : undefined; + return acc; + }, {} as { [key: string]: string | undefined }); + + return { + from: from !== '' ? `${t('From')}: ${formatDate(from)}` : undefined, + to: to !== '' ? `${t('To')}: ${formatDate(to)}` : undefined, + guest: guest !== '' ? `${t('Text')}: ${guest}` : undefined, + servedBy: servedBy !== 'all' ? `${t('Served_By')}: ${agentData?.user.name}` : undefined, + department: department !== 'all' ? `${t('Department')}: ${departmentData?.department.name}` : undefined, + status: status !== 'all' ? `${t('Status')}: ${t(statusTextMap[status] as TranslationKey)}` : undefined, + tags: tags.length > 0 ? tags.map((tag) => `${t('Tag')}: ${tag.label}`) : undefined, + ...displayCustomFields, + }; +}; + +export const useChatsFilters = () => { + const [filtersQuery, setFiltersQuery] = useLocalStorage('conversationsQuery', initialValues); + const displayFilters = useDisplayFilters(filtersQuery); + + const resetFiltersQuery = () => + setFiltersQuery((prevState) => { + const customFields = Object.keys(prevState).filter((item) => !Object.keys(initialValues).includes(item)); + + const initialCustomFields = customFields.reduce((acc, cv) => { + acc[cv] = ''; + return acc; + }, {} as { [key: string]: string }); + + return { ...initialValues, ...initialCustomFields }; + }); + + const removeFilter = (filter: keyof typeof initialValues) => + setFiltersQuery((prevState) => ({ ...prevState, [filter]: initialValues[filter] })); + + return { filtersQuery, setFiltersQuery, resetFiltersQuery, displayFilters, removeFilter }; +}; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts new file mode 100644 index 000000000000..193fd6d72aaa --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts @@ -0,0 +1,94 @@ +import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings'; +import { usePermission, useUserId } from '@rocket.chat/ui-contexts'; +import moment from 'moment'; + +import type { ChatsFiltersQuery } from './useChatsFilters'; + +type useQueryType = ( + debouncedParams: ChatsFiltersQuery, + [column, direction]: [string, 'asc' | 'desc'], + current: number, + itemsPerPage: 25 | 50 | 100, +) => GETLivechatRoomsParams; + +type CurrentChatQuery = { + agents?: string[]; + offset?: number; + roomName?: string; + departmentId?: string; + open?: boolean; + createdAt?: string; + closedAt?: string; + tags?: string[]; + onhold?: boolean; + customFields?: string; + sort: string; + count?: number; + queued?: boolean; +}; + +const sortDir = (sortDir: 'asc' | 'desc'): 1 | -1 => (sortDir === 'asc' ? 1 : -1); + +export const useChatsQuery = () => { + const userIdLoggedIn = useUserId(); + const canViewLivechatRooms = usePermission('view-livechat-rooms'); + + const chatsQuery: useQueryType = ( + { guest, servedBy, department, status, from, to, tags, ...customFields }, + [column, direction], + current, + itemsPerPage, + ) => { + const query: CurrentChatQuery = { + ...(guest && { roomName: guest }), + sort: JSON.stringify({ + [column]: sortDir(direction), + ts: column === 'ts' ? sortDir(direction) : undefined, + }), + ...(itemsPerPage && { count: itemsPerPage }), + ...(current && { offset: current }), + }; + + if (from || to) { + query.createdAt = JSON.stringify({ + ...(from && { + start: moment(new Date(from)).set({ hour: 0, minutes: 0, seconds: 0 }).toISOString(), + }), + ...(to && { + end: moment(new Date(to)).set({ hour: 23, minutes: 59, seconds: 59 }).toISOString(), + }), + }); + } + + if (status !== 'all') { + query.open = status === 'opened' || status === 'onhold' || status === 'queued'; + query.onhold = status === 'onhold'; + query.queued = status === 'queued'; + } + + if (canViewLivechatRooms && servedBy && servedBy !== 'all') { + query.agents = [servedBy]; + } else { + query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; + } + + if (department && department !== 'all') { + query.departmentId = department; + } + + if (tags && tags.length > 0) { + query.tags = tags.map((tag) => tag.value); + } + + if (customFields && Object.keys(customFields).length > 0) { + const customFieldsQuery = Object.fromEntries(Object.entries(customFields).filter((item) => item[1] !== undefined && item[1] !== '')); + if (Object.keys(customFieldsQuery).length > 0) { + query.customFields = JSON.stringify(customFieldsQuery); + } + } + + return query; + }; + + return chatsQuery; +}; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index a9745288f967..7c3a15c21c38 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -91,6 +91,7 @@ test.describe('Omnichannel Contact Center', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await poOmniSection.btnContactCenter.click(); + await poOmniSection.tabContacts.click(); await page.waitForURL(URL.contactCenter); }); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts index 87a34a66356e..e1f2cfb23de2 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts @@ -22,4 +22,8 @@ export class OmnichannelSection { get btnContactCenter(): Locator { return this.page.locator('role=button[name="Contact Center"]'); } + + get tabContacts(): Locator { + return this.page.locator('role=tab[name="Contacts"]'); + } } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index cba7fbede924..16cfa0142d9a 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -1,3 +1,4 @@ +import type { ILivechatDepartment } from './ILivechatDepartment'; import type { ILivechatPriority } from './ILivechatPriority'; import type { ILivechatVisitor } from './ILivechatVisitor'; import type { IMessage, MessageTypesValues } from './IMessage'; @@ -354,6 +355,8 @@ export type IOmnichannelRoomClosingInfo = Pick): room is IOmnichannelRoom & IRoom => room.t === 'l'; export const isVoipRoom = (room: IRoom): room is IVoipRoom & IRoom => room.t === 'v'; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 8b7fd7162934..d28e11ef3e97 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -9,6 +9,7 @@ import type { ILivechatVisitorDTO, IMessage, IOmnichannelRoom, + IOmnichannelRoomWithDepartment, IRoom, ISetting, ILivechatAgentActivity, @@ -3941,7 +3942,7 @@ export type OmnichannelEndpoints = { DELETE: () => void; }; '/v1/livechat/rooms': { - GET: (params: GETLivechatRoomsParams) => PaginatedResult<{ rooms: IOmnichannelRoom[] }>; + GET: (params: GETLivechatRoomsParams) => PaginatedResult<{ rooms: IOmnichannelRoomWithDepartment[] }>; }; '/v1/livechat/room/:rid/priority': { POST: (params: POSTLivechatRoomPriorityParams) => void; From 094b155205bbfa617da6a9b5f0d99610385f0d93 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 14 Oct 2024 14:17:40 -0300 Subject: [PATCH 010/173] chore: Get livechat rooms rooms properly on chats table (#33568) --- .../views/omnichannel/directory/chats/useChatsQuery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts index 193fd6d72aaa..494741d2d794 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts @@ -66,10 +66,12 @@ export const useChatsQuery = () => { query.queued = status === 'queued'; } + if (!canViewLivechatRooms) { + query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; + } + if (canViewLivechatRooms && servedBy && servedBy !== 'all') { query.agents = [servedBy]; - } else { - query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; } if (department && department !== 'all') { From 047b31d4454cd2b279726d2d1cf0b91bdff9f075 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:38:17 -0300 Subject: [PATCH 011/173] feat: Add support for Omnichannel Contacts on the Importer system (#33422) * feat: Add support for Omnichannel Contacts on the Importer system * fixed type * Added a separate importer option just for contacts * missing types and validations * changeset * wtf * Update .changeset/happy-clouds-smell.md Co-authored-by: Marcos Spessatto Defendi * merge fixes --------- Co-authored-by: Marcos Spessatto Defendi --- .changeset/happy-clouds-smell.md | 9 ++ .../app/importer-csv/server/CsvImporter.ts | 27 ++++-- .../importer-csv/server/addParsedContacts.ts | 36 ++++++++ .../server/ContactImporter.ts | 48 +++++++++++ .../server/index.ts | 11 +++ .../server/PendingAvatarImporter.ts | 4 +- .../server/PendingFileImporter.ts | 4 +- .../app/importer/lib/ImporterProgressStep.ts | 4 + .../server/classes/ImportDataConverter.ts | 11 ++- .../app/importer/server/classes/Importer.ts | 38 +++++++-- .../server/classes/ImporterSelection.ts | 20 ++++- .../server/methods/getImportFileData.ts | 1 + .../importer/server/methods/startImport.ts | 6 +- .../views/admin/import/ImportHistoryPage.tsx | 3 + .../admin/import/ImportOperationSummary.tsx | 4 +- .../import/ImportOperationSummarySkeleton.tsx | 3 + .../views/admin/import/PrepareContacts.tsx | 84 +++++++++++++++++++ .../views/admin/import/PrepareImportPage.tsx | 17 +++- apps/meteor/server/importPackages.ts | 1 + apps/meteor/server/models/raw/ImportData.ts | 17 ++++ apps/meteor/server/services/import/service.ts | 4 +- packages/core-typings/src/import/IImport.ts | 1 + .../core-typings/src/import/IImportContact.ts | 9 ++ .../src/import/IImportProgress.ts | 2 + .../core-typings/src/import/IImportRecord.ts | 10 ++- .../src/import/IImporterSelection.ts | 4 +- .../src/import/IImporterSelectionContact.ts | 13 +++ packages/core-typings/src/import/index.ts | 2 + packages/i18n/src/locales/en.i18n.json | 1 + .../src/models/IImportDataModel.ts | 9 +- .../src/v1/import/StartImportParamsPOST.ts | 13 +++ 31 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 .changeset/happy-clouds-smell.md create mode 100644 apps/meteor/app/importer-csv/server/addParsedContacts.ts create mode 100644 apps/meteor/app/importer-omnichannel-contacts/server/ContactImporter.ts create mode 100644 apps/meteor/app/importer-omnichannel-contacts/server/index.ts create mode 100644 apps/meteor/client/views/admin/import/PrepareContacts.tsx create mode 100644 packages/core-typings/src/import/IImportContact.ts create mode 100644 packages/core-typings/src/import/IImporterSelectionContact.ts diff --git a/.changeset/happy-clouds-smell.md b/.changeset/happy-clouds-smell.md new file mode 100644 index 000000000000..d23cc7ae18a7 --- /dev/null +++ b/.changeset/happy-clouds-smell.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added option to import omnichannel contacts from CSV files diff --git a/apps/meteor/app/importer-csv/server/CsvImporter.ts b/apps/meteor/app/importer-csv/server/CsvImporter.ts index a9844e747640..2f875475ca74 100644 --- a/apps/meteor/app/importer-csv/server/CsvImporter.ts +++ b/apps/meteor/app/importer-csv/server/CsvImporter.ts @@ -8,9 +8,11 @@ import type { ConverterOptions } from '../../importer/server/classes/ImportDataC import type { ImporterProgress } from '../../importer/server/classes/ImporterProgress'; import type { ImporterInfo } from '../../importer/server/definitions/ImporterInfo'; import { notifyOnSettingChanged } from '../../lib/server/lib/notifyListener'; +import { isSingleContactEnabled } from '../../livechat/server/lib/Contacts'; +import { addParsedContacts } from './addParsedContacts'; export class CsvImporter extends Importer { - private csvParser: (csv: string) => string[]; + private csvParser: (csv: string) => string[][]; constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { super(info, importRecord, converterOptions); @@ -46,6 +48,7 @@ export class CsvImporter extends Importer { let messagesCount = 0; let usersCount = 0; let channelsCount = 0; + let contactsCount = 0; const dmRooms = new Set(); const roomIds = new Map(); const usedUsernames = new Set(); @@ -140,6 +143,20 @@ export class CsvImporter extends Importer { continue; } + // Parse the contacts + if (entry.entryName.toLowerCase() === 'contacts.csv') { + if (isSingleContactEnabled()) { + await super.updateProgress(ProgressStep.PREPARING_CONTACTS); + const parsedContacts = this.csvParser(entry.getData().toString()); + + contactsCount = await addParsedContacts.call(this.converter, parsedContacts); + + await super.updateRecord({ 'count.contacts': contactsCount }); + } + increaseProgressCount(); + continue; + } + // Parse the messages if (entry.entryName.indexOf('/') > -1) { if (this.progress.step !== ProgressStep.PREPARING_MESSAGES) { @@ -258,12 +275,12 @@ export class CsvImporter extends Importer { } } - await super.addCountToTotal(messagesCount + usersCount + channelsCount); + await super.addCountToTotal(messagesCount + usersCount + channelsCount + contactsCount); ImporterWebsocket.progressUpdated({ rate: 100 }); - // Ensure we have at least a single user, channel, or message - if (usersCount === 0 && channelsCount === 0 && messagesCount === 0) { - this.logger.error('No users, channels, or messages found in the import file.'); + // Ensure we have at least a single record of any kind + if (usersCount === 0 && channelsCount === 0 && messagesCount === 0 && contactsCount === 0) { + this.logger.error('No valid record found in the import file.'); await super.updateProgress(ProgressStep.ERROR); } diff --git a/apps/meteor/app/importer-csv/server/addParsedContacts.ts b/apps/meteor/app/importer-csv/server/addParsedContacts.ts new file mode 100644 index 000000000000..ac8e62443abf --- /dev/null +++ b/apps/meteor/app/importer-csv/server/addParsedContacts.ts @@ -0,0 +1,36 @@ +import { Random } from '@rocket.chat/random'; + +import type { ImportDataConverter } from '../../importer/server/classes/ImportDataConverter'; + +export async function addParsedContacts(this: ImportDataConverter, parsedContacts: string[][]): Promise { + const columnNames = parsedContacts.shift(); + let addedContacts = 0; + + for await (const parsedData of parsedContacts) { + const contactData = parsedData.reduce((acc, value, index) => { + const columnName = columnNames && index < columnNames.length ? columnNames[index] : `column${index}`; + return { + ...acc, + [columnName]: value, + }; + }, {} as Record); + + if (!contactData.emails && !contactData.phones && !contactData.name) { + continue; + } + + const { emails = '', phones = '', name = '', manager: contactManager = undefined, ...customFields } = contactData; + + await this.addContact({ + importIds: [Random.id()], + emails: emails.split(';'), + phones: phones.split(';'), + name, + contactManager, + customFields, + }); + addedContacts++; + } + + return addedContacts; +} diff --git a/apps/meteor/app/importer-omnichannel-contacts/server/ContactImporter.ts b/apps/meteor/app/importer-omnichannel-contacts/server/ContactImporter.ts new file mode 100644 index 000000000000..2cec5dff3af9 --- /dev/null +++ b/apps/meteor/app/importer-omnichannel-contacts/server/ContactImporter.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; + +import type { IImport } from '@rocket.chat/core-typings'; +import { parse } from 'csv-parse/lib/sync'; + +import { addParsedContacts } from '../../importer-csv/server/addParsedContacts'; +import { Importer, ProgressStep, ImporterWebsocket } from '../../importer/server'; +import type { ConverterOptions } from '../../importer/server/classes/ImportDataConverter'; +import type { ImporterProgress } from '../../importer/server/classes/ImporterProgress'; +import type { ImporterInfo } from '../../importer/server/definitions/ImporterInfo'; + +export class ContactImporter extends Importer { + private csvParser: (csv: string) => string[][]; + + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + super(info, importRecord, converterOptions); + + this.csvParser = parse; + } + + async prepareUsingLocalFile(fullFilePath: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + + ImporterWebsocket.progressUpdated({ rate: 0 }); + + await super.updateProgress(ProgressStep.PREPARING_CONTACTS); + // Reading the whole file at once for compatibility with the code written for the other importers + // We can change this to a stream once we improve the rest of the importer classes + const fileContents = fs.readFileSync(fullFilePath, { encoding: 'utf8' }); + if (!fileContents || typeof fileContents !== 'string') { + throw new Error('Failed to load file contents.'); + } + + const parsedContacts = this.csvParser(fileContents); + const contactsCount = await addParsedContacts.call(this.converter, parsedContacts); + + if (contactsCount === 0) { + this.logger.error('No contacts found in the import file.'); + await super.updateProgress(ProgressStep.ERROR); + } else { + await super.updateRecord({ 'count.contacts': contactsCount, 'count.total': contactsCount }); + ImporterWebsocket.progressUpdated({ rate: 100 }); + } + + return super.getProgress(); + } +} diff --git a/apps/meteor/app/importer-omnichannel-contacts/server/index.ts b/apps/meteor/app/importer-omnichannel-contacts/server/index.ts new file mode 100644 index 000000000000..592a506c4109 --- /dev/null +++ b/apps/meteor/app/importer-omnichannel-contacts/server/index.ts @@ -0,0 +1,11 @@ +import { Importers } from '../../importer/server'; +import { isSingleContactEnabled } from '../../livechat/server/lib/Contacts'; +import { ContactImporter } from './ContactImporter'; + +if (isSingleContactEnabled()) { + Importers.add({ + key: 'omnichannel_contact', + name: 'omnichannel_contacts_importer', + importer: ContactImporter, + }); +} diff --git a/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts b/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts index 0f6c8c7d41df..6fca7b271806 100644 --- a/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts +++ b/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts @@ -20,7 +20,7 @@ export class PendingAvatarImporter extends Importer { await this.updateRecord({ 'count.messages': fileCount, 'messagesstatus': null }); await this.addCountToTotal(fileCount); - const fileData = new Selection(this.info.name, [], [], fileCount); + const fileData = new Selection(this.info.name, [], [], fileCount, []); await this.updateRecord({ fileData }); await super.updateProgress(ProgressStep.IMPORTING_FILES); @@ -31,7 +31,7 @@ export class PendingAvatarImporter extends Importer { return fileCount; } - async startImport(importSelection: Selection): Promise { + async startImport(importSelection: Selection): Promise { const pendingFileUserList = Users.findAllUsersWithPendingAvatar(); try { for await (const user of pendingFileUserList) { diff --git a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts index 400a9856c4e7..fab9ded51e3f 100644 --- a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts +++ b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts @@ -30,7 +30,7 @@ export class PendingFileImporter extends Importer { await this.updateRecord({ 'count.messages': fileCount, 'messagesstatus': null }); await this.addCountToTotal(fileCount); - const fileData = new Selection(this.info.name, [], [], fileCount); + const fileData = new Selection(this.info.name, [], [], fileCount, []); await this.updateRecord({ fileData }); await super.updateProgress(ProgressStep.IMPORTING_FILES); @@ -41,7 +41,7 @@ export class PendingFileImporter extends Importer { return fileCount; } - async startImport(importSelection: Selection): Promise { + async startImport(importSelection: Selection): Promise { const downloadedFileIds: string[] = []; const maxFileCount = 10; const maxFileSize = 1024 * 1024 * 500; diff --git a/apps/meteor/app/importer/lib/ImporterProgressStep.ts b/apps/meteor/app/importer/lib/ImporterProgressStep.ts index 1b5ffe53c93f..5e7ea1b75966 100644 --- a/apps/meteor/app/importer/lib/ImporterProgressStep.ts +++ b/apps/meteor/app/importer/lib/ImporterProgressStep.ts @@ -11,6 +11,7 @@ export const ProgressStep = Object.freeze({ PREPARING_USERS: 'importer_preparing_users', PREPARING_CHANNELS: 'importer_preparing_channels', PREPARING_MESSAGES: 'importer_preparing_messages', + PREPARING_CONTACTS: 'importer_preparing_contacts', USER_SELECTION: 'importer_user_selection', @@ -18,6 +19,7 @@ export const ProgressStep = Object.freeze({ IMPORTING_USERS: 'importer_importing_users', IMPORTING_CHANNELS: 'importer_importing_channels', IMPORTING_MESSAGES: 'importer_importing_messages', + IMPORTING_CONTACTS: 'importer_importing_contacts', IMPORTING_FILES: 'importer_importing_files', FINISHING: 'importer_finishing', @@ -35,6 +37,7 @@ export const ImportPreparingStartedStates: IImportProgress['step'][] = [ ProgressStep.PREPARING_USERS, ProgressStep.PREPARING_CHANNELS, ProgressStep.PREPARING_MESSAGES, + ProgressStep.PREPARING_CONTACTS, ]; export const ImportingStartedStates: IImportProgress['step'][] = [ @@ -42,6 +45,7 @@ export const ImportingStartedStates: IImportProgress['step'][] = [ ProgressStep.IMPORTING_USERS, ProgressStep.IMPORTING_CHANNELS, ProgressStep.IMPORTING_MESSAGES, + ProgressStep.IMPORTING_CONTACTS, ProgressStep.IMPORTING_FILES, ProgressStep.FINISHING, ]; diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index 64226f8752a1..f878b3069110 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -1,4 +1,4 @@ -import type { IImportRecord, IImportUser, IImportMessage, IImportChannel } from '@rocket.chat/core-typings'; +import type { IImportRecord, IImportUser, IImportMessage, IImportChannel, IImportContact } from '@rocket.chat/core-typings'; import type { Logger } from '@rocket.chat/logger'; import { ImportData } from '@rocket.chat/models'; import { pick } from '@rocket.chat/tools'; @@ -90,6 +90,10 @@ export class ImportDataConverter { this._messageConverter = new MessageConverter(messageOptions, logger, this._cache); } + async addContact(_data: IImportContact): Promise { + // #ToDo + } + async addUser(data: IImportUser): Promise { return this._userConverter.addObject(data); } @@ -104,6 +108,10 @@ export class ImportDataConverter { }); } + async convertContacts(_callbacks: IConversionCallbacks): Promise { + // #ToDo + } + async convertUsers(callbacks: IConversionCallbacks): Promise { return this._userConverter.convertData(callbacks); } @@ -118,6 +126,7 @@ export class ImportDataConverter { async convertData(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise { await this.convertUsers(callbacks); + await this.convertContacts(callbacks); await this.convertChannels(startedByUserId, callbacks); await this.convertMessages(callbacks); diff --git a/apps/meteor/app/importer/server/classes/Importer.ts b/apps/meteor/app/importer/server/classes/Importer.ts index d89cb5f979f3..72b5800708eb 100644 --- a/apps/meteor/app/importer/server/classes/Importer.ts +++ b/apps/meteor/app/importer/server/classes/Importer.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { IImport, IImportRecord, IImportChannel, IImportUser, IImportProgress } from '@rocket.chat/core-typings'; +import type { IImport, IImportRecord, IImportChannel, IImportUser, IImportProgress, IImportContact } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Settings, ImportData, Imports } from '@rocket.chat/models'; import AdmZip from 'adm-zip'; @@ -94,7 +94,7 @@ export class Importer { * @param {Selection} importSelection The selection data. * @returns {ImporterProgress} The progress record of the import. */ - async startImport(importSelection: Selection, startedByUserId: string): Promise { + async startImport(importSelection: Selection, startedByUserId: string): Promise { if (!(importSelection instanceof Selection)) { throw new Error(`Invalid Selection data provided to the ${this.info.name} importer.`); } else if (importSelection.users === undefined) { @@ -156,6 +156,19 @@ export class Importer { return false; } + + case 'contact': { + const contactData = data as IImportContact; + + const id = contactData.importIds[0]; + for (const contact of importSelection.contacts) { + if (contact.id === id) { + return contact.do_import; + } + } + + return false; + } } return false; @@ -202,6 +215,9 @@ export class Importer { await callbacks.run('beforeUserImport', { userCount: usersToImport.length }); await this.converter.convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn }); + await this.updateProgress(ProgressStep.IMPORTING_CONTACTS); + await this.converter.convertContacts({ beforeImportFn, afterImportFn, onErrorFn }); + await this.updateProgress(ProgressStep.IMPORTING_CHANNELS); await this.converter.convertChannels(startedByUserId, { beforeImportFn, afterImportFn, onErrorFn }); @@ -324,14 +340,10 @@ export class Importer { } async maybeUpdateRecord() { - // Only update the database every 500 messages (or 50 for users/channels) + // Only update the database every 500 messages (or 50 for other records) // Or the completed is greater than or equal to the total amount const count = this.progress.count.completed + this.progress.count.error; - const range = ([ProgressStep.IMPORTING_USERS, ProgressStep.IMPORTING_CHANNELS] as IImportProgress['step'][]).includes( - this.progress.step, - ) - ? 50 - : 500; + const range = this.progress.step === ProgressStep.IMPORTING_MESSAGES ? 500 : 50; if (count % range === 0 || count >= this.progress.count.total || count - this._lastProgressReportTotal > range) { this._lastProgressReportTotal = this.progress.count.completed + this.progress.count.error; @@ -379,6 +391,7 @@ export class Importer { const users = await ImportData.getAllUsersForSelection(); const channels = await ImportData.getAllChannelsForSelection(); + const contacts = await ImportData.getAllContactsForSelection(); const hasDM = await ImportData.checkIfDirectMessagesExists(); const selectionUsers = users.map( @@ -388,13 +401,20 @@ export class Importer { const selectionChannels = channels.map( (c) => new SelectionChannel(c.data.importIds[0], c.data.name, Boolean(c.data.archived), true, c.data.t === 'p', c.data.t === 'd'), ); + const selectionContacts = contacts.map((c) => ({ + id: c.data.importIds[0], + name: c.data.name || '', + emails: c.data.emails || [], + phones: c.data.phones || [], + do_import: true, + })); const selectionMessages = await ImportData.countMessages(); if (hasDM) { selectionChannels.push(new SelectionChannel('__directMessages__', t('Direct_Messages'), false, true, true, true)); } - const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages); + const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages, selectionContacts); return results; } diff --git a/apps/meteor/app/importer/server/classes/ImporterSelection.ts b/apps/meteor/app/importer/server/classes/ImporterSelection.ts index 107dbbf9c824..1ae6e16bf95a 100644 --- a/apps/meteor/app/importer/server/classes/ImporterSelection.ts +++ b/apps/meteor/app/importer/server/classes/ImporterSelection.ts @@ -1,12 +1,19 @@ -import type { IImporterSelection, IImporterSelectionChannel, IImporterSelectionUser } from '@rocket.chat/core-typings'; +import type { + IImporterSelection, + IImporterSelectionChannel, + IImporterSelectionUser, + IImporterSelectionContactOrIdentifier, +} from '@rocket.chat/core-typings'; -export class ImporterSelection implements IImporterSelection { +export class ImporterSelection implements IImporterSelection { public name: string; public users: IImporterSelectionUser[]; public channels: IImporterSelectionChannel[]; + public contacts: IImporterSelectionContactOrIdentifier[]; + public message_count: number; /** @@ -17,10 +24,17 @@ export class ImporterSelection implements IImporterSelection { * @param channels the channels which can be selected * @param messageCount the number of messages */ - constructor(name: string, users: IImporterSelectionUser[], channels: IImporterSelectionChannel[], messageCount: number) { + constructor( + name: string, + users: IImporterSelectionUser[], + channels: IImporterSelectionChannel[], + messageCount: number, + contacts: IImporterSelectionContactOrIdentifier[], + ) { this.name = name; this.users = users; this.channels = channels; this.message_count = messageCount; + this.contacts = contacts; } } diff --git a/apps/meteor/app/importer/server/methods/getImportFileData.ts b/apps/meteor/app/importer/server/methods/getImportFileData.ts index 1d36f7fc5a5e..522a9e8b7124 100644 --- a/apps/meteor/app/importer/server/methods/getImportFileData.ts +++ b/apps/meteor/app/importer/server/methods/getImportFileData.ts @@ -31,6 +31,7 @@ export const executeGetImportFileData = async (): Promise new SelectionChannel(channel.channel_id, channel.name, channel.is_archived, channel.do_import, channel.is_private, channel.is_direct), ); - const selection = new Selection(importer.name, usersSelection, channelsSelection, 0); + const contactSelection = (input.contacts || []).map(({ id, do_import = true }) => ({ + id, + do_import, + })); + const selection = new Selection(importer.name, usersSelection, channelsSelection, 0, contactSelection); await instance.startImport(selection, startedByUserId); }; diff --git a/apps/meteor/client/views/admin/import/ImportHistoryPage.tsx b/apps/meteor/client/views/admin/import/ImportHistoryPage.tsx index 42b8cee969f5..71aab86a8487 100644 --- a/apps/meteor/client/views/admin/import/ImportHistoryPage.tsx +++ b/apps/meteor/client/views/admin/import/ImportHistoryPage.tsx @@ -151,6 +151,9 @@ function ImportHistoryPage() { {t('Users')} + + {t('Contacts')} + {t('Channels')} diff --git a/apps/meteor/client/views/admin/import/ImportOperationSummary.tsx b/apps/meteor/client/views/admin/import/ImportOperationSummary.tsx index dc8d6bfe0ec5..6f7529704723 100644 --- a/apps/meteor/client/views/admin/import/ImportOperationSummary.tsx +++ b/apps/meteor/client/views/admin/import/ImportOperationSummary.tsx @@ -24,6 +24,7 @@ type ImportOperationSummaryProps = { users?: number; channels?: number; messages?: number; + contacts?: number; total?: number; }; valid?: boolean; @@ -37,7 +38,7 @@ function ImportOperationSummary({ file = '', user, small, - count: { users = 0, channels = 0, messages = 0, total = 0 } = {}, + count: { users = 0, channels = 0, messages = 0, total = 0, contacts = 0 } = {}, valid, }: ImportOperationSummaryProps) { const t = useTranslation(); @@ -101,6 +102,7 @@ function ImportOperationSummary({ {status && t(status.replace('importer_', 'importer_status_') as TranslationKey)} {fileName} {users} + {contacts} {channels} {messages} {total} diff --git a/apps/meteor/client/views/admin/import/ImportOperationSummarySkeleton.tsx b/apps/meteor/client/views/admin/import/ImportOperationSummarySkeleton.tsx index 8c2a465cb58b..42391a253d82 100644 --- a/apps/meteor/client/views/admin/import/ImportOperationSummarySkeleton.tsx +++ b/apps/meteor/client/views/admin/import/ImportOperationSummarySkeleton.tsx @@ -34,6 +34,9 @@ function ImportOperationSummarySkeleton({ small = false }: ImportOperationSummar + + + )} diff --git a/apps/meteor/client/views/admin/import/PrepareContacts.tsx b/apps/meteor/client/views/admin/import/PrepareContacts.tsx new file mode 100644 index 000000000000..c3e79d919e22 --- /dev/null +++ b/apps/meteor/client/views/admin/import/PrepareContacts.tsx @@ -0,0 +1,84 @@ +import type { IImporterSelectionContact } from '@rocket.chat/core-typings'; +import { CheckBox, Table, Pagination, TableHead, TableRow, TableCell, TableBody } from '@rocket.chat/fuselage'; +import type { Dispatch, SetStateAction, ChangeEvent } from 'react'; +import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type PrepareContactsProps = { + contactsCount: number; + contacts: IImporterSelectionContact[]; + setContacts: Dispatch>; +}; + +const PrepareContacts = ({ contactsCount, contacts, setContacts }: PrepareContactsProps) => { + const { t } = useTranslation(); + const [current, setCurrent] = useState(0); + const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); + const showingResultsLabel = useCallback( + ({ count, current, itemsPerPage }) => + t('Showing_results_of', { + postProcess: 'sprintf', + sprintf: [current + 1, Math.min(current + itemsPerPage, count), count], + }), + [t], + ); + const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]); + + return ( + <> + + + + + 0} + indeterminate={contactsCount > 0 && contactsCount !== contacts.length} + onChange={(): void => { + setContacts((contacts) => { + const isChecking = contactsCount === 0; + + return contacts.map((contact) => ({ ...contact, do_import: isChecking })); + }); + }} + /> + + {t('Name')} + {t('Emails')} + {t('Phones')} + + + + {contacts.slice(current, current + itemsPerPage).map((contact) => ( + + + ): void => { + const { checked } = event.currentTarget; + setContacts((contacts) => + contacts.map((_contact) => (_contact === contact ? { ..._contact, do_import: checked } : _contact)), + ); + }} + /> + + {contact.name} + {contact.emails.join('\n')} + {contact.phones.join('\n')} + + ))} + +
+ + + ); +}; + +export default PrepareContacts; diff --git a/apps/meteor/client/views/admin/import/PrepareImportPage.tsx b/apps/meteor/client/views/admin/import/PrepareImportPage.tsx index 39002b3c3085..df0ab77c9419 100644 --- a/apps/meteor/client/views/admin/import/PrepareImportPage.tsx +++ b/apps/meteor/client/views/admin/import/PrepareImportPage.tsx @@ -1,4 +1,4 @@ -import type { IImport, IImporterSelection, Serialized } from '@rocket.chat/core-typings'; +import type { IImport, IImporterSelection, IImporterSelectionContact, Serialized } from '@rocket.chat/core-typings'; import { Badge, Box, Button, ButtonGroup, Margins, ProgressBar, Throbber, Tabs } from '@rocket.chat/fuselage'; import { useDebouncedValue, useSafely } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; @@ -17,6 +17,7 @@ import { numberFormat } from '../../../../lib/utils/stringUtils'; import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page'; import type { ChannelDescriptor } from './ChannelDescriptor'; import PrepareChannels from './PrepareChannels'; +import PrepareContacts from './PrepareContacts'; import PrepareUsers from './PrepareUsers'; import type { UserDescriptor } from './UserDescriptor'; import { useErrorHandler } from './useErrorHandler'; @@ -47,11 +48,13 @@ function PrepareImportPage() { const [status, setStatus] = useSafely(useState(null)); const [messageCount, setMessageCount] = useSafely(useState(0)); const [users, setUsers] = useState([]); + const [contacts, setContacts] = useState([]); const [channels, setChannels] = useState([]); const [isImporting, setImporting] = useSafely(useState(false)); const usersCount = useMemo(() => users.filter(({ do_import }) => do_import).length, [users]); const channelsCount = useMemo(() => channels.filter(({ do_import }) => do_import).length, [channels]); + const contactsCount = useMemo(() => contacts.filter(({ do_import }) => do_import).length, [contacts]); const router = useRouter(); @@ -89,6 +92,7 @@ function PrepareImportPage() { setMessageCount(data.message_count); setUsers(data.users.map((user) => ({ ...user, username: user.username ?? '', do_import: true }))); setChannels(data.channels.map((channel) => ({ ...channel, name: channel.name ?? '', do_import: true }))); + setContacts(data.contacts?.map((contact) => ({ ...contact, name: contact.name ?? '', do_import: true })) || []); setPreparing(false); setProgressRate(null); } catch (error) { @@ -155,6 +159,7 @@ function PrepareImportPage() { input: { users: users.map((user) => ({ is_bot: false, is_email_taken: false, ...user })), channels: channels.map((channel) => ({ is_private: false, is_direct: false, ...channel })), + contacts: contacts.map(({ id, do_import }) => ({ id, do_import })), }, }); router.navigate('/admin/import/progress'); @@ -170,8 +175,8 @@ function PrepareImportPage() { const statusDebounced = useDebouncedValue(status, 100); const handleMinimumImportData = !!( - (!usersCount && !channelsCount && !messageCount) || - (!usersCount && !channelsCount && messageCount !== 0) + (!usersCount && !channelsCount && !contactsCount && !messageCount) || + (!usersCount && !channelsCount && !contactsCount && messageCount !== 0) ); return ( @@ -193,6 +198,9 @@ function PrepareImportPage() { {t('Users')} {usersCount} + + {t('Contacts')} {contactsCount} + {t('Channels')} {channelsCount} @@ -218,6 +226,9 @@ function PrepareImportPage() { )} {!isPreparing && tab === 'users' && } + {!isPreparing && tab === 'contacts' && ( + + )} {!isPreparing && tab === 'channels' && ( )} diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index 2b4e3106ed45..e4e6ebd93f61 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -27,6 +27,7 @@ import '../app/iframe-login/server'; import '../app/importer/server'; import '../app/importer-csv/server'; import '../app/importer-hipchat-enterprise/server'; +import '../app/importer-omnichannel-contacts/server'; import '../app/importer-pending-files/server'; import '../app/importer-pending-avatars/server'; import '../app/importer-slack/server'; diff --git a/apps/meteor/server/models/raw/ImportData.ts b/apps/meteor/server/models/raw/ImportData.ts index e38670662a3f..239aacf6dfb0 100644 --- a/apps/meteor/server/models/raw/ImportData.ts +++ b/apps/meteor/server/models/raw/ImportData.ts @@ -3,6 +3,7 @@ import type { IImportMessageRecord, IImportRecord, IImportUserRecord, + IImportContactRecord, RocketChatRecordDeleted, } from '@rocket.chat/core-typings'; import type { IImportDataModel } from '@rocket.chat/model-typings'; @@ -67,6 +68,22 @@ export class ImportDataRaw extends BaseRaw implements IImportData ).toArray(); } + getAllContactsForSelection(): Promise { + return this.find( + { + dataType: 'contact', + }, + { + projection: { + 'data.importIds': 1, + 'data.name': 1, + 'data.phones': 1, + 'data.emails': 1, + }, + }, + ).toArray(); + } + async checkIfDirectMessagesExists(): Promise { return ( (await this.col.countDocuments({ diff --git a/apps/meteor/server/services/import/service.ts b/apps/meteor/server/services/import/service.ts index cb95b2d1aa8f..9a0477fbcedb 100644 --- a/apps/meteor/server/services/import/service.ts +++ b/apps/meteor/server/services/import/service.ts @@ -56,6 +56,7 @@ export class ImportService extends ServiceClassInternal implements IImportServic case 'importer_preparing_users': case 'importer_preparing_channels': case 'importer_preparing_messages': + case 'importer_preparing_contacts': return 'loading'; case 'importer_user_selection': return 'ready'; @@ -63,6 +64,7 @@ export class ImportService extends ServiceClassInternal implements IImportServic case 'importer_importing_users': case 'importer_importing_channels': case 'importer_importing_messages': + case 'importer_importing_contacts': case 'importer_importing_files': case 'importer_finishing': return 'importing'; @@ -175,7 +177,7 @@ export class ImportService extends ServiceClassInternal implements IImportServic skipExistingUsers: true, }); - const selection = new ImporterSelection(importer.name, [], [], 0); + const selection = new ImporterSelection(importer.name, [], [], 0, []); await instance.startImport(selection, userId); } } diff --git a/packages/core-typings/src/import/IImport.ts b/packages/core-typings/src/import/IImport.ts index 11fc3028dbbd..344371826efc 100644 --- a/packages/core-typings/src/import/IImport.ts +++ b/packages/core-typings/src/import/IImport.ts @@ -19,5 +19,6 @@ export interface IImport extends IRocketChatRecord { users?: number; messages?: number; channels?: number; + contacts?: number; }; } diff --git a/packages/core-typings/src/import/IImportContact.ts b/packages/core-typings/src/import/IImportContact.ts new file mode 100644 index 000000000000..c9fbe6960168 --- /dev/null +++ b/packages/core-typings/src/import/IImportContact.ts @@ -0,0 +1,9 @@ +export interface IImportContact { + importIds: string[]; + _id?: string; + name?: string; + phones?: string[]; + emails?: string[]; + contactManager?: string; + customFields?: Record; +} diff --git a/packages/core-typings/src/import/IImportProgress.ts b/packages/core-typings/src/import/IImportProgress.ts index b9f91a16120d..311f59e1cf49 100644 --- a/packages/core-typings/src/import/IImportProgress.ts +++ b/packages/core-typings/src/import/IImportProgress.ts @@ -7,11 +7,13 @@ export type ProgressStep = | 'importer_preparing_users' | 'importer_preparing_channels' | 'importer_preparing_messages' + | 'importer_preparing_contacts' | 'importer_user_selection' | 'importer_importing_started' | 'importer_importing_users' | 'importer_importing_channels' | 'importer_importing_messages' + | 'importer_importing_contacts' | 'importer_importing_files' | 'importer_finishing' | 'importer_done' diff --git a/packages/core-typings/src/import/IImportRecord.ts b/packages/core-typings/src/import/IImportRecord.ts index 9adf58e284ed..5f572e54dee0 100644 --- a/packages/core-typings/src/import/IImportRecord.ts +++ b/packages/core-typings/src/import/IImportRecord.ts @@ -1,9 +1,10 @@ import type { IImportChannel } from './IImportChannel'; +import type { IImportContact } from './IImportContact'; import type { IImportMessage } from './IImportMessage'; import type { IImportUser } from './IImportUser'; -export type IImportRecordType = 'user' | 'channel' | 'message'; -export type IImportData = IImportUser | IImportChannel | IImportMessage; +export type IImportRecordType = 'user' | 'channel' | 'message' | 'contact'; +export type IImportData = IImportUser | IImportChannel | IImportMessage | IImportContact; export interface IImportRecord { data: IImportData; @@ -34,3 +35,8 @@ export interface IImportMessageRecord extends IImportRecord { useQuickInsert?: boolean; }; } + +export interface IImportContactRecord extends IImportRecord { + data: IImportContact; + dataType: 'contact'; +} diff --git a/packages/core-typings/src/import/IImporterSelection.ts b/packages/core-typings/src/import/IImporterSelection.ts index 1c0279e4041b..197df70bd4a5 100644 --- a/packages/core-typings/src/import/IImporterSelection.ts +++ b/packages/core-typings/src/import/IImporterSelection.ts @@ -1,9 +1,11 @@ import type { IImporterSelectionChannel } from './IImporterSelectionChannel'; +import type { IImporterSelectionContactOrIdentifier } from './IImporterSelectionContact'; import type { IImporterSelectionUser } from './IImporterSelectionUser'; -export interface IImporterSelection { +export interface IImporterSelection { name: string; users: IImporterSelectionUser[]; channels: IImporterSelectionChannel[]; + contacts?: IImporterSelectionContactOrIdentifier[]; message_count: number; } diff --git a/packages/core-typings/src/import/IImporterSelectionContact.ts b/packages/core-typings/src/import/IImporterSelectionContact.ts new file mode 100644 index 000000000000..12d0eaf57b26 --- /dev/null +++ b/packages/core-typings/src/import/IImporterSelectionContact.ts @@ -0,0 +1,13 @@ +export interface IImporterSelectionContact { + id: string; + name: string; + emails: string[]; + phones: string[]; + do_import: boolean; +} + +export type IImporterSelectionContactIdentifier = Pick; + +export type IImporterSelectionContactOrIdentifier = WithData extends true + ? IImporterSelectionContact + : IImporterSelectionContactIdentifier; diff --git a/packages/core-typings/src/import/index.ts b/packages/core-typings/src/import/index.ts index 00df59ff93b6..582f2b1f513b 100644 --- a/packages/core-typings/src/import/index.ts +++ b/packages/core-typings/src/import/index.ts @@ -3,10 +3,12 @@ export * from './IImportUser'; export * from './IImportRecord'; export * from './IImportMessage'; export * from './IImportChannel'; +export * from './IImportContact'; export * from './IImporterInfo'; export * from './IImportFileData'; export * from './IImportProgress'; export * from './IImporterSelection'; export * from './IImporterSelectionUser'; export * from './IImporterSelectionChannel'; +export * from './IImporterSelectionContact'; export * from './ImportState'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9dc3eea5219d..adbded8c4601 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4053,6 +4053,7 @@ "Old Colors (minor)": "Old Colors (minor)", "Older_than": "Older than", "Omnichannel": "Omnichannel", + "omnichannel_contacts_importer": "Omnichannel Contacts (*.csv)", "Omnichannel_Description": "Set up Omnichannel to communicate with customers from one place, regardless of how they connect with you.", "Omnichannel_Directory": "Omnichannel Directory", "Omnichannel_appearance": "Omnichannel Appearance", diff --git a/packages/model-typings/src/models/IImportDataModel.ts b/packages/model-typings/src/models/IImportDataModel.ts index 160eaaf604a0..6d2bbde483a4 100644 --- a/packages/model-typings/src/models/IImportDataModel.ts +++ b/packages/model-typings/src/models/IImportDataModel.ts @@ -1,4 +1,10 @@ -import type { IImportRecord, IImportUserRecord, IImportMessageRecord, IImportChannelRecord } from '@rocket.chat/core-typings'; +import type { + IImportRecord, + IImportUserRecord, + IImportMessageRecord, + IImportContactRecord, + IImportChannelRecord, +} from '@rocket.chat/core-typings'; import type { FindCursor } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -9,6 +15,7 @@ export interface IImportDataModel extends IBaseModel { getAllChannels(): FindCursor; getAllUsersForSelection(): Promise>; getAllChannelsForSelection(): Promise>; + getAllContactsForSelection(): Promise; checkIfDirectMessagesExists(): Promise; countMessages(): Promise; findChannelImportIdByNameOrImportId(channelIdentifier: string): Promise; diff --git a/packages/rest-typings/src/v1/import/StartImportParamsPOST.ts b/packages/rest-typings/src/v1/import/StartImportParamsPOST.ts index 310ea8c1d61a..0400a327ea0c 100644 --- a/packages/rest-typings/src/v1/import/StartImportParamsPOST.ts +++ b/packages/rest-typings/src/v1/import/StartImportParamsPOST.ts @@ -1,3 +1,4 @@ +import type { IImporterSelectionContactIdentifier } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; const ajv = new Ajv({ @@ -24,6 +25,7 @@ export type StartImportParamsPOST = { is_private: boolean; is_direct: boolean; }[]; + contacts?: IImporterSelectionContactIdentifier[]; }; }; @@ -65,6 +67,17 @@ const StartImportParamsPostSchema = { required: ['channel_id', 'name', 'is_archived', 'do_import', 'is_private', 'is_direct'], }, }, + contacts: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + do_import: { type: 'boolean' }, + }, + required: ['id', 'do_import'], + }, + }, }, required: ['users', 'channels'], }, From 102572316fc498c4f9ea16ec1f8ffe17bbfc5f97 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 16 Oct 2024 10:19:23 -0300 Subject: [PATCH 012/173] feat(sci): on demand migration of Visitors to Contacts (#33594) * feat: SCI: on demand migration of Visitors to Contacts * create the contact only when the room is created * merge visitor data into contact --- .changeset/calm-worms-add.md | 6 + .../app/apps/server/converters/visitors.js | 2 - .../app/livechat/server/api/lib/livechat.ts | 12 +- .../app/livechat/server/api/lib/visitors.ts | 17 +- .../app/livechat/server/api/v1/contact.ts | 14 +- .../app/livechat/server/api/v1/visitor.ts | 3 +- .../app/livechat/server/lib/ContactMerger.ts | 282 ++++++++++++++++++ .../app/livechat/server/lib/Contacts.ts | 180 +++++++++-- apps/meteor/app/livechat/server/lib/Helper.ts | 15 +- .../app/livechat/server/lib/LivechatTyped.ts | 66 +--- .../ee/server/models/raw/LivechatRooms.ts | 11 + .../server/models/raw/LivechatContacts.ts | 84 +++++- .../meteor/server/models/raw/LivechatRooms.ts | 30 ++ .../tests/end-to-end/api/livechat/contacts.ts | 27 +- .../app/livechat/server/lib/Contacts.spec.ts | 127 +++++++- packages/core-typings/src/ILivechatContact.ts | 5 +- packages/core-typings/src/ILivechatVisitor.ts | 1 - packages/core-typings/src/IRoom.ts | 9 +- .../src/models/ILivechatContactsModel.ts | 15 +- .../src/models/ILivechatRoomsModel.ts | 6 + packages/rest-typings/src/v1/omnichannel.ts | 4 +- 21 files changed, 781 insertions(+), 135 deletions(-) create mode 100644 .changeset/calm-worms-add.md create mode 100644 apps/meteor/app/livechat/server/lib/ContactMerger.ts diff --git a/.changeset/calm-worms-add.md b/.changeset/calm-worms-add.md new file mode 100644 index 000000000000..f0c19329dd3e --- /dev/null +++ b/.changeset/calm-worms-add.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/meteor': minor +--- + +Added on-demand data migration for omnichannel visitors to be converted into the new contacts diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index 32864e3e900e..c8fb0b7c4a21 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -36,7 +36,6 @@ export class AppVisitorsConverter { visitorEmails: 'visitorEmails', livechatData: 'livechatData', status: 'status', - contactId: 'contactId', }; return transformMappedData(visitor, map); @@ -55,7 +54,6 @@ export class AppVisitorsConverter { phone: visitor.phone, livechatData: visitor.livechatData, status: visitor.status || 'online', - contactId: visitor.contactId, ...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }), ...(visitor.department && { department: visitor.department }), }; diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 01c4d9736c66..c3c22c327731 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -49,17 +49,7 @@ async function findDepartments( } export function findGuest(token: string): Promise { - return LivechatVisitors.getVisitorByToken(token, { - projection: { - name: 1, - username: 1, - token: 1, - visitorEmails: 1, - department: 1, - activity: 1, - contactId: 1, - }, - }); + return LivechatVisitors.getVisitorByToken(token); } export function findGuestWithoutActivity(token: string): Promise { diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index 0abed5197d78..391838804a38 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -4,6 +4,7 @@ import type { FindOptions } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; +import { isSingleContactEnabled, migrateVisitorToContactId, getContactIdByVisitorId } from '../../lib/Contacts'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { const visitor = await LivechatVisitors.findOneEnabledById(visitorId); @@ -12,7 +13,21 @@ export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id' } return { - visitor, + visitor: await addContactIdToVisitor(visitor), + }; +} + +export async function addContactIdToVisitor(visitor: ILivechatVisitor): Promise { + if (!isSingleContactEnabled()) { + return visitor; + } + + const contactId = await getContactIdByVisitorId(visitor._id); + + // If the visitor doesn't have a contactId yet, create a new contact for it using the same _id + return { + ...visitor, + contactId: contactId || (await migrateVisitorToContactId(visitor, undefined, true)) || undefined, }; } diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 5c68a475a952..44b8dd787c4d 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,4 +1,4 @@ -import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; import { isPOSTOmnichannelContactsProps, isPOSTUpdateOmnichannelContactsProps, @@ -12,7 +12,15 @@ import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; -import { getContactHistory, Contacts, createContact, updateContact, getContacts, isSingleContactEnabled } from '../../lib/Contacts'; +import { + getContactHistory, + Contacts, + createContact, + getContact, + updateContact, + getContacts, + isSingleContactEnabled, +} from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', @@ -136,7 +144,7 @@ API.v1.addRoute( if (!isSingleContactEnabled()) { return API.v1.unauthorized(); } - const contact = await LivechatContacts.findOneById(this.queryParams.contactId); + const contact = await getContact(this.queryParams.contactId); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index ed32f0e2d279..2e48273faae5 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -8,6 +8,7 @@ import { API } from '../../../../api/server'; import { settings } from '../../../../settings/server'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; import { findGuest, normalizeHttpHeaderData } from '../lib/livechat'; +import { addContactIdToVisitor } from '../lib/visitors'; API.v1.addRoute( 'livechat/visitor', @@ -144,7 +145,7 @@ API.v1.addRoute('livechat/visitor/:token', { throw new Meteor.Error('invalid-token'); } - return API.v1.success({ visitor }); + return API.v1.success({ visitor: await addContactIdToVisitor(visitor) }); }, async delete() { check(this.urlParams, { diff --git a/apps/meteor/app/livechat/server/lib/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/ContactMerger.ts new file mode 100644 index 000000000000..923d4d985ac6 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/ContactMerger.ts @@ -0,0 +1,282 @@ +import type { + ILivechatContact, + ILivechatVisitor, + ILivechatContactChannel, + ILivechatContactConflictingField, + IUser, + DeepWritable, + IOmnichannelSource, +} from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import type { UpdateFilter } from 'mongodb'; + +import { getContactManagerIdByUsername } from './Contacts'; + +type ManagerValue = { id: string } | { username: string }; +type ContactFields = { + email: string; + phone: string; + name: string; + username: string; + manager: ManagerValue; + channel: ILivechatContactChannel; +}; + +type CustomFieldAndValue = { type: `customFields.${string}`; value: string }; + +type FieldAndValue = + | { type: keyof Omit; value: string } + | { type: 'manager'; value: ManagerValue } + | { type: 'channel'; value: ILivechatContactChannel } + | CustomFieldAndValue; + +export class ContactMerger { + private managerList = new Map(); + + private getManagerId(manager: ManagerValue): IUser['_id'] | undefined { + if ('id' in manager) { + return manager.id; + } + + return this.managerList.get(manager.username); + } + + private isSameManager(manager1: ManagerValue, manager2: ManagerValue): boolean { + if ('id' in manager1 && 'id' in manager2) { + return manager1.id === manager2.id; + } + if ('username' in manager1 && 'username' in manager2) { + return manager1.username === manager2.username; + } + + const id1 = this.getManagerId(manager1); + const id2 = this.getManagerId(manager2); + + if (!id1 || !id2) { + return false; + } + + return id1 === id2; + } + + private isSameChannel(channel1: ILivechatContactChannel, channel2: ILivechatContactChannel): boolean { + return channel1.visitorId === channel2.visitorId; + } + + private isSameField(field1: FieldAndValue, field2: FieldAndValue): boolean { + if (field1.type === 'manager' && field2.type === 'manager') { + return this.isSameManager(field1.value, field2.value); + } + + if (field1.type === 'channel' && field2.type === 'channel') { + return this.isSameChannel(field1.value, field2.value); + } + + if (field1.type !== field2.type) { + return false; + } + + if (field1.value === field2.value) { + return true; + } + + return false; + } + + private async loadDataForFields(...fieldLists: FieldAndValue[][]): Promise { + for await (const fieldList of fieldLists) { + for await (const field of fieldList) { + if (field.type !== 'manager' || 'id' in field.value) { + continue; + } + + if (this.managerList.has(field.value.username)) { + continue; + } + + const id = await getContactManagerIdByUsername(field.value.username); + this.managerList.set(field.value.username, id); + } + } + } + + static async getAllFieldsFromContact(contact: ILivechatContact): Promise { + const { customFields = {}, name, contactManager } = contact; + + const fields = new Set(); + + contact.emails?.forEach(({ address: value }) => fields.add({ type: 'email', value })); + contact.phones?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value })); + contact.channels?.forEach((value) => fields.add({ type: 'channel', value })); + + if (name) { + fields.add({ type: 'name', value: name }); + } + + if (contactManager) { + fields.add({ type: 'manager', value: { id: contactManager } }); + } + + Object.keys(customFields).forEach((key) => ({ type: `customFields.${key}`, value: customFields[key] })); + + // If the contact already has conflicts, load their values as well + if (contact.conflictingFields) { + for (const conflict of contact.conflictingFields) { + fields.add({ type: conflict.field, value: conflict.value } as FieldAndValue); + } + } + + return [...fields]; + } + + static async getAllFieldsFromVisitor(visitor: ILivechatVisitor, source?: IOmnichannelSource): Promise { + const { livechatData: customFields = {}, contactManager, name, username } = visitor; + + const fields = new Set(); + + visitor.visitorEmails?.forEach(({ address: value }) => fields.add({ type: 'email', value })); + visitor.phone?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value })); + if (name) { + fields.add({ type: 'name', value: name }); + } + if (username) { + fields.add({ type: 'username', value: username }); + } + if (contactManager?.username) { + fields.add({ type: 'manager', value: { username: contactManager?.username } }); + } + Object.keys(customFields).forEach((key) => ({ type: `customFields.${key}`, value: customFields[key] })); + + if (source) { + fields.add({ + type: 'channel', + value: { + name: source.label || source.type.toString(), + visitorId: visitor._id, + blocked: false, + verified: false, + details: source, + }, + }); + } + + return [...fields]; + } + + static getFieldValuesByType(fields: FieldAndValue[], type: T): ContactFields[T][] { + return fields.filter((field) => field.type === type).map(({ value }) => value) as ContactFields[T][]; + } + + static async mergeFieldsIntoContact(fields: FieldAndValue[], contact: ILivechatContact): Promise { + const existingFields = await ContactMerger.getAllFieldsFromContact(contact); + + const merger = new ContactMerger(); + await merger.loadDataForFields(fields, existingFields); + + const newFields = fields.filter((field) => { + // If the field already exists with the same value, ignore it + if (existingFields.some((existingField) => merger.isSameField(existingField, field))) { + return false; + } + + // If the field is an username and the contact already has a name, ignore it as well + if (field.type === 'username' && existingFields.some(({ type }) => type === 'name')) { + return false; + } + + return true; + }); + + const newPhones = ContactMerger.getFieldValuesByType(newFields, 'phone'); + const newEmails = ContactMerger.getFieldValuesByType(newFields, 'email'); + const newChannels = ContactMerger.getFieldValuesByType(newFields, 'channel'); + const newNamesOnly = ContactMerger.getFieldValuesByType(newFields, 'name'); + const newCustomFields = newFields.filter(({ type }) => type.startsWith('customFields.')) as CustomFieldAndValue[]; + // Usernames are ignored unless the contact has no other name + const newUsernames = !contact.name && !newNamesOnly.length ? ContactMerger.getFieldValuesByType(newFields, 'username') : []; + + const dataToSet: DeepWritable['$set']> = {}; + + // Names, Managers and Custom Fields need are set as conflicting fields if the contact already has them + const newNames = [...newNamesOnly, ...newUsernames]; + const newManagers = ContactMerger.getFieldValuesByType(newFields, 'manager') + .map((manager) => { + if ('id' in manager) { + return manager.id; + } + return merger.getManagerId(manager); + }) + .filter((id) => Boolean(id)); + + if (newNames.length && !contact.name) { + const firstName = newNames.shift(); + if (firstName) { + dataToSet.name = firstName; + } + } + + if (newManagers.length && !contact.contactManager) { + const firstManager = newManagers.shift(); + if (firstManager) { + dataToSet.contactManager = firstManager; + } + } + + const customFieldsPerName = new Map(); + for (const customField of newCustomFields) { + if (!customFieldsPerName.has(customField.type)) { + customFieldsPerName.set(customField.type, []); + } + customFieldsPerName.get(customField.type)?.push(customField); + } + + const customFieldConflicts: CustomFieldAndValue[] = []; + + for (const [key, customFields] of customFieldsPerName) { + const fieldName = key.replace('customFields.', ''); + + // If the contact does not have this custom field yet, save the first value directly to the contact instead of as a conflict + if (!contact.customFields?.[fieldName]) { + const first = customFields.shift(); + if (first) { + dataToSet[key] = first.value; + } + } + + customFieldConflicts.push(...customFields); + } + + const allConflicts: ILivechatContactConflictingField[] = [ + ...newNames.map((name): ILivechatContactConflictingField => ({ field: 'name', value: name })), + ...newManagers.map((manager): ILivechatContactConflictingField => ({ field: 'manager', value: manager as string })), + ...customFieldConflicts.map(({ type, value }): ILivechatContactConflictingField => ({ field: type, value })), + ]; + + // Phones, Emails and Channels are simply added to the contact's existing list + const dataToAdd: UpdateFilter['$addToSet'] = { + ...(newPhones.length ? { phones: newPhones.map((phoneNumber) => ({ phoneNumber })) } : {}), + ...(newEmails.length ? { emails: newEmails.map((address) => ({ address })) } : {}), + ...(newChannels.length ? { channels: newChannels } : {}), + ...(allConflicts.length ? { conflictingFields: allConflicts } : {}), + }; + + const updateData: UpdateFilter = { + ...(Object.keys(dataToSet).length ? { $set: dataToSet } : {}), + ...(Object.keys(dataToAdd).length ? { $addToSet: dataToAdd } : {}), + }; + + if (Object.keys(updateData).length) { + await LivechatContacts.updateOne({ _id: contact._id }, updateData); + } + } + + public static async mergeVisitorIntoContact(visitor: ILivechatVisitor, contact: ILivechatContact): Promise { + const fields = await ContactMerger.getAllFieldsFromVisitor(visitor); + await ContactMerger.mergeFieldsIntoContact(fields, contact); + } + + public static async mergeContacts(source: ILivechatContact, target: ILivechatContact): Promise { + const fields = await ContactMerger.getAllFieldsFromContact(source); + await ContactMerger.mergeFieldsIntoContact(fields, target); + } +} diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index dd7fb0b99848..9e7570b5a4fc 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,4 +1,5 @@ import type { + IOmnichannelSource, AtLeast, ILivechatContact, ILivechatContactChannel, @@ -7,7 +8,6 @@ import type { IOmnichannelRoom, IUser, } from '@rocket.chat/core-typings'; -import type { InsertionModel } from '@rocket.chat/model-typings'; import { LivechatVisitors, Users, @@ -31,6 +31,8 @@ import { notifyOnLivechatInquiryChangedByRoom, } from '../../../lib/server/lib/notifyListener'; import { i18n } from '../../../utils/lib/i18n'; +import { ContactMerger } from './ContactMerger'; +import { Livechat } from './LivechatTyped'; type RegisterContactProps = { _id?: string; @@ -187,41 +189,166 @@ export const Contacts = { }, }; +export async function getContactManagerIdByUsername(username?: IUser['username']): Promise { + if (!username) { + return; + } + + const user = await Users.findOneByUsername(username, { projection: { _id: 1 } }); + + return user?._id; +} + +export async function getContactIdByVisitorId(visitorId: ILivechatVisitor['_id']): Promise { + const contact = await LivechatContacts.findOneByVisitorId>(visitorId, { projection: { _id: 1 } }); + if (!contact) { + return null; + } + return contact._id; +} + +export async function migrateVisitorIfMissingContact( + visitorId: ILivechatVisitor['_id'], + source: IOmnichannelSource, +): Promise { + if (!isSingleContactEnabled()) { + return null; + } + + Livechat.logger.debug(`Detecting visitor's contact ID`); + // Check if there is any contact already linking to this visitorId + const contactId = await getContactIdByVisitorId(visitorId); + if (contactId) { + return contactId; + } + + const visitor = await LivechatVisitors.findOneById(visitorId); + if (!visitor) { + throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); + } + + return migrateVisitorToContactId(visitor, source); +} + +export async function findContactMatchingVisitor( + visitor: AtLeast, +): Promise { + // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. + return LivechatContacts.findContactMatchingVisitor(visitor); +} + +async function getVisitorNewestSource(visitor: ILivechatVisitor): Promise { + const room = await LivechatRooms.findNewestByVisitorIdOrToken>(visitor._id, visitor.token, { + projection: { source: 1 }, + }); + + if (!room) { + return null; + } + + return room.source; +} + +/** + This function assumes you already ensured that the visitor is not yet linked to any contact +**/ +export async function migrateVisitorToContactId( + visitor: ILivechatVisitor, + source?: IOmnichannelSource, + useVisitorId = false, +): Promise { + // If we haven't received any source and the visitor doesn't have any room yet, then there's no need to migrate it + const visitorSource = source || (await getVisitorNewestSource(visitor)); + if (!visitorSource) { + return null; + } + + const existingContact = await findContactMatchingVisitor(visitor); + if (!existingContact) { + Livechat.logger.debug(`Creating a new contact for existing visitor ${visitor._id}`); + return createContactFromVisitor(visitor, visitorSource, useVisitorId); + } + + // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it + Livechat.logger.debug(`Adding channel to existing contact ${existingContact._id}`); + await ContactMerger.mergeVisitorIntoContact(visitor, existingContact); + + // Update all existing rooms of that visitor to add the contactId to them + await LivechatRooms.setContactIdByVisitorIdOrToken(existingContact._id, visitor._id, visitor.token); + + return existingContact._id; +} + +export async function getContact(contactId: ILivechatContact['_id']): Promise { + const contact = await LivechatContacts.findOneById(contactId); + if (contact) { + return contact; + } + + // If the contact was not found, search for a visitor with the same ID + const visitor = await LivechatVisitors.findOneById(contactId); + // If there's also no visitor with that ID, then there's nothing for us to get + if (!visitor) { + return null; + } + + // ContactId is actually the ID of a visitor, so let's get the contact that is linked to this visitor + const linkedContact = await LivechatContacts.findOneByVisitorId(contactId); + if (linkedContact) { + return linkedContact; + } + + // If this is the ID of a visitor and there is no contact linking to it yet, then migrate it into a contact + const newContactId = await migrateVisitorToContactId(visitor, undefined, true); + // If no contact was created by the migration, this visitor doesn't need a contact yet, so let's return null + if (!newContactId) { + return null; + } + + // Finally, let's return the data of the migrated contact + return LivechatContacts.findOneById(newContactId); +} + export function isSingleContactEnabled(): boolean { // The Single Contact feature is not yet available in production, but can already be partially used in test environments. return process.env.TEST_MODE?.toUpperCase() === 'TRUE'; } -export async function createContactFromVisitor(visitor: ILivechatVisitor): Promise { - if (visitor.contactId) { - throw new Error('error-contact-already-exists'); - } - - const contactData: InsertionModel = { +export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { + return { name: visitor.name || visitor.username, - emails: visitor.visitorEmails, - phones: visitor.phone || undefined, + emails: visitor.visitorEmails?.map(({ address }) => address), + phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), unknown: true, - channels: [], + channels: [ + { + name: source.label || source.type.toString(), + visitorId: visitor._id, + blocked: false, + verified: false, + details: source, + }, + ], customFields: visitor.livechatData, - createdAt: new Date(), + contactManager: await getContactManagerIdByUsername(visitor.contactManager?.username), }; +} - if (visitor.contactManager) { - const contactManagerId = await Users.findOneByUsername>(visitor.contactManager.username, { projection: { _id: 1 } }); - if (contactManagerId) { - contactData.contactManager = contactManagerId._id; - } - } +export async function createContactFromVisitor( + visitor: ILivechatVisitor, + source: IOmnichannelSource, + useVisitorId = false, +): Promise { + const contactData = await mapVisitorToContact(visitor, source); - const { insertedId: contactId } = await LivechatContacts.insertOne(contactData); + const contactId = await createContact(contactData, useVisitorId ? visitor._id : undefined); - await LivechatVisitors.updateOne({ _id: visitor._id }, { $set: { contactId } }); + await LivechatRooms.setContactIdByVisitorIdOrToken(contactId, visitor._id, visitor.token); return contactId; } -export async function createContact(params: CreateContactParams): Promise { +export async function createContact(params: CreateContactParams, upsertId?: ILivechatContact['_id']): Promise { const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown } = params; if (contactManager) { @@ -231,7 +358,7 @@ export async function createContact(params: CreateContactParams): Promise ({ address })), phones: phones?.map((phoneNumber) => ({ phoneNumber })), @@ -239,10 +366,15 @@ export async function createContact(params: CreateContactParams): Promise { diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 1416ed8b028f..9c5ba8ec0236 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -13,6 +13,7 @@ import type { TransferByData, ILivechatAgent, ILivechatDepartment, + IOmnichannelSource, } from '@rocket.chat/core-typings'; import { LivechatInquiryStatus, OmnichannelSourceType, DEFAULT_SLA_CONFIG, UserStatus } from '@rocket.chat/core-typings'; import { LivechatPriorityWeight } from '@rocket.chat/core-typings/src/ILivechatPriority'; @@ -44,6 +45,7 @@ import { notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; +import { isSingleContactEnabled, migrateVisitorIfMissingContact } from './Contacts'; import { Livechat as LivechatTyped } from './LivechatTyped'; import { queueInquiry, saveQueueInquiry } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -85,7 +87,7 @@ export const createLivechatRoom = async < ); const extraRoomInfo = await callbacks.run('livechat.beforeRoom', roomInfo, extraData); - const { _id, username, token, department: departmentId, status = 'online', contactId } = guest; + const { _id, username, token, department: departmentId, status = 'online' } = guest; const newRoomAt = new Date(); const { activity } = guest; @@ -94,6 +96,17 @@ export const createLivechatRoom = async < visitor: { _id, username, departmentId, status, activity }, }); + const contactId = await (async () => { + if (!isSingleContactEnabled()) { + return undefined; + } + + return migrateVisitorIfMissingContact( + _id, + (extraRoomInfo.source || roomInfo.source || { type: OmnichannelSourceType.OTHER }) as IOmnichannelSource, + ); + })(); + // TODO: Solve `u` missing issue const room: InsertionModel = { _id: rid, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index e521ac98fe71..b43edaae1f15 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -21,10 +21,9 @@ import type { ILivechatDepartmentAgents, LivechatDepartmentDTO, ILivechatInquiryRecord, - ILivechatContact, - ILivechatContactChannel, + OmnichannelSourceType, } from '@rocket.chat/core-typings'; -import { OmnichannelSourceType, ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; import { LivechatDepartment, @@ -38,7 +37,6 @@ import { ReadReceipts, Rooms, LivechatCustomField, - LivechatContacts, } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; @@ -73,7 +71,6 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { createContact, createContactFromVisitor, isSingleContactEnabled } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -461,55 +458,6 @@ class LivechatClass { extraData, }); - if (isSingleContactEnabled()) { - let { contactId } = visitor; - - if (!contactId) { - const visitorContact = await LivechatVisitors.findOne< - Pick - >(visitor._id, { - projection: { - name: 1, - contactManager: 1, - livechatData: 1, - phone: 1, - visitorEmails: 1, - username: 1, - contactId: 1, - }, - }); - - contactId = visitorContact?.contactId; - } - - if (!contactId) { - // ensure that old visitors have a contact - contactId = await createContactFromVisitor(visitor); - } - - const contact = await LivechatContacts.findOneById>(contactId, { - projection: { _id: 1, channels: 1 }, - }); - - if (contact) { - const channel = contact.channels?.find( - (channel: ILivechatContactChannel) => channel.name === roomInfo.source?.type && channel.visitorId === visitor._id, - ); - - if (!channel) { - Livechat.logger.debug(`Adding channel for contact ${contact._id}`); - - await LivechatContacts.addChannel(contact._id, { - name: roomInfo.source?.label || roomInfo.source?.type.toString() || OmnichannelSourceType.OTHER, - visitorId: visitor._id, - blocked: false, - verified: false, - details: roomInfo.source, - }); - } - } - } - Livechat.logger.debug(`Room obtained for visitor ${visitor._id} -> ${room._id}`); await Messages.setRoomIdByToken(visitor.token, room._id); @@ -720,16 +668,6 @@ class LivechatClass { } } - if (isSingleContactEnabled()) { - const contactId = await createContact({ - name: name ?? (visitorDataToUpdate.username as string), - emails: email ? [email] : [], - phones: phone ? [phone.number] : [], - unknown: true, - }); - visitorDataToUpdate.contactId = contactId; - } - const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { upsert: true, returnDocument: 'after', diff --git a/apps/meteor/ee/server/models/raw/LivechatRooms.ts b/apps/meteor/ee/server/models/raw/LivechatRooms.ts index 5b89704a522c..2e4c8606d301 100644 --- a/apps/meteor/ee/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/ee/server/models/raw/LivechatRooms.ts @@ -726,4 +726,15 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo ...extraQuery, }); } + + async setContactIdByVisitorIdOrToken(contactId: string, visitorId: string, visitorToken: string): Promise { + return this.updateMany( + { + 't': 'l', + '$or': [{ 'v._id': visitorId }, { 'v.token': visitorToken }], + 'v.contactId': { $exists: false }, + }, + { $set: { 'v.contactId': contactId } }, + ); + } } diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 5e0f9d6703ac..a19ed4c22d03 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -1,7 +1,23 @@ -import type { ILivechatContact, ILivechatContactChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; -import type { FindPaginated, ILivechatContactsModel } from '@rocket.chat/model-typings'; +import type { + AtLeast, + ILivechatContact, + ILivechatContactChannel, + ILivechatVisitor, + RocketChatRecordDeleted, +} from '@rocket.chat/core-typings'; +import type { FindPaginated, ILivechatContactsModel, InsertionModel } from '@rocket.chat/model-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import type { Collection, Db, RootFilterOperators, Filter, FindOptions, FindCursor, IndexDescription, UpdateResult } from 'mongodb'; +import type { + Document, + Collection, + Db, + RootFilterOperators, + Filter, + FindOptions, + FindCursor, + IndexDescription, + UpdateResult, +} from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -33,6 +49,26 @@ export class LivechatContactsRaw extends BaseRaw implements IL ]; } + async insertContact( + data: InsertionModel> & { createdAt?: ILivechatContact['createdAt'] }, + ): Promise { + const result = await this.insertOne({ + createdAt: new Date(), + ...data, + }); + + return result.insertedId; + } + + async upsertContact(contactId: string, data: Partial): Promise { + const result = await this.findOneAndUpdate( + { _id: contactId }, + { $set: data, $setOnInsert: { createdAt: new Date() } }, + { upsert: true }, + ); + return result.value; + } + async updateContact(contactId: string, data: Partial): Promise { const updatedValue = await this.findOneAndUpdate( { _id: contactId }, @@ -61,6 +97,48 @@ export class LivechatContactsRaw extends BaseRaw implements IL ); } + async findContactMatchingVisitor(visitor: AtLeast): Promise { + const emails = visitor.visitorEmails?.map(({ address }) => address).filter((email) => Boolean(email)) || []; + const phoneNumbers = visitor.phone?.map(({ phoneNumber }) => phoneNumber).filter((phone) => Boolean(phone)) || []; + + if (!emails?.length && !phoneNumbers?.length) { + return null; + } + + const query = { + $and: [ + { + $or: [ + ...emails?.map((email) => ({ 'emails.address': email })), + ...phoneNumbers?.map((phone) => ({ 'phones.phoneNumber': phone })), + ], + }, + { + $or: [ + { + channels: { $exists: false }, + }, + { + channels: { $size: 0 }, + }, + ], + }, + ], + }; + + return this.findOne(query); + } + + async findOneByVisitorId( + visitorId: ILivechatVisitor['_id'], + options: FindOptions = {}, + ): Promise { + const query = { + 'channels.visitorId': visitorId, + }; + return this.findOne(query, options); + } + async addChannel(contactId: string, channel: ILivechatContactChannel): Promise { await this.updateOne({ _id: contactId }, { $push: { channels: channel } }); } diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index 7c63eec6e075..f9dcf4340f3c 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -1992,6 +1992,32 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive return this.find(query, options); } + async findNewestByVisitorIdOrToken( + visitorId: string, + visitorToken: string, + options: Omit, 'sort' | 'limit'> = {}, + ): Promise { + const query: Filter = { + t: 'l', + $or: [ + { + 'v._id': visitorId, + }, + { + 'v.token': visitorToken, + }, + ], + }; + + const cursor = this.find(query, { + ...options, + sort: { _updatedAt: -1 }, + limit: 1, + }); + + return (await cursor.toArray()).pop() || null; + } + findOneOpenByRoomIdAndVisitorToken(roomId: string, visitorToken: string, options: FindOptions = {}) { const query: Filter = { 't': 'l', @@ -2723,4 +2749,8 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive getTotalConversationsWithoutDepartmentBetweenDates(_start: Date, _end: Date, _extraQuery: Filter): Promise { throw new Error('Method not implemented.'); } + + setContactIdByVisitorIdOrToken(_contactId: string, _visitorId: string, _visitorToken: string): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index 4c6222ffe74e..dc0a29724040 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -667,7 +667,7 @@ describe('LIVECHAT - contacts', () => { }); it('should return the last chat', async () => { - const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.v.contactId }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); @@ -793,9 +793,9 @@ describe('LIVECHAT - contacts', () => { }); it('should add a channel to a contact when creating a new room', async () => { - await request.get(api('livechat/room')).query({ token: visitor.token }); + const room = await createLivechatRoom(visitor.token); - const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.v.contactId }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); @@ -808,19 +808,19 @@ describe('LIVECHAT - contacts', () => { }); it('should not add a channel if visitor already has one with same type', async () => { - const roomResult = await request.get(api('livechat/room')).query({ token: visitor.token }); + const room = await createLivechatRoom(visitor.token); - const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.v.contactId }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); expect(res.body.contact.channels).to.be.an('array'); expect(res.body.contact.channels.length).to.be.equal(1); - await closeOmnichannelRoom(roomResult.body.room._id); + await closeOmnichannelRoom(room._id); await request.get(api('livechat/room')).query({ token: visitor.token }); - const secondResponse = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + const secondResponse = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.v.contactId }); expect(secondResponse.status).to.be.equal(200); expect(secondResponse.body).to.have.property('success', true); @@ -847,7 +847,7 @@ describe('LIVECHAT - contacts', () => { }); it('should be able to list a contact history', async () => { - const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: visitor.contactId }); + const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: room1.v.contactId }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); @@ -873,7 +873,7 @@ describe('LIVECHAT - contacts', () => { const res = await request .get(api(`omnichannel/contacts.history`)) .set(credentials) - .query({ contactId: visitor.contactId, source: 'api' }); + .query({ contactId: room1.v.contactId, source: 'api' }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); @@ -885,9 +885,10 @@ describe('LIVECHAT - contacts', () => { expect(res.body.history[0].source).to.have.property('type', 'api'); }); - it('should return an empty list if contact does not have history', async () => { - const emptyVisitor = await createVisitor(); - const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: emptyVisitor.contactId }); + it.skip('should return an empty list if contact does not have history', async () => { + // #TODO: Create a Contact + // const emptyVisitor = await createVisitor(); + const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: 'contactId' }); expect(res.status).to.be.equal(200); expect(res.body).to.have.property('success', true); @@ -895,7 +896,7 @@ describe('LIVECHAT - contacts', () => { expect(res.body.history.length).to.be.equal(0); expect(res.body.total).to.be.equal(0); - await deleteVisitor(emptyVisitor.token); + // await deleteVisitor(emptyVisitor.token); }); it('should return an error if contacts not exists', async () => { diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts index fef3c59469f8..42b6bfc9b02c 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts @@ -1,3 +1,4 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; @@ -5,18 +6,42 @@ import sinon from 'sinon'; const modelsMock = { Users: { findOneAgentById: sinon.stub(), + findOneByUsername: sinon.stub(), }, LivechatContacts: { findOneById: sinon.stub(), + insertOne: sinon.stub(), + upsertContact: sinon.stub(), updateContact: sinon.stub(), + findContactMatchingVisitor: sinon.stub(), + findOneByVisitorId: sinon.stub(), + }, + LivechatRooms: { + findNewestByVisitorIdOrToken: sinon.stub(), + setContactIdByVisitorIdOrToken: sinon.stub(), + }, + LivechatVisitors: { + findOneById: sinon.stub(), + updateById: sinon.stub(), + updateOne: sinon.stub(), + }, + LivechatCustomField: { + findByScope: sinon.stub(), }, }; -const { validateCustomFields, validateContactManager, updateContact } = proxyquire +const { validateCustomFields, validateContactManager, updateContact, getContact } = proxyquire .noCallThru() .load('../../../../../../app/livechat/server/lib/Contacts', { 'meteor/check': sinon.stub(), 'meteor/meteor': sinon.stub(), '@rocket.chat/models': modelsMock, + './LivechatTyped': { + Livechat: { + logger: { + debug: sinon.stub(), + }, + }, + }, }); describe('[OC] Contacts', () => { @@ -99,4 +124,104 @@ describe('[OC] Contacts', () => { expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); }); }); + + describe('getContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.upsertContact.reset(); + modelsMock.LivechatContacts.insertOne.reset(); + modelsMock.LivechatVisitors.findOneById.reset(); + modelsMock.LivechatVisitors.updateById.reset(); + modelsMock.Users.findOneByUsername.reset(); + }); + + describe('contact not found', () => { + it('should search for visitor when the contact is not found', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + modelsMock.LivechatVisitors.findOneById.resolves(undefined); + expect(await getContact('any_id')).to.be.null; + + expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatVisitors.updateById.getCall(0)).to.be.null; + expect(modelsMock.LivechatVisitors.findOneById.getCall(0).args[0]).to.be.equal('any_id'); + }); + + it('should create a contact if there is a visitor with that id', async () => { + let createdContact: ILivechatContact | null = null; + modelsMock.LivechatContacts.findOneById.callsFake(() => createdContact); + modelsMock.Users.findOneByUsername.resolves({ _id: 'manager_id' }); + modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); + modelsMock.LivechatVisitors.findOneById.resolves({ + _id: 'any_id', + contactManager: { username: 'username' }, + name: 'VisitorName', + username: 'VisitorUsername', + visitorEmails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], + phone: [{ phoneNumber: '1' }, { phoneNumber: '2' }], + }); + + modelsMock.LivechatContacts.upsertContact.callsFake((contactId, data) => { + createdContact = { + ...data, + _id: contactId, + }; + }); + modelsMock.LivechatContacts.insertOne.callsFake((data) => { + createdContact = { + ...data, + _id: 'random_id', + }; + return { + insertedId: 'random_id', + }; + }); + modelsMock.LivechatRooms.findNewestByVisitorIdOrToken.resolves({ + _id: 'room_id', + visitorId: 'any_id', + source: { + type: 'widget', + }, + }); + + expect(await getContact('any_id')).to.be.deep.equal({ + _id: 'any_id', + name: 'VisitorName', + emails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], + phones: [{ phoneNumber: '1' }, { phoneNumber: '2' }], + contactManager: 'manager_id', + unknown: true, + channels: [ + { + name: 'widget', + visitorId: 'any_id', + blocked: false, + verified: false, + details: { type: 'widget' }, + }, + ], + customFields: {}, + }); + + expect(modelsMock.LivechatContacts.findOneById.getCall(0).args[0]).to.be.equal('any_id'); + expect(modelsMock.LivechatContacts.findOneById.getCall(0).returnValue).to.be.equal(null); + expect(modelsMock.LivechatContacts.findOneById.getCall(1).args[0]).to.be.equal('any_id'); + expect(modelsMock.LivechatContacts.findOneById.getCall(1).returnValue).to.be.equal(createdContact); + + expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; + expect(modelsMock.Users.findOneByUsername.getCall(0).args[0]).to.be.equal('username'); + }); + }); + + describe('contact found', () => { + it('should not search for visitor data.', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ _id: 'any_id' }); + + expect(await getContact('any_id')).to.be.deep.equal({ _id: 'any_id' }); + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; + }); + }); + }); }); diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts index 466eeb23d039..8a490e55bc8b 100644 --- a/packages/core-typings/src/ILivechatContact.ts +++ b/packages/core-typings/src/ILivechatContact.ts @@ -14,9 +14,8 @@ export interface ILivechatContactChannel { } export interface ILivechatContactConflictingField { - field: string; - oldValue: string; - newValue: string; + field: 'name' | 'manager' | `customFields.${string}`; + value: string; } export interface ILivechatContact extends IRocketChatRecord { diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index eefb4ebd720c..21819cc23f24 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -49,7 +49,6 @@ export interface ILivechatVisitor extends IRocketChatRecord { }; activity?: string[]; disabled?: boolean; - contactId?: string; } export interface ILivechatVisitorDTO { diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 16cfa0142d9a..eae9854e4ed2 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -197,9 +197,10 @@ export interface IOmnichannelSourceFromApp extends IOmnichannelSource { export interface IOmnichannelGenericRoom extends Omit { t: 'l' | 'v'; - v: Pick & { + v: Pick & { lastMessageTs?: Date; phone?: string; + contactId?: string; }; email?: { // Data used when the room is created from an email, via email Integration. @@ -336,7 +337,11 @@ export interface IVoipRoom extends IOmnichannelGenericRoom { queue: string; // The ID assigned to the call (opaque ID) callUniqueId?: string; - v: Pick & { lastMessageTs?: Date; phone?: string }; + v: Pick & { + lastMessageTs?: Date; + phone?: string; + contactId?: string; + }; // Outbound means the call was initiated from Rocket.Chat and vise versa direction: 'inbound' | 'outbound'; } diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index b57cc0cde49f..958eea028d58 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -1,11 +1,20 @@ -import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; -import type { FindCursor, FindOptions, UpdateResult } from 'mongodb'; +import type { AtLeast, ILivechatContact, ILivechatContactChannel, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; -import type { FindPaginated, IBaseModel } from './IBaseModel'; +import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; export interface ILivechatContactsModel extends IBaseModel { + insertContact( + data: InsertionModel> & { createdAt?: ILivechatContact['createdAt'] }, + ): Promise; + upsertContact(contactId: string, data: Partial): Promise; updateContact(contactId: string, data: Partial): Promise; addChannel(contactId: string, channel: ILivechatContactChannel): Promise; findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated>; updateLastChatById(contactId: string, lastChat: ILivechatContact['lastChat']): Promise; + findContactMatchingVisitor(visitor: AtLeast): Promise; + findOneByVisitorId( + visitorId: ILivechatVisitor['_id'], + options?: FindOptions, + ): Promise; } diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 6ffe8e57bd25..e93967d16991 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -260,6 +260,12 @@ export interface ILivechatRoomsModel extends IBaseModel { getVisitorActiveForPeriodUpdateQuery(period: string, updater?: Updater): Updater; getMACStatisticsForPeriod(period: string): Promise; getMACStatisticsBetweenDates(start: Date, end: Date): Promise; + findNewestByVisitorIdOrToken( + visitorId: string, + visitorToken: string, + options?: Omit, 'sort' | 'limit'>, + ): Promise; + setContactIdByVisitorIdOrToken(contactId: string, visitorId: string, visitorToken: string): Promise; findPaginatedRoomsByVisitorsIdsAndSource(params: { visitorsIds: string[]; source?: string; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index d28e11ef3e97..879ce5155f80 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -3554,7 +3554,7 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/visitors.info': { GET: (params: LivechatVisitorsInfo) => { - visitor: ILivechatVisitor; + visitor: ILivechatVisitor & { contactId?: string }; }; }; '/v1/livechat/room.onHold': { @@ -3734,7 +3734,7 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/visitor/:token': { - GET: (params?: LivechatVisitorTokenGet) => { visitor: ILivechatVisitor }; + GET: (params?: LivechatVisitorTokenGet) => { visitor: ILivechatVisitor & { contactId?: string } }; DELETE: (params: LivechatVisitorTokenDelete) => { visitor: { _id: string; ts: string }; }; From febf7d279d73efaa7c05dcd835a9b4aa646d0f0b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:25:05 -0300 Subject: [PATCH 013/173] Merge branch 'develop' into feat/single-contact-id --- .changeset/two-geckos-train.md | 6 ++ apps/meteor/client/apps/orchestrator.ts | 23 ++++-- apps/meteor/client/contexts/AppsContext.tsx | 11 +-- .../providers/AppsProvider/AppsProvider.tsx | 70 ++++++++++++------- .../views/marketplace/AppsPage/AppsPage.tsx | 15 +--- .../marketplace/AppsPage/AppsPageContent.tsx | 23 ++++-- .../AppsPage/AppsPageContentBody.tsx | 7 +- .../AppsPage/UnsupportedEmptyState.spec.tsx | 33 +++++++++ .../UnsupportedEmptyState.stories.tsx | 16 +++++ .../AppsPage/UnsupportedEmptyState.tsx | 33 +++++++++ .../components/MarketplaceHeader.tsx | 13 +++- .../components/UpdateRocketChatButton.tsx | 15 ++++ .../marketplace/hooks/useFilteredApps.ts | 10 ++- .../ee/server/apps/communication/rest.ts | 6 ++ .../meteor/tests/mocks/client/marketplace.tsx | 2 +- packages/i18n/src/locales/en.i18n.json | 4 ++ 16 files changed, 227 insertions(+), 60 deletions(-) create mode 100644 .changeset/two-geckos-train.md create mode 100644 apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx create mode 100644 apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx create mode 100644 apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx diff --git a/.changeset/two-geckos-train.md b/.changeset/two-geckos-train.md new file mode 100644 index 000000000000..16c2d5d6fd9e --- /dev/null +++ b/.changeset/two-geckos-train.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/i18n": major +--- + +Adds new empty states for the marketplace view diff --git a/apps/meteor/client/apps/orchestrator.ts b/apps/meteor/client/apps/orchestrator.ts index f33807d25be4..86d4df829aa9 100644 --- a/apps/meteor/client/apps/orchestrator.ts +++ b/apps/meteor/client/apps/orchestrator.ts @@ -11,6 +11,9 @@ import type { App } from '../views/marketplace/types'; import type { IAppExternalURL, ICategory } from './@types/IOrchestrator'; import { RealAppsEngineUIHost } from './RealAppsEngineUIHost'; +const isErrorObject = (e: unknown): e is { error: string } => + typeof e === 'object' && e !== null && 'error' in e && typeof e.error === 'string'; + class AppClientOrchestrator { private _appClientUIHost: AppsEngineUIHost; @@ -53,15 +56,25 @@ class AppClientOrchestrator { throw new Error('Invalid response from API'); } - public async getAppsFromMarketplace(isAdminUser?: boolean): Promise { - const result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' }); + public async getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }> { + let result: App[] = []; + try { + result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' }); + } catch (e) { + if (isErrorObject(e)) { + return { apps: [], error: e.error }; + } + if (typeof e === 'string') { + return { apps: [], error: e }; + } + } if (!Array.isArray(result)) { // TODO: chapter day: multiple results are returned, but we only need one - throw new Error('Invalid response from API'); + return { apps: [], error: 'Invalid response from API' }; } - return (result as App[]).map((app: App) => { + const apps = (result as App[]).map((app: App) => { const { latest, appRequestStats, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt, bundledIn, requestedEndUser } = app; return { ...latest, @@ -75,6 +88,8 @@ class AppClientOrchestrator { requestedEndUser, }; }); + + return { apps, error: undefined }; } public async getAppsOnBundle(bundleId: string): Promise { diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 2be8e74c2d67..9421715eccbf 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -14,7 +14,7 @@ export interface IAppsOrchestrator { getAppClientManager(): AppClientManager; handleError(error: unknown): void; getInstalledApps(): Promise; - getAppsFromMarketplace(isAdminUser?: boolean): Promise; + getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }>; getAppsOnBundle(bundleId: string): Promise; getApp(appId: string): Promise; setAppSettings(appId: string, settings: ISetting[]): Promise; @@ -27,9 +27,9 @@ export interface IAppsOrchestrator { } export type AppsContextValue = { - installedApps: Omit, 'error'>; - marketplaceApps: Omit, 'error'>; - privateApps: Omit, 'error'>; + installedApps: AsyncState<{ apps: App[] }>; + marketplaceApps: AsyncState<{ apps: App[] }>; + privateApps: AsyncState<{ apps: App[] }>; reload: () => Promise; orchestrator?: IAppsOrchestrator; }; @@ -38,14 +38,17 @@ export const AppsContext = createContext({ installedApps: { phase: AsyncStatePhase.LOADING, value: undefined, + error: undefined, }, marketplaceApps: { phase: AsyncStatePhase.LOADING, value: undefined, + error: undefined, }, privateApps: { phase: AsyncStatePhase.LOADING, value: undefined, + error: undefined, }, reload: () => Promise.resolve(), orchestrator: undefined, diff --git a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx index cf1d4d671d94..f67df644523f 100644 --- a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx @@ -2,12 +2,11 @@ import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import { usePermission, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { AppClientOrchestratorInstance } from '../../apps/orchestrator'; import { AppsContext } from '../../contexts/AppsContext'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; -import { useInvalidateLicense } from '../../hooks/useLicense'; +import { useInvalidateLicense, useLicense } from '../../hooks/useLicense'; import type { AsyncState } from '../../lib/asyncState'; import { AsyncStatePhase } from '../../lib/asyncState'; import { useInvalidateAppsCountQueryCallback } from '../../views/marketplace/hooks/useAppsCountQuery'; @@ -17,15 +16,24 @@ import { storeQueryFunction } from './storeQueryFunction'; const getAppState = ( loading: boolean, apps: App[] | undefined, -): Omit< - AsyncState<{ - apps: App[]; - }>, - 'error' -> => ({ - phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED, - value: { apps: apps || [] }, -}); + error?: Error, +): AsyncState<{ + apps: App[]; +}> => { + if (error) { + return { + phase: AsyncStatePhase.REJECTED, + value: undefined, + error, + }; + } + + return { + phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED, + value: { apps: apps || [] }, + error, + }; +}; type AppsProviderProps = { children: ReactNode; @@ -36,8 +44,10 @@ const AppsProvider = ({ children }: AppsProviderProps) => { const queryClient = useQueryClient(); - const { data } = useIsEnterprise(); - const isEnterprise = !!data?.isEnterprise; + const { isLoading: isLicenseInformationLoading, data: { license } = {} } = useLicense({ loadValues: true }); + const isEnterprise = isLicenseInformationLoading ? undefined : !!license; + + const [marketplaceError, setMarketplaceError] = useState(); const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); const invalidateLicenseQuery = useInvalidateLicense(); @@ -66,10 +76,14 @@ const AppsProvider = ({ children }: AppsProviderProps) => { const marketplace = useQuery( ['marketplace', 'apps-marketplace', isAdminUser], - () => { - const result = AppClientOrchestratorInstance.getAppsFromMarketplace(isAdminUser); + async () => { + const result = await AppClientOrchestratorInstance.getAppsFromMarketplace(isAdminUser); queryClient.invalidateQueries(['marketplace', 'apps-stored']); - return result; + if (result.error && typeof result.error === 'string') { + setMarketplaceError(new Error(result.error)); + return []; + } + return result.apps; }, { staleTime: Infinity, @@ -95,21 +109,25 @@ const AppsProvider = ({ children }: AppsProviderProps) => { }, ); - const store = useQuery(['marketplace', 'apps-stored', instance.data, marketplace.data], () => storeQueryFunction(marketplace, instance), { - enabled: marketplace.isFetched && instance.isFetched, - keepPreviousData: true, - }); + const { isLoading: isMarketplaceDataLoading, data: marketplaceData } = useQuery( + ['marketplace', 'apps-stored', instance.data, marketplace.data], + () => storeQueryFunction(marketplace, instance), + { + enabled: marketplace.isFetched && instance.isFetched, + keepPreviousData: true, + }, + ); - const [marketplaceAppsData, installedAppsData, privateAppsData] = store.data || []; - const { isLoading } = store; + const [marketplaceAppsData, installedAppsData, privateAppsData] = marketplaceData || []; return ( { await Promise.all([queryClient.invalidateQueries(['marketplace'])]); }, diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx index 0c90ac238d24..af0b8f384c8a 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx @@ -1,24 +1,13 @@ -import { useTranslation, useRouteParameter } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; -import { Page, PageContent } from '../../../components/Page'; -import MarketplaceHeader from '../components/MarketplaceHeader'; +import { Page } from '../../../components/Page'; import AppsPageContent from './AppsPageContent'; -type AppsContext = 'explore' | 'installed' | 'premium' | 'private'; - const AppsPage = (): ReactElement => { - const t = useTranslation(); - - const context = useRouteParameter('context') as AppsContext; - return ( - - - - + ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx index e72a30e6a5c9..23446b0da208 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx @@ -4,8 +4,10 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; +import { PageContent } from '../../../components/Page'; import { useAppsResult } from '../../../contexts/hooks/useAppsResult'; import { AsyncStatePhase } from '../../../lib/asyncState'; +import MarketplaceHeader from '../components/MarketplaceHeader'; import type { RadioDropDownGroup } from '../definitions/RadioDropDownDefinitions'; import { useCategories } from '../hooks/useCategories'; import type { appsDataType } from '../hooks/useFilteredApps'; @@ -20,6 +22,9 @@ import NoInstalledAppMatchesEmptyState from './NoInstalledAppMatchesEmptyState'; import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState'; import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState'; import PrivateEmptyState from './PrivateEmptyState'; +import UnsupportedEmptyState from './UnsupportedEmptyState'; + +type AppsContext = 'explore' | 'installed' | 'premium' | 'private' | 'requested'; const AppsPageContent = (): ReactElement => { const t = useTranslation(); @@ -29,7 +34,7 @@ const AppsPageContent = (): ReactElement => { const router = useRouter(); - const context = useRouteParameter('context'); + const context = useRouteParameter('context') as AppsContext; const isMarketplace = context === 'explore'; const isPremium = context === 'premium'; @@ -134,6 +139,8 @@ const AppsPageContent = (): ReactElement => { const noInstalledApps = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value?.totalAppsLength === 0; + const unsupportedVersion = appsResult.phase === AsyncStatePhase.REJECTED && appsResult.error.message === 'unsupported version'; + const noMarketplaceOrInstalledAppMatches = appsResult.phase === AsyncStatePhase.RESOLVED && (isMarketplace || isPremium) && appsResult.value?.count === 0; @@ -189,6 +196,10 @@ const AppsPageContent = (): ReactElement => { }, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]); const getEmptyState = () => { + if (unsupportedVersion) { + return ; + } + if (noAppRequests) { return ; } @@ -213,7 +224,9 @@ const AppsPageContent = (): ReactElement => { }; return ( - <> + + + { context={context || 'explore'} /> {appsResult.phase === AsyncStatePhase.LOADING && } - {appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && ( + {appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && !unsupportedVersion && ( { /> )} {getEmptyState()} - {appsResult.phase === AsyncStatePhase.REJECTED && } - + {appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion && } + ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx index 56c3ca26f9a7..bd41d63245f6 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx @@ -11,7 +11,12 @@ import FeaturedAppsSections from './FeaturedAppsSections'; type AppsPageContentBodyProps = { isMarketplace: boolean; isFiltered: boolean; - appsResult?: { items: App[] } & { shouldShowSearchText: boolean } & PaginatedResult & { allApps: App[] } & { totalAppsLength: number }; + appsResult?: PaginatedResult<{ + items: App[]; + shouldShowSearchText: boolean; + allApps: App[]; + totalAppsLength: number; + }>; itemsPerPage: 25 | 50 | 100; current: number; onSetItemsPerPage: React.Dispatch>; diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx new file mode 100644 index 000000000000..1e205c602752 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx @@ -0,0 +1,33 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AppsContext } from '../../../contexts/AppsContext'; +import { asyncState } from '../../../lib/asyncState'; +import UnsupportedEmptyState from './UnsupportedEmptyState'; + +describe('with private apps enabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Marketplace_unavailable: 'Marketplace unavailable', + }) + .wrap((children) => ( + Promise.resolve(), + orchestrator: undefined, + }} + > + {children} + + )); + + it('should inform that the marketplace is unavailable due unsupported version', () => { + render(, { wrapper: appRoot.build(), legacyRoot: true }); + + expect(screen.getByRole('heading', { name: 'Marketplace unavailable' })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx new file mode 100644 index 000000000000..8f7ed193c77d --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx @@ -0,0 +1,16 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import UnsupportedEmptyState from './UnsupportedEmptyState'; + +export default { + title: 'Marketplace/Components/UnsupportedEmptyState', + component: UnsupportedEmptyState, + parameters: { + layout: 'fullscreen', + controls: { hideNoControlsWarning: true }, + }, +} as ComponentMeta; + +export const Default: ComponentStory = () => ; +Default.storyName = 'UnsupportedEmptyState'; diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx new file mode 100644 index 000000000000..d7999cfbad01 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx @@ -0,0 +1,33 @@ +import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import UpdateRocketChatButton from '../components/UpdateRocketChatButton'; + +const UnsupportedEmptyState = (): ReactElement => { + const isAdmin = usePermission('manage-apps'); + const { t } = useTranslation(); + + const title = isAdmin ? t('Update_to_access_marketplace') : t('Marketplace_unavailable'); + const description = isAdmin ? t('Update_to_access_marketplace_description') : t('Marketplace_unavailable_description'); + + return ( + + + + {title} + {description} + + + {isAdmin && } + + + + ); +}; + +export default UnsupportedEmptyState; diff --git a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx index dfc3033e9812..6cb734056229 100644 --- a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx +++ b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx @@ -8,8 +8,9 @@ import { PageHeader } from '../../../components/Page'; import UnlimitedAppsUpsellModal from '../UnlimitedAppsUpsellModal'; import { useAppsCountQuery } from '../hooks/useAppsCountQuery'; import EnabledAppsCount from './EnabledAppsCount'; +import UpdateRocketChatButton from './UpdateRocketChatButton'; -const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => { +const MarketplaceHeader = ({ title, unsupportedVersion }: { title: string; unsupportedVersion: boolean }): ReactElement | null => { const t = useTranslation(); const isAdmin = usePermission('manage-apps'); const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested'; @@ -29,8 +30,11 @@ const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => {result.isLoading && } - {result.isSuccess && !result.data.hasUnlimitedApps && } - {isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && ( + {!unsupportedVersion && result.isSuccess && !result.data.hasUnlimitedApps && ( + + )} + + {!unsupportedVersion && isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && ( )} + {isAdmin && context === 'private' && } + + {unsupportedVersion && context !== 'private' && } ); diff --git a/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx b/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx new file mode 100644 index 000000000000..9d6be4cc3cc8 --- /dev/null +++ b/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx @@ -0,0 +1,15 @@ +import { Button } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const UpdateRocketChatButton = () => { + const { t } = useTranslation(); + + return ( + + ); +}; + +export default UpdateRocketChatButton; diff --git a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts index c90052fd78e3..1a669b868080 100644 --- a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts +++ b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts @@ -39,9 +39,13 @@ export const useFilteredApps = ({ sortingMethod: string; status: string; context?: string; -}): Omit< - AsyncState<{ items: App[] } & { shouldShowSearchText: boolean } & PaginatedResult & { allApps: App[] } & { totalAppsLength: number }>, - 'error' +}): AsyncState< + PaginatedResult<{ + items: App[]; + shouldShowSearchText: boolean; + allApps: App[]; + totalAppsLength: number; + }> > => { const value = useMemo(() => { if (appsData.value === undefined) { diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 3eed45f4ddad..9baee8196e20 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -125,6 +125,12 @@ export class AppsRestApi { ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }), }, }); + + if (request.status === 426) { + orchestrator.getRocketChatLogger().error('Workspace out of support window:', await request.json()); + return API.v1.failure({ error: 'unsupported version' }); + } + if (request.status !== 200) { orchestrator.getRocketChatLogger().error('Error getting the Apps:', await request.json()); return API.v1.failure(); diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx index f0147509cb12..1e87c26d4f72 100644 --- a/apps/meteor/tests/mocks/client/marketplace.tsx +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -30,7 +30,7 @@ export const mockAppsOrchestrator = () => { getAppClientManager: () => manager, handleError: () => undefined, getInstalledApps: async () => [], - getAppsFromMarketplace: async () => [], + getAppsFromMarketplace: async () => ({ apps: [] }), getAppsOnBundle: async () => [], getApp: () => Promise.reject(new Error('not implemented')), setAppSettings: async () => undefined, diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index adbded8c4601..9715393d3770 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3535,6 +3535,8 @@ "Marketplace_app_last_updated": "Last updated {{lastUpdated}}", "Marketplace_view_marketplace": "View Marketplace", "Marketplace_error": "Cannot connect to internet or your workspace may be an offline install.", + "Marketplace_unavailable": "Marketplace unavailable", + "Marketplace_unavailable_description": "This workspace cannot access the marketplace because it’s running an unsupported version of Rocket.Chat. Ask your workspace admin to update and regain access.", "MAU_value": "MAU {{value}}", "Max_length_is": "Max length is %s", "Max_number_incoming_livechats_displayed": "Max number of items displayed in the queue", @@ -5635,6 +5637,8 @@ "Update_LatestAvailableVersion": "Update Latest Available Version", "Update_to_version": "Update to {{version}}", "Update_your_RocketChat": "Update your Rocket.Chat", + "Update_to_access_marketplace": "Update to access marketplace", + "Update_to_access_marketplace_description": "This workspace cannot access the marketplace because it's running an unsupported version of Rocket.Chat.", "Updated_at": "Updated at", "Upgrade_tab_upgrade_your_plan": "Upgrade your plan", "Upload": "Upload", From 9ea30e17a10f79e401e8a92bf70d31801bd84df3 Mon Sep 17 00:00:00 2001 From: Rafael Tapia Date: Tue, 15 Oct 2024 18:08:39 -0300 Subject: [PATCH 014/173] feat: get contact by phone or email (#33595) * feat: allow get contact by phone or email as well * test: ensure that endpoint is working as intended --- .../app/livechat/server/api/v1/contact.ts | 19 ++++- .../tests/end-to-end/api/livechat/contacts.ts | 76 +++++++++++++++++-- packages/rest-typings/src/v1/omnichannel.ts | 40 ++++++++-- 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 44b8dd787c4d..1bcaed84cc43 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,4 +1,5 @@ -import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; import { isPOSTOmnichannelContactsProps, isPOSTUpdateOmnichannelContactsProps, @@ -144,7 +145,21 @@ API.v1.addRoute( if (!isSingleContactEnabled()) { return API.v1.unauthorized(); } - const contact = await getContact(this.queryParams.contactId); + + const { contactId, email, phone } = this.queryParams; + let contact: ILivechatContact | null = null; + + if (contactId) { + contact = await getContact(contactId); + } + + if (email) { + contact = await LivechatContacts.findOne({ 'emails.address': email }); + } + + if (phone) { + contact = await LivechatContacts.findOne({ 'phones.phoneNumber': phone }); + } return API.v1.success({ contact }); }, diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index dc0a29724040..8b4e217677e5 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -587,10 +587,13 @@ describe('LIVECHAT - contacts', () => { describe('[GET] omnichannel/contacts.get', () => { let contactId: string; + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + const contact = { name: faker.person.fullName(), - emails: [faker.internet.email().toLowerCase()], - phones: [faker.phone.number()], + emails: [email], + phones: [phone], contactManager: agentUser?._id, }; @@ -624,7 +627,41 @@ describe('LIVECHAT - contacts', () => { expect(res.body.contact.contactManager).to.be.equal(contact.contactManager); }); - it('should return null if contact does not exist', async () => { + it('should be able get a contact by phone', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ phone }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('createdAt'); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.name).to.be.equal(contact.name); + expect(res.body.contact.emails).to.be.deep.equal([ + { + address: contact.emails[0], + }, + ]); + expect(res.body.contact.phones).to.be.deep.equal([{ phoneNumber: contact.phones[0] }]); + expect(res.body.contact.contactManager).to.be.equal(contact.contactManager); + }); + + it('should be able get a contact by email', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ email }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('createdAt'); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.name).to.be.equal(contact.name); + expect(res.body.contact.emails).to.be.deep.equal([ + { + address: contact.emails[0], + }, + ]); + expect(res.body.contact.phones).to.be.deep.equal([{ phoneNumber: contact.phones[0] }]); + expect(res.body.contact.contactManager).to.be.equal(contact.contactManager); + }); + + it('should return null if contact does not exist using contactId', async () => { const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: 'invalid' }); expect(res.status).to.be.equal(200); @@ -632,6 +669,22 @@ describe('LIVECHAT - contacts', () => { expect(res.body.contact).to.be.null; }); + it('should return null if contact does not exist using email', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ email: 'invalid' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it('should return null if contact does not exist using phone', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ phone: 'invalid' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { await removePermissionFromAllRoles('view-livechat-contact'); @@ -643,12 +696,25 @@ describe('LIVECHAT - contacts', () => { await restorePermissionToRoles('view-livechat-contact'); }); - it('should return an error if contactId is missing', async () => { + it('should return an error if contactId, email or phone is missing', async () => { const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials); expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('error'); - expect(res.body.error).to.be.equal("must have required property 'contactId' [invalid-params]"); + expect(res.body.error).to.be.equal( + "must have required property 'email'\n must have required property 'phone'\n must have required property 'contactId'\n must match a schema in anyOf [invalid-params]", + ); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if more than one field is provided', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId, phone, email }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal( + 'must NOT have additional properties\n must NOT have additional properties\n must NOT have additional properties\n must match a schema in anyOf [invalid-params]', + ); expect(res.body.errorType).to.be.equal('invalid-params'); }); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 879ce5155f80..b88fee73651e 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1315,17 +1315,41 @@ const POSTUpdateOmnichannelContactsSchema = { export const isPOSTUpdateOmnichannelContactsProps = ajv.compile(POSTUpdateOmnichannelContactsSchema); -type GETOmnichannelContactsProps = { contactId: string }; +type GETOmnichannelContactsProps = { contactId?: string; email?: string; phone?: string }; const GETOmnichannelContactsSchema = { - type: 'object', - properties: { - contactId: { - type: 'string', + anyOf: [ + { + type: 'object', + properties: { + email: { + type: 'string', + }, + }, + required: ['email'], + additionalProperties: false, }, - }, - required: ['contactId'], - additionalProperties: false, + { + type: 'object', + properties: { + phone: { + type: 'string', + }, + }, + required: ['phone'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + contactId: { + type: 'string', + }, + }, + required: ['contactId'], + additionalProperties: false, + }, + ], }; export const isGETOmnichannelContactsProps = ajv.compile(GETOmnichannelContactsSchema); From 761c2a6358bd548ebc89337d57516b0acbdd424d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 15 Oct 2024 20:15:05 -0300 Subject: [PATCH 015/173] Merge branch 'develop' into feat/single-contact-id --- .../useStartCallRoomAction.tsx | 4 +- .../views/admin/users/AdminUsersPage.tsx | 2 +- .../users/UsersPageHeaderContent.spec.tsx | 21 ++++++++++- .../admin/users/UsersPageHeaderContent.tsx | 14 +++---- .../users/UsersTable/UsersTable.spec.tsx | 34 +++++++++++++++-- .../admin/users/UsersTable/UsersTable.tsx | 20 +++++----- .../admin/users/UsersTable/UsersTableRow.tsx | 21 +++++++---- .../users/hooks/useVoipExtensionAction.tsx | 7 ++-- .../users/voip/AssignExtensionButton.tsx | 16 ++++---- .../voip/hooks/useVoipExtensionAction.tsx | 37 +++++++++++++++++++ .../voip/hooks/useVoipExtensionPermission.tsx | 8 ++++ packages/ui-voip/src/hooks/useVoipClient.tsx | 5 ++- .../src/hooks/useVoipExtensionDetails.tsx | 5 +-- .../ui-voip/src/providers/VoipProvider.tsx | 20 ++++++++-- 14 files changed, 159 insertions(+), 55 deletions(-) create mode 100644 apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionAction.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionPermission.tsx diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx index ee3117d664d1..18d3efd01053 100644 --- a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx @@ -7,8 +7,8 @@ import useVideoConfMenuOptions from './useVideoConfMenuOptions'; import useVoipMenuOptions from './useVoipMenuOptions'; export const useStartCallRoomAction = () => { - const voipCall = useVideoConfMenuOptions(); - const videoCall = useVoipMenuOptions(); + const videoCall = useVideoConfMenuOptions(); + const voipCall = useVoipMenuOptions(); return useMemo((): RoomToolboxActionConfig | undefined => { if (!videoCall.allowed && !voipCall.allowed) { diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 14757e3710b0..afe881f64cc9 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -147,12 +147,12 @@ const AdminUsersPage = (): ReactElement => {
diff --git a/apps/meteor/client/views/admin/users/UsersPageHeaderContent.spec.tsx b/apps/meteor/client/views/admin/users/UsersPageHeaderContent.spec.tsx index f4691eb4dd69..6181fe4fed50 100644 --- a/apps/meteor/client/views/admin/users/UsersPageHeaderContent.spec.tsx +++ b/apps/meteor/client/views/admin/users/UsersPageHeaderContent.spec.tsx @@ -5,12 +5,31 @@ import '@testing-library/jest-dom'; import UsersPageHeaderContent from './UsersPageHeaderContent'; -it('should render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is enabled', async () => { +it('should not show "Assign Extension" button if voip setting is enabled but user dont have required permission', async () => { render(, { legacyRoot: true, wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(), }); + expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument(); +}); + +it('should not show "Assign Extension" button if user has required permission but voip setting is disabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(), + }); + + expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument(); +}); + +it('should show "Assign Extension" button if user has required permission and voip setting is enabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).withPermission('manage-voip-extensions').build(), + }); + + expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeEnabled(); }); diff --git a/apps/meteor/client/views/admin/users/UsersPageHeaderContent.tsx b/apps/meteor/client/views/admin/users/UsersPageHeaderContent.tsx index e6794a9f98f6..89916c3e6f2e 100644 --- a/apps/meteor/client/views/admin/users/UsersPageHeaderContent.tsx +++ b/apps/meteor/client/views/admin/users/UsersPageHeaderContent.tsx @@ -1,5 +1,5 @@ import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage'; -import { usePermission, useRouter, useSetModal, useSetting } from '@rocket.chat/ui-contexts'; +import { usePermission, useRouter } from '@rocket.chat/ui-contexts'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -7,7 +7,8 @@ import { useExternalLink } from '../../../hooks/useExternalLink'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import SeatsCapUsage from './SeatsCapUsage'; import type { SeatCapProps } from './useSeatsCap'; -import AssignExtensionModal from './voip/AssignExtensionModal'; +import AssignExtensionButton from './voip/AssignExtensionButton'; +import { useVoipExtensionPermission } from './voip/hooks/useVoipExtensionPermission'; type UsersPageHeaderContentProps = { isSeatsCapExceeded: boolean; @@ -17,10 +18,9 @@ type UsersPageHeaderContentProps = { const UsersPageHeaderContent = ({ isSeatsCapExceeded, seatsCap }: UsersPageHeaderContentProps) => { const { t } = useTranslation(); const router = useRouter(); - const setModal = useSetModal(); const canCreateUser = usePermission('create-user'); const canBulkCreateUser = usePermission('bulk-register-user'); - const canRegisterExtension = useSetting('VoIP_TeamCollab_Enabled'); + const canManageVoipExtension = useVoipExtensionPermission(); const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' }); const openExternalLink = useExternalLink(); @@ -41,11 +41,7 @@ const UsersPageHeaderContent = ({ isSeatsCapExceeded, seatsCap }: UsersPageHeade )} - {canRegisterExtension && ( - - )} + {canManageVoipExtension && } {canBulkCreateUser && ( ); }; diff --git a/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionAction.tsx b/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionAction.tsx new file mode 100644 index 000000000000..e5f25683586a --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionAction.tsx @@ -0,0 +1,37 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { Action } from '../../../../hooks/useActionSpread'; +import AssignExtensionModal from '../AssignExtensionModal'; +import RemoveExtensionModal from '../RemoveExtensionModal'; + +type VoipExtensionActionParams = { + name: string; + username: string; + extension?: string; + enabled: boolean; +}; + +export const useVoipExtensionAction = ({ name, username, extension, enabled }: VoipExtensionActionParams): Action | undefined => { + const { t } = useTranslation(); + const setModal = useSetModal(); + + const handleExtensionAssignment = useEffectEvent(() => { + if (extension) { + setModal( setModal(null)} />); + return; + } + + setModal( setModal(null)} />); + }); + + return enabled + ? { + icon: extension ? 'phone-disabled' : 'phone', + label: extension ? t('Unassign_extension') : t('Assign_extension'), + action: handleExtensionAssignment, + } + : undefined; +}; diff --git a/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionPermission.tsx b/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionPermission.tsx new file mode 100644 index 000000000000..70e9e2ce91af --- /dev/null +++ b/apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionPermission.tsx @@ -0,0 +1,8 @@ +import { useSetting, usePermission } from '@rocket.chat/ui-contexts'; + +export const useVoipExtensionPermission = () => { + const isVoipSettingEnabled = useSetting('VoIP_TeamCollab_Enabled', false); + const canManageVoipExtensions = usePermission('manage-voip-extensions'); + + return isVoipSettingEnabled && canManageVoipExtensions; +}; diff --git a/packages/ui-voip/src/hooks/useVoipClient.tsx b/packages/ui-voip/src/hooks/useVoipClient.tsx index e4aad0f4919b..26c3c50427ba 100644 --- a/packages/ui-voip/src/hooks/useVoipClient.tsx +++ b/packages/ui-voip/src/hooks/useVoipClient.tsx @@ -6,6 +6,7 @@ import VoipClient from '../lib/VoipClient'; import { useWebRtcServers } from './useWebRtcServers'; type VoipClientParams = { + enabled?: boolean; autoRegister?: boolean; }; @@ -14,7 +15,7 @@ type VoipClientResult = { error: Error | null; }; -export const useVoipClient = ({ autoRegister = true }: VoipClientParams): VoipClientResult => { +export const useVoipClient = ({ enabled = true, autoRegister = true }: VoipClientParams = {}): VoipClientResult => { const { _id: userId } = useUser() || {}; const isVoipEnabled = useSetting('VoIP_TeamCollab_Enabled'); const voipClientRef = useRef(null); @@ -71,7 +72,7 @@ export const useVoipClient = ({ autoRegister = true }: VoipClientParams): VoipCl }, { initialData: null, - enabled: isVoipEnabled, + enabled, }, ); diff --git a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx index d106ae2842aa..d08d6f851638 100644 --- a/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx +++ b/packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx @@ -7,10 +7,7 @@ export const useVoipExtensionDetails = ({ extension, enabled = true }: { extensi const { data, ...result } = useQuery( ['voip', 'voip-extension-details', extension, getContactDetails], () => getContactDetails({ extension: extension as string }), - { - enabled: isEnabled, - onError: () => undefined, - }, + { enabled: isEnabled }, ); return { diff --git a/packages/ui-voip/src/providers/VoipProvider.tsx b/packages/ui-voip/src/providers/VoipProvider.tsx index 28133abd8698..d723c4b5ceb6 100644 --- a/packages/ui-voip/src/providers/VoipProvider.tsx +++ b/packages/ui-voip/src/providers/VoipProvider.tsx @@ -1,6 +1,12 @@ import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; import type { Device } from '@rocket.chat/ui-contexts'; -import { useSetInputMediaDevice, useSetOutputMediaDevice, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { + usePermission, + useSetInputMediaDevice, + useSetOutputMediaDevice, + useSetting, + useToastMessageDispatch, +} from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; @@ -15,16 +21,22 @@ import { useVoipSounds } from '../hooks/useVoipSounds'; const VoipProvider = ({ children }: { children: ReactNode }) => { // Settings - const isVoipEnabled = useSetting('VoIP_TeamCollab_Enabled') || false; + const isVoipSettingEnabled = useSetting('VoIP_TeamCollab_Enabled') || false; + const canViewVoipRegistrationInfo = usePermission('view-user-voip-extension'); + const isVoipEnabled = isVoipSettingEnabled && canViewVoipRegistrationInfo; + const [isLocalRegistered, setStorageRegistered] = useLocalStorage('voip-registered', true); // Hooks + const { t } = useTranslation(); const voipSounds = useVoipSounds(); - const { voipClient, error } = useVoipClient({ autoRegister: isLocalRegistered }); + const { voipClient, error } = useVoipClient({ + enabled: isVoipEnabled, + autoRegister: isLocalRegistered, + }); const setOutputMediaDevice = useSetOutputMediaDevice(); const setInputMediaDevice = useSetInputMediaDevice(); const dispatchToastMessage = useToastMessageDispatch(); - const { t } = useTranslation(); // Refs const remoteAudioMediaRef = useRef(null); From 865f3bb98acde910df69351efebec3b4d0da5665 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 16 Oct 2024 12:32:01 -0300 Subject: [PATCH 016/173] fix: contacts.get endpoint returning a successful response even if the contact is not found (#33607) --- apps/meteor/app/livechat/server/api/v1/contact.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 1bcaed84cc43..92964fe5b601 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -161,6 +161,10 @@ API.v1.addRoute( contact = await LivechatContacts.findOne({ 'phones.phoneNumber': phone }); } + if (!contact) { + return API.v1.notFound(); + } + return API.v1.success({ contact }); }, }, From 91381b300d561cd15463d71c98333e75d4d3beae Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 16 Oct 2024 15:54:15 -0300 Subject: [PATCH 017/173] feat: Search, create and edit contacts (#33591) * feat: Get contacts from `contacts.search` endpoint (#33573) * feat: Allows multiple emails and phones in edit contact info (#32900) * feat: get contact data from the new endpoint * feat: allow multiple emails and phone numbers * chore: `ContactManagerInput` to ts * chore: add fields validations * chore: changeset * chore: remove unnecessary `id` from `EditContactInfo` * chore: skip contact center tests --- .changeset/early-oranges-doubt.md | 6 + ...tactManager.js => ContactManagerInput.tsx} | 9 +- .../views/omnichannel/additionalForms.tsx | 4 +- .../{ => ContactInfo}/ContactInfo.tsx | 61 ++--- .../ContactInfo/ContactInfoWithData.tsx | 34 +++ .../contactInfo/ContactInfo/index.ts | 1 + .../contactInfo/ContactInfoRouter.tsx | 6 +- .../contactInfo/EditContactInfo.tsx | 259 ++++++++++-------- .../contactInfo/EditContactInfoWithData.tsx | 12 +- .../ContactInfoDetails/ContactInfoDetails.tsx | 25 +- .../ContactInfoDetailsEntry.tsx | 26 +- .../ContactInfoDetailsGroup.tsx | 25 ++ .../ContactInfoDetails/ContactManagerInfo.tsx | 8 +- .../directory/ContactContextualBar.tsx | 2 +- .../directory/contacts/ContactTable.tsx | 41 ++- .../contacts/hooks/useCurrentContacts.ts | 10 +- .../omnichannel/hooks/useContactRoute.ts | 4 +- .../omnichannel-contact-center.spec.ts | 3 +- packages/i18n/src/locales/en.i18n.json | 2 + 19 files changed, 306 insertions(+), 232 deletions(-) create mode 100644 .changeset/early-oranges-doubt.md rename apps/meteor/client/omnichannel/additionalForms/{ContactManager.js => ContactManagerInput.tsx} (73%) rename apps/meteor/client/views/omnichannel/contactInfo/{ => ContactInfo}/ContactInfo.tsx (66%) create mode 100644 apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfoWithData.tsx create mode 100644 apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/index.ts create mode 100644 apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx diff --git a/.changeset/early-oranges-doubt.md b/.changeset/early-oranges-doubt.md new file mode 100644 index 000000000000..22effd8537fc --- /dev/null +++ b/.changeset/early-oranges-doubt.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Allows agents to add multiple emails and phone numbers to a contact diff --git a/apps/meteor/client/omnichannel/additionalForms/ContactManager.js b/apps/meteor/client/omnichannel/additionalForms/ContactManagerInput.tsx similarity index 73% rename from apps/meteor/client/omnichannel/additionalForms/ContactManager.js rename to apps/meteor/client/omnichannel/additionalForms/ContactManagerInput.tsx index 52ab527ef841..1773ab8b5e36 100644 --- a/apps/meteor/client/omnichannel/additionalForms/ContactManager.js +++ b/apps/meteor/client/omnichannel/additionalForms/ContactManagerInput.tsx @@ -5,7 +5,12 @@ import React from 'react'; import AutoCompleteAgent from '../../components/AutoCompleteAgent'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; -export const ContactManager = ({ value: userId, handler }) => { +type ContactManagerInputProps = { + value: string; + handler: (currentValue: string) => void; +}; + +export const ContactManagerInput = ({ value: userId, handler }: ContactManagerInputProps) => { const t = useTranslation(); const hasLicense = useHasLicenseModule('livechat-enterprise'); @@ -23,4 +28,4 @@ export const ContactManager = ({ value: userId, handler }) => { ); }; -export default ContactManager; +export default ContactManagerInput; diff --git a/apps/meteor/client/views/omnichannel/additionalForms.tsx b/apps/meteor/client/views/omnichannel/additionalForms.tsx index 824b5eb69694..ef2c41757244 100644 --- a/apps/meteor/client/views/omnichannel/additionalForms.tsx +++ b/apps/meteor/client/views/omnichannel/additionalForms.tsx @@ -1,5 +1,5 @@ import BusinessHoursMultiple from '../../omnichannel/additionalForms/BusinessHoursMultiple'; -import ContactManager from '../../omnichannel/additionalForms/ContactManager'; +import ContactManagerInput from '../../omnichannel/additionalForms/ContactManagerInput'; import CurrentChatTags from '../../omnichannel/additionalForms/CurrentChatTags'; import CustomFieldsAdditionalForm from '../../omnichannel/additionalForms/CustomFieldsAdditionalForm'; import DepartmentBusinessHours from '../../omnichannel/additionalForms/DepartmentBusinessHours'; @@ -20,7 +20,7 @@ export { EeTextAreaInput, BusinessHoursMultiple, EeTextInput, - ContactManager, + ContactManagerInput, CurrentChatTags, DepartmentBusinessHours, DepartmentForwarding, diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx similarity index 66% rename from apps/meteor/client/views/omnichannel/contactInfo/ContactInfo.tsx rename to apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx index 22aa14350a99..8bfc37587db9 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx @@ -1,26 +1,23 @@ +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; import { Box, IconButton, Tabs, TabsItem } from '@rocket.chat/fuselage'; import { UserAvatar } from '@rocket.chat/ui-avatar'; -import type { RouteName } from '@rocket.chat/ui-contexts'; import { useTranslation, useEndpoint, usePermission, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import { ContextualbarHeader, ContextualbarIcon, ContextualbarTitle, ContextualbarClose } from '../../../components/Contextualbar'; -import { useFormatDate } from '../../../hooks/useFormatDate'; -import { FormSkeleton } from '../directory/components/FormSkeleton'; -import { useContactRoute } from '../hooks/useContactRoute'; -import ContactInfoChannels from './tabs/ContactInfoChannels'; -import ContactInfoDetails from './tabs/ContactInfoDetails'; -import ContactInfoHistory from './tabs/ContactInfoHistory'; +import { ContextualbarHeader, ContextualbarIcon, ContextualbarTitle, ContextualbarClose } from '../../../../components/Contextualbar'; +import { useFormatDate } from '../../../../hooks/useFormatDate'; +import { useContactRoute } from '../../hooks/useContactRoute'; +import ContactInfoChannels from '../tabs/ContactInfoChannels'; +import ContactInfoDetails from '../tabs/ContactInfoDetails'; +import ContactInfoHistory from '../tabs/ContactInfoHistory'; type ContactInfoProps = { - id: string; + contact: Serialized; onClose: () => void; - rid?: string; - route?: RouteName; }; -const ContactInfo = ({ id: contactId, onClose }: ContactInfoProps) => { +const ContactInfo = ({ contact, onClose }: ContactInfoProps) => { const t = useTranslation(); const { getRouteName } = useRouter(); @@ -36,34 +33,10 @@ const ContactInfo = ({ id: contactId, onClose }: ContactInfoProps) => { const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); const { data: { customFields } = {} } = useQuery(['/v1/livechat/custom-fields'], () => getCustomFields()); - const getContact = useEndpoint('GET', '/v1/omnichannel/contact'); - const { - data: { contact } = {}, - isInitialLoading, - isError, - } = useQuery(['/v1/omnichannel/contact', contactId], () => getContact({ contactId }), { - enabled: canViewCustomFields && !!contactId, - }); - - if (isInitialLoading) { - return ( - - - - ); - } - - if (isError || !contact) { - return {t('Contact_not_found')}; - } - - const { username, visitorEmails, phone, ts, livechatData, lastChat, contactManager } = contact; + const { name, emails, phones, createdAt, lastChat, contactManager, customFields: userCustomFields } = contact; const showContactHistory = (currentRouteName === 'live' || currentRouteName === 'omnichannel-directory') && lastChat; - const [{ phoneNumber = '' }] = phone ?? [{}]; - const [{ address: email = '' }] = visitorEmails ?? [{}]; - const checkIsVisibleAndScopeVisitor = (key: string) => { const field = customFields?.find(({ _id }) => _id === key); return field?.visibility === 'visible' && field?.scope === 'visitor'; @@ -71,7 +44,7 @@ const ContactInfo = ({ id: contactId, onClose }: ContactInfoProps) => { // Serialized does not like unknown :( const customFieldEntries = canViewCustomFields - ? Object.entries((livechatData ?? {}) as unknown as Record).filter( + ? Object.entries((userCustomFields ?? {}) as unknown as Record).filter( ([key, value]) => checkIsVisibleAndScopeVisitor(key) && value, ) : []; @@ -84,12 +57,12 @@ const ContactInfo = ({ id: contactId, onClose }: ContactInfoProps) => { - {username && ( + {name && ( - + - {username} + {name} {lastChat && {`${t('Last_Chat')}: ${formatDate(lastChat.ts)}`}} @@ -118,10 +91,10 @@ const ContactInfo = ({ id: contactId, onClose }: ContactInfoProps) => { {context === 'details' && ( phoneNumber)} + emails={emails?.map(({ address }) => address)} customFieldEntries={customFieldEntries} /> )} diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfoWithData.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfoWithData.tsx new file mode 100644 index 000000000000..6da95fb99c46 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfoWithData.tsx @@ -0,0 +1,34 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEndpoint, usePermission, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { ContextualbarSkeleton } from '../../../../components/Contextualbar'; +import ContactInfo from './ContactInfo'; + +type ContactInfoWithDataProps = { + id: string; + onClose: () => void; +}; + +const ContactInfoWithData = ({ id: contactId, onClose }: ContactInfoWithDataProps) => { + const t = useTranslation(); + const canViewCustomFields = usePermission('view-livechat-room-customfields'); + + const getContact = useEndpoint('GET', '/v1/omnichannel/contacts.get'); + const { data, isInitialLoading, isError } = useQuery(['getContactById', contactId], () => getContact({ contactId }), { + enabled: canViewCustomFields && !!contactId, + }); + + if (isInitialLoading) { + return ; + } + + if (isError || !data?.contact) { + return {t('Contact_not_found')}; + } + + return ; +}; + +export default ContactInfoWithData; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/index.ts b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/index.ts new file mode 100644 index 000000000000..59e2beece146 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/index.ts @@ -0,0 +1 @@ +export { default } from './ContactInfoWithData'; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfoRouter.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfoRouter.tsx index 23201497fb79..30a35f8610fc 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfoRouter.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfoRouter.tsx @@ -18,14 +18,14 @@ const ContactInfoRouter = () => { }; const { - v: { _id }, + v: { contactId }, } = room; if (context === 'edit') { - return ; + return ; } - return ; + return ; }; export default ContactInfoRouter; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx index 8cb79d712c69..ddfcfa4973e9 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx @@ -1,11 +1,12 @@ -import type { ILivechatVisitor, Serialized } from '@rocket.chat/core-typings'; -import { Field, FieldLabel, FieldRow, FieldError, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings'; +import { Field, FieldLabel, FieldRow, FieldError, TextInput, ButtonGroup, Button, IconButton, Divider } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; -import React, { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import React, { Fragment } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; import { validateEmail } from '../../../../lib/emailValidator'; @@ -18,34 +19,30 @@ import { ContextualbarTitle, ContextualbarClose, } from '../../../components/Contextualbar'; -import { createToken } from '../../../lib/utils/createToken'; -import { ContactManager as ContactManagerForm } from '../additionalForms'; +import { ContactManagerInput } from '../additionalForms'; import { FormSkeleton } from '../directory/components/FormSkeleton'; import { useCustomFieldsMetadata } from '../directory/hooks/useCustomFieldsMetadata'; import { useContactRoute } from '../hooks/useContactRoute'; type ContactNewEditProps = { - id: string; - contactData?: { contact: Serialized | null }; + contactData?: Serialized | null; onClose: () => void; onCancel: () => void; }; type ContactFormData = { - token: string; name: string; - email: string; - phone: string; - username: string; + emails: { address: string }[]; + phones: { phoneNumber: string }[]; customFields: Record; + contactManager: string; }; const DEFAULT_VALUES = { - token: '', name: '', - email: '', - phone: '', - username: '', + emails: [], + phones: [], + contactManager: '', customFields: {}, }; @@ -54,136 +51,118 @@ const getInitialValues = (data: ContactNewEditProps['contactData']): ContactForm return DEFAULT_VALUES; } - const { name, token, phone, visitorEmails, livechatData, contactManager } = data.contact ?? {}; + const { name, phones, emails, customFields, contactManager } = data ?? {}; return { - token: token ?? '', name: name ?? '', - email: visitorEmails ? visitorEmails[0].address : '', - phone: phone ? phone[0].phoneNumber : '', - customFields: livechatData ?? {}, - username: contactManager?.username ?? '', + emails: emails ?? [], + phones: phones ?? [], + customFields: customFields ?? {}, + contactManager: contactManager ?? '', }; }; -const EditContactInfo = ({ id, contactData, onClose, onCancel }: ContactNewEditProps): ReactElement => { +const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const queryClient = useQueryClient(); const handleNavigate = useContactRoute(); - const canViewCustomFields = (): boolean => - hasAtLeastOnePermission(['view-livechat-room-customfields', 'edit-livechat-room-customfields']); + const canViewCustomFields = hasAtLeastOnePermission(['view-livechat-room-customfields', 'edit-livechat-room-customfields']); - const [userId, setUserId] = useState('no-agent-selected'); - const saveContact = useEndpoint('POST', '/v1/omnichannel/contact'); - const getContactBy = useEndpoint('GET', '/v1/omnichannel/contact.search'); - const getUserData = useEndpoint('GET', '/v1/users.info'); + const getContact = useEndpoint('GET', '/v1/omnichannel/contacts.get'); + const createContact = useEndpoint('POST', '/v1/omnichannel/contacts'); + const updateContact = useEndpoint('POST', '/v1/omnichannel/contacts.update'); const { data: customFieldsMetadata = [], isInitialLoading: isLoadingCustomFields } = useCustomFieldsMetadata({ scope: 'visitor', - enabled: canViewCustomFields(), + enabled: canViewCustomFields, }); const initialValue = getInitialValues(contactData); - const { username: initialUsername } = initialValue; const { - register, - formState: { errors, isValid, isDirty, isSubmitting }, + formState: { errors, isSubmitting }, control, - setValue, + watch, handleSubmit, - setError, } = useForm({ - mode: 'onChange', - reValidateMode: 'onChange', + mode: 'onBlur', + reValidateMode: 'onBlur', defaultValues: initialValue, }); - useEffect(() => { - if (!initialUsername) { - return; - } + const { + fields: emailFields, + append: appendEmail, + remove: removeEmail, + } = useFieldArray({ + control, + name: 'emails', + }); - getUserData({ username: initialUsername }).then(({ user }) => { - setUserId(user._id); - }); - }, [getUserData, initialUsername]); + const { + fields: phoneFields, + append: appendPhone, + remove: removePhone, + } = useFieldArray({ + control, + name: 'phones', + }); - const validateEmailFormat = (email: string): boolean | string => { - if (!email || email === initialValue.email) { - return true; - } + const { emails, phones } = watch(); - if (!validateEmail(email)) { + const validateEmailFormat = async (emailValue: string) => { + const currentEmails = emails.map(({ address }) => address); + const isDuplicated = currentEmails.filter((email) => email === emailValue).length > 1; + + if (!validateEmail(emailValue)) { return t('error-invalid-email-address'); } - return true; + const { contact } = await getContact({ email: emailValue }); + return (!contact || contact._id === contactData?._id) && !isDuplicated ? true : t('Email_already_exists'); }; - const validateContactField = async (name: 'phone' | 'email', value: string, optional = true) => { - if ((optional && !value) || value === initialValue[name]) { - return true; - } + const validatePhone = async (phoneValue: string) => { + const currentPhones = phones.map(({ phoneNumber }) => phoneNumber); + const isDuplicated = currentPhones.filter((phone) => phone === phoneValue).length > 1; - const query = { [name]: value } as Record<'phone' | 'email', string>; - const { contact } = await getContactBy(query); - return !contact || contact._id === id; + const { contact } = await getContact({ phone: phoneValue }); + return (!contact || contact._id === contactData?._id) && !isDuplicated ? true : t('Phone_already_exists'); }; const validateName = (v: string): string | boolean => (!v.trim() ? t('Required_field', { field: t('Name') }) : true); - const handleContactManagerChange = async (userId: string): Promise => { - setUserId(userId); - - if (userId === 'no-agent-selected') { - setValue('username', '', { shouldDirty: true }); - return; - } - - const { user } = await getUserData({ userId }); - setValue('username', user.username || '', { shouldDirty: true }); - }; - - const validateAsync = async ({ phone = '', email = '' } = {}) => { - const isEmailValid = await validateContactField('email', email); - const isPhoneValid = await validateContactField('phone', phone); - - !isEmailValid && setError('email', { message: t('Email_already_exists') }); - !isPhoneValid && setError('phone', { message: t('Phone_already_exists') }); - - return isEmailValid && isPhoneValid; - }; - const handleSave = async (data: ContactFormData): Promise => { - if (!(await validateAsync(data))) { - return; - } - - const { name, phone, email, customFields, username, token } = data; + const { name, phones, emails, customFields, contactManager } = data; const payload = { name, - phone, - email, + phones: phones.map(({ phoneNumber }) => phoneNumber), + emails: emails.map(({ address }) => address), customFields, - token: token || createToken(), - ...(username && { contactManager: { username } }), - ...(id && { _id: id }), + contactManager, }; try { - await saveContact(payload); + if (contactData) { + await updateContact({ contactId: contactData?._id, ...payload }); + handleNavigate({ context: 'details', id: contactData?._id }); + } else { + const { contactId } = await createContact(payload); + handleNavigate({ context: 'details', id: contactId }); + } + dispatchToastMessage({ type: 'success', message: t('Saved') }); await queryClient.invalidateQueries({ queryKey: ['current-contacts'] }); - contactData ? handleNavigate({ context: 'details' }) : handleNavigate({ tab: 'contacts', context: '' }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } }; + const nameField = useUniqueId(); + if (isLoadingCustomFields) { return ( @@ -201,43 +180,89 @@ const EditContactInfo = ({ id, contactData, onClose, onCancel }: ContactNewEditP - {t('Name')}* + + {t('Name')} + - + } + /> - {errors.name?.message} + {errors.name && {errors.name.message}} {t('Email')} - - - - {errors.email?.message} + {emailFields.map((field, index) => ( + + + } + /> + removeEmail(index)} mis={8} icon='trash' /> + + {errors.emails?.[index]?.address && {errors.emails?.[index]?.address?.message}} + + ))} + {t('Phone')} - - - - {errors.phone?.message} + {phoneFields.map((field, index) => ( + + + } + /> + removePhone(index)} mis={8} icon='trash' /> + + {errors.phones?.[index]?.phoneNumber && {errors.phones?.[index]?.phoneNumber?.message}} + {errors.phones?.[index]?.message} + + ))} + - {canViewCustomFields() && } - + ( + { + if (currentValue === 'no-agent-selected') { + return onChange(''); + } + + onChange(currentValue); + }} + /> + )} + /> + + {canViewCustomFields && } - - + diff --git a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfoWithData.tsx b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfoWithData.tsx index 8fac0a5baccc..68137b02e75e 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfoWithData.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/EditContactInfoWithData.tsx @@ -6,10 +6,16 @@ import React from 'react'; import { FormSkeleton } from '../directory/components/FormSkeleton'; import EditContactInfo from './EditContactInfo'; -const EditContactInfoWithData = ({ id, onClose, onCancel }: { id: string; onClose: () => void; onCancel: () => void }) => { +type EditContactInfoWithDataProps = { + id: string; + onClose: () => void; + onCancel: () => void; +}; + +const EditContactInfoWithData = ({ id, onClose, onCancel }: EditContactInfoWithDataProps) => { const t = useTranslation(); - const getContactEndpoint = useEndpoint('GET', '/v1/omnichannel/contact'); + const getContactEndpoint = useEndpoint('GET', '/v1/omnichannel/contacts.get'); const { data, isLoading, isError } = useQuery(['getContactById', id], async () => getContactEndpoint({ contactId: id })); if (isLoading) { @@ -24,7 +30,7 @@ const EditContactInfoWithData = ({ id, onClose, onCancel }: { id: string; onClos return {t('Contact_not_found')}; } - return ; + return ; }; export default EditContactInfoWithData; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetails.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetails.tsx index fbb304d73aeb..9ece7d02f330 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetails.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetails.tsx @@ -4,38 +4,35 @@ import React from 'react'; import { ContextualbarScrollableContent } from '../../../../../components/Contextualbar'; import { useFormatDate } from '../../../../../hooks/useFormatDate'; -import { parseOutboundPhoneNumber } from '../../../../../lib/voip/parseOutboundPhoneNumber'; import CustomField from '../../../components/CustomField'; import Field from '../../../components/Field'; import Info from '../../../components/Info'; import Label from '../../../components/Label'; -import ContactInfoDetailsEntry from './ContactInfoDetailsEntry'; +import ContactInfoDetailsGroup from './ContactInfoDetailsGroup'; import ContactManagerInfo from './ContactManagerInfo'; type ContactInfoDetailsProps = { - email: string; - phoneNumber: string; - ts: string; + emails?: string[]; + phones?: string[]; + createdAt: string; customFieldEntries: [string, string][]; - contactManager?: { - username: string; - }; + contactManager?: string; }; -const ContactInfoDetails = ({ email, phoneNumber, ts, customFieldEntries, contactManager }: ContactInfoDetailsProps) => { +const ContactInfoDetails = ({ emails, phones, createdAt, customFieldEntries, contactManager }: ContactInfoDetailsProps) => { const t = useTranslation(); const formatDate = useFormatDate(); return ( - {email && } - {phoneNumber && } - {contactManager && } + {emails?.length ? : null} + {phones?.length ? : null} + {contactManager && } - {ts && ( + {createdAt && ( - {formatDate(ts)} + {formatDate(createdAt)} )} {customFieldEntries.length > 0 && } diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx index 81764d9e6000..c6fcc9bf9546 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsEntry.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useIsCallReady } from '../../../../../contexts/CallContext'; import useClipboardWithToast from '../../../../../hooks/useClipboardWithToast'; +import { parseOutboundPhoneNumber } from '../../../../../lib/voip/parseOutboundPhoneNumber'; import ContactInfoCallButton from './ContactInfoCallButton'; type ContactInfoDetailsEntryProps = { @@ -12,27 +13,22 @@ type ContactInfoDetailsEntryProps = { value: string; }; -const ContactInfoDetailsEntry = ({ type, label, value }: ContactInfoDetailsEntryProps) => { +const ContactInfoDetailsEntry = ({ type, value }: ContactInfoDetailsEntryProps) => { const t = useTranslation(); const { copy } = useClipboardWithToast(value); const isCallReady = useIsCallReady(); return ( - - - {label} - - - - - - {value} - - - {isCallReady && type === 'phone' && } - copy()} tiny title={t('Copy')} icon='copy' /> - + + + + + {type === 'phone' ? parseOutboundPhoneNumber(value) : value} + + + {isCallReady && type === 'phone' && } + copy()} tiny title={t('Copy')} icon='copy' /> diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx new file mode 100644 index 000000000000..82201409f7a8 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactInfoDetailsGroup.tsx @@ -0,0 +1,25 @@ +import { Box } from '@rocket.chat/fuselage'; +import React from 'react'; + +import ContactInfoDetailsEntry from './ContactInfoDetailsEntry'; + +type ContactInfoDetailsGroupProps = { + type: 'phone' | 'email'; + label: string; + values: string[]; +}; + +const ContactInfoDetailsGroup = ({ type, label, values }: ContactInfoDetailsGroupProps) => { + return ( + + + {label} + + {values.map((value, index) => ( + + ))} + + ); +}; + +export default ContactInfoDetailsGroup; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactManagerInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactManagerInfo.tsx index ff74f5add91c..f8a58fec5579 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactManagerInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoDetails/ContactManagerInfo.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { UserStatus } from '../../../../../components/UserStatus'; -type ContactManagerInfoProps = { username: string }; +type ContactManagerInfoProps = { userId: string }; -const ContactManagerInfo = ({ username }: ContactManagerInfoProps) => { +const ContactManagerInfo = ({ userId }: ContactManagerInfoProps) => { const t = useTranslation(); const getContactManagerByUsername = useEndpoint('GET', '/v1/users.info'); - const { data, isLoading } = useQuery(['getContactManagerByUsername', username], async () => getContactManagerByUsername({ username })); + const { data, isLoading } = useQuery(['getContactManagerByUserId', userId], async () => getContactManagerByUsername({ userId })); if (isLoading) { return null; @@ -22,7 +22,7 @@ const ContactManagerInfo = ({ username }: ContactManagerInfoProps) => { {t('Contact_Manager')} - + {data?.user.username && } diff --git a/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx index ac3d45edb8ed..48f26d317397 100644 --- a/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx +++ b/apps/meteor/client/views/omnichannel/directory/ContactContextualBar.tsx @@ -19,7 +19,7 @@ const ContactContextualBar = () => { }; if (context === 'new') { - return ; + return ; } if (context === 'edit') { diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx index 4f8da207fa4f..1747400c30e2 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx @@ -2,7 +2,6 @@ import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitl import { useDebouncedState, useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; import { hashQueryKey } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import FilterByText from '../../../../components/FilterByText'; @@ -24,19 +23,19 @@ import { parseOutboundPhoneNumber } from '../../../../lib/voip/parseOutboundPhon import { CallDialpadButton } from '../components/CallDialpadButton'; import { useCurrentContacts } from './hooks/useCurrentContacts'; -function ContactTable(): ReactElement { +function ContactTable() { + const t = useTranslation(); + const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); - const { sortBy, sortDirection, setSort } = useSort<'username' | 'phone' | 'name' | 'visitorEmails.address' | 'lastChat.ts'>('username'); + const { sortBy, sortDirection, setSort } = useSort<'name' | 'phone' | 'visitorEmails.address' | 'lastChat.ts'>('name'); const isCallReady = useIsCallReady(); const [term, setTerm] = useDebouncedState('', 500); - const t = useTranslation(); - const query = useDebouncedValue( useMemo( () => ({ - term, + searchText: term, sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, ...(itemsPerPage && { count: itemsPerPage }), ...(current && { offset: current }), @@ -72,9 +71,6 @@ function ContactTable(): ReactElement { const headers = ( <> - - {t('Username')} - {t('Name')} @@ -105,7 +101,7 @@ function ContactTable(): ReactElement { return ( <> - {((isSuccess && data?.visitors.length > 0) || queryHasChanged) && ( + {((isSuccess && data?.contacts.length > 0) || queryHasChanged) && (