feat: Outlook Calendar Integration (#27922)

This commit is contained in:
Douglas Fabris 2023-07-04 11:40:48 -03:00 committed by GitHub
parent 3e2d70087d
commit c10137e015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 2385 additions and 46 deletions

View File

@ -20,6 +20,7 @@
"typescript.tsdk": "./node_modules/typescript/lib",
"cSpell.words": [
"autotranslate",
"Contextualbar",
"fname",
"Gazzodown",
"katex",

View File

@ -7,6 +7,7 @@ import './helpers/isUserFromParams';
import './helpers/parseJsonQuery';
import './default/info';
import './v1/assets';
import './v1/calendar';
import './v1/channels';
import './v1/chat';
import './v1/cloud';

View File

@ -0,0 +1,139 @@
import {
isCalendarEventListProps,
isCalendarEventCreateProps,
isCalendarEventImportProps,
isCalendarEventInfoProps,
isCalendarEventUpdateProps,
isCalendarEventDeleteProps,
} from '@rocket.chat/rest-typings';
import { Calendar } from '@rocket.chat/core-services';
import { API } from '../api';
API.v1.addRoute(
'calendar-events.list',
{ authRequired: true, validateParams: isCalendarEventListProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } },
{
async get() {
const { userId } = this;
const { date } = this.queryParams;
const data = await Calendar.list(userId, new Date(date));
return API.v1.success({ data });
},
},
);
API.v1.addRoute(
'calendar-events.info',
{ authRequired: true, validateParams: isCalendarEventInfoProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } },
{
async get() {
const { userId } = this;
const { id } = this.queryParams;
const event = await Calendar.get(id);
if (!event || event.uid !== userId) {
return API.v1.failure();
}
return API.v1.success({ event });
},
},
);
API.v1.addRoute(
'calendar-events.create',
{ authRequired: true, validateParams: isCalendarEventCreateProps },
{
async post() {
const { userId: uid } = this;
const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;
const id = await Calendar.create({
uid,
startTime: new Date(startTime),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});
return API.v1.success({ id });
},
},
);
API.v1.addRoute(
'calendar-events.import',
{ authRequired: true, validateParams: isCalendarEventImportProps },
{
async post() {
const { userId: uid } = this;
const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;
const id = await Calendar.import({
uid,
startTime: new Date(startTime),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});
return API.v1.success({ id });
},
},
);
API.v1.addRoute(
'calendar-events.update',
{ authRequired: true, validateParams: isCalendarEventUpdateProps },
{
async post() {
const { userId } = this;
const { eventId, startTime, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;
const event = await Calendar.get(eventId);
if (!event || event.uid !== userId) {
throw new Error('invalid-calendar-event');
}
await Calendar.update(eventId, {
startTime: new Date(startTime),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});
return API.v1.success();
},
},
);
API.v1.addRoute(
'calendar-events.delete',
{ authRequired: true, validateParams: isCalendarEventDeleteProps },
{
async post() {
const { userId } = this;
const { eventId } = this.bodyParams;
const event = await Calendar.get(eventId);
if (!event || event.uid !== userId) {
throw new Error('invalid-calendar-event');
}
await Calendar.delete(eventId);
return API.v1.success();
},
},
);

View File

@ -98,7 +98,7 @@ const GenericModal: FC<GenericModalProps> = ({
{confirmText ?? t('Ok')}
</Button>
)}
{!wrapperFunction && (
{!wrapperFunction && onConfirm && (
<Button {...getButtonProps(variant)} onClick={onConfirm} disabled={confirmDisabled}>
{confirmText ?? t('Ok')}
</Button>

View File

@ -0,0 +1,25 @@
import { Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import React from 'react';
import GenericModal from './GenericModal';
const GenericModalSkeleton = ({ onClose, ...props }: ComponentProps<typeof GenericModal>) => {
const t = useTranslation();
return (
<GenericModal
{...props}
variant='warning'
onClose={onClose}
title={<Skeleton width='50%' />}
confirmText={t('Cancel')}
onConfirm={onClose}
>
<Skeleton width='full' />
</GenericModal>
);
};
export default GenericModalSkeleton;

View File

@ -0,0 +1,2 @@
export * from './GenericModal';
export { default } from './GenericModal';

View File

@ -3,7 +3,7 @@ import { useUserPreference, useTranslation, useEndpoint } from '@rocket.chat/ui-
import type { FC, ReactElement, ComponentType } from 'react';
import React, { useState } from 'react';
import type { DontAskAgainList } from '../hooks/useDontAskAgain';
import type { DontAskAgainList } from '../../hooks/useDontAskAgain';
type DoNotAskAgainProps = {
onConfirm: (...args: any) => Promise<void> | void;

View File

@ -0,0 +1,14 @@
type OutlookEventsResponse = { status: 'success' | 'canceled' };
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Window {
RocketChatDesktop:
| {
openInternalVideoChatWindow?: (url: string, options: undefined) => void;
getOutlookEvents?: (date: Date) => Promise<OutlookEventsResponse>;
setOutlookExchangeUrl?: (url: string, userId: string) => Promise<void>;
hasOutlookCredentials?: () => Promise<boolean>;
clearOutlookCredentials?: () => void;
}
| undefined;
}

View File

@ -13,3 +13,4 @@ import './views/admin';
import './views/marketplace';
import './views/account';
import './views/teams';
import './views/outlookCalendar';

View File

@ -8,11 +8,11 @@ import { VideoConfContext } from '../contexts/VideoConfContext';
import type { DirectCallParams, ProviderCapabilities, CallPreferences } from '../lib/VideoConfManager';
import { VideoConfManager } from '../lib/VideoConfManager';
import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups';
import { useVideoOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import { useVideoConfOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
const VideoConfContextProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [outgoing, setOutgoing] = useState<VideoConfPopupPayload | undefined>();
const handleOpenCall = useVideoOpenCall();
const handleOpenCall = useVideoConfOpenCall();
useEffect(
() =>

View File

@ -1,17 +1,22 @@
import type { AtLeast, ISubscription, IUser } from '@rocket.chat/core-typings';
import type { AtLeast, ISubscription, IUser, ICalendarNotification } from '@rocket.chat/core-typings';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { lazy } from 'react';
import { CachedChatSubscription } from '../../../app/models/client';
import { Notifications } from '../../../app/notifications/client';
import { settings } from '../../../app/settings/client';
import { readMessage } from '../../../app/ui-utils/client';
import { KonchatNotification } from '../../../app/ui/client/lib/KonchatNotification';
import { getUserPreference } from '../../../app/utils/client';
import { RoomManager } from '../../lib/RoomManager';
import { imperativeModal } from '../../lib/imperativeModal';
import { fireGlobalEvent } from '../../lib/utils/fireGlobalEvent';
import { isLayoutEmbedded } from '../../lib/utils/isLayoutEmbedded';
const OutlookCalendarEventModal = lazy(() => import('../../views/outlookCalendar/OutlookCalendarEventModal'));
const notifyNewRoom = async (sub: AtLeast<ISubscription, 'rid'>): Promise<void> => {
const user = Meteor.user() as IUser | null;
if (!user || user.status === 'busy') {
@ -41,11 +46,42 @@ function notifyNewMessageAudio(rid?: string): void {
}
Meteor.startup(() => {
const notifyUserCalendar = async function (notification: ICalendarNotification): Promise<void> {
const user = Meteor.user() as IUser | null;
if (!user || user.status === 'busy') {
return;
}
const requireInteraction = getUserPreference<boolean>(Meteor.userId(), 'desktopNotificationRequireInteraction');
const n = new Notification(notification.title, {
body: notification.text,
tag: notification.payload._id,
silent: true,
requireInteraction,
} as NotificationOptions);
n.onclick = function () {
this.close();
window.focus();
imperativeModal.open({
component: OutlookCalendarEventModal,
props: { id: notification.payload._id, onClose: imperativeModal.close, onCancel: imperativeModal.close },
});
};
};
Tracker.autorun(() => {
if (!Meteor.userId() || !settings.get('Outlook_Calendar_Enabled')) {
return Notifications.unUser('calendar');
}
Notifications.onUser('calendar', notifyUserCalendar);
});
Tracker.autorun(() => {
if (!Meteor.userId()) {
return;
}
Notifications.onUser('notification', (notification) => {
const openedRoomId = ['channel', 'group', 'direct'].includes(FlowRouter.getRouteName()) ? RoomManager.opened : undefined;

View File

@ -46,6 +46,7 @@ type CurrentData = {
muteFocusedConversations: boolean;
receiveLoginDetectionEmail: boolean;
dontAskAgainList: [action: string, label: string][];
notifyCalendarEvents: boolean;
};
export type FormSectionProps = {

View File

@ -30,6 +30,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const userMobileNotifications = useUserPreference('pushNotifications');
const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap;
const userReceiveLoginDetectionEmail = useUserPreference('receiveLoginDetectionEmail');
const userNotifyCalendarEvents = useUserPreference('notifyCalendarEvents');
const defaultDesktopNotifications = useSetting(
'Accounts_Default_User_Preferences_desktopNotifications',
@ -42,6 +43,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails');
const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference');
const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference;
const showCalendarPreference = useSetting('Outlook_Calendar_Enabled');
const { values, handlers, commit } = useForm(
{
@ -50,6 +52,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
pushNotifications: userMobileNotifications,
emailNotificationMode: userEmailNotificationMode,
receiveLoginDetectionEmail: userReceiveLoginDetectionEmail,
notifyCalendarEvents: userNotifyCalendarEvents,
},
onChange,
);
@ -60,12 +63,14 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
pushNotifications,
emailNotificationMode,
receiveLoginDetectionEmail,
notifyCalendarEvents,
} = values as {
desktopNotificationRequireInteraction: boolean;
desktopNotifications: string;
pushNotifications: string;
emailNotificationMode: string;
receiveLoginDetectionEmail: boolean;
notifyCalendarEvents: boolean;
};
const {
@ -74,6 +79,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
handlePushNotifications,
handleEmailNotificationMode,
handleReceiveLoginDetectionEmail,
handleNotifyCalendarEvents,
} = handlers;
useEffect(() => setNotificationsPermission(window.Notification && Notification.permission), []);
@ -186,6 +192,17 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
<Field.Hint>{t('Receive_Login_Detection_Emails_Description')}</Field.Hint>
</Field>
)}
{showCalendarPreference && (
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Notify_Calendar_Events')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={notifyCalendarEvents} onChange={handleNotifyCalendarEvents} />
</Field.Row>
</Box>
</Field>
)}
</FieldGroup>
</Accordion.Item>
);

View File

@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
import React, { useEffect } from 'react';
import { useUserDisplayName } from '../../hooks/useUserDisplayName';
import { useVideoOpenCall } from '../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import { useVideoConfOpenCall } from '../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import PageLoading from '../root/PageLoading';
import ConferencePageError from './ConferencePageError';
@ -19,7 +19,7 @@ const ConferencePage = (): ReactElement => {
const user = useUser();
const defaultRoute = useRoute('/');
const setModal = useSetModal();
const handleOpenCall = useVideoOpenCall();
const handleOpenCall = useVideoConfOpenCall();
const userDisplayName = useUserDisplayName({ name: user?.name, username: user?.username });
const { callUrlParam } = getQueryParams();

View File

@ -1,23 +1,24 @@
import type { Dispatch, SetStateAction, ReactNode } from 'react';
import { createElement, useEffect } from 'react';
import React, { Suspense, createElement, useEffect } from 'react';
import { imperativeModal } from '../../lib/imperativeModal';
export const useImperativeModal = (setModal: Dispatch<SetStateAction<ReactNode>>): void => {
useEffect(() => {
const unsub = imperativeModal.on('update', (descriptor) => {
return imperativeModal.on('update', (descriptor) => {
if (descriptor === null) {
return setModal(null);
}
if ('component' in descriptor) {
setModal(
createElement(descriptor.component, {
key: Math.random(),
...descriptor.props,
}),
<Suspense fallback={<div />}>
{createElement(descriptor.component, {
key: Math.random(),
...descriptor.props,
})}
</Suspense>,
);
}
});
return unsub;
}, [setModal]);
};

View File

@ -0,0 +1,53 @@
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import React from 'react';
import GenericModal from '../../components/GenericModal';
import GenericModalSkeleton from '../../components/GenericModal/GenericModalSkeleton';
import OutlookEventItemContent from './OutlookEventsList/OutlookEventItemContent';
import { useOutlookOpenCall } from './hooks/useOutlookOpenCall';
type OutlookCalendarEventModalProps = ComponentProps<typeof GenericModal> & {
id?: string;
subject?: string;
meetingUrl?: string;
description?: string;
};
const OutlookCalendarEventModal = ({ id, subject, meetingUrl, description, ...props }: OutlookCalendarEventModalProps) => {
const t = useTranslation();
const calendarInfoEndpoint = useEndpoint('GET', '/v1/calendar-events.info');
const { data, isLoading } = useQuery(['calendar-events.info', id], async () => {
if (!id) {
const event = { event: { subject, meetingUrl, description } };
return event;
}
return calendarInfoEndpoint({ id });
});
const openCall = useOutlookOpenCall(data?.event.meetingUrl);
if (isLoading) {
return <GenericModalSkeleton {...props} />;
}
return (
<GenericModal
{...props}
tagline={t('Outlook_calendar_event')}
icon={null}
variant='warning'
title={data?.event.subject}
cancelText={t('Close')}
confirmText={t('Join_call')}
onConfirm={openCall}
>
{data?.event.description ? <OutlookEventItemContent html={data?.event.description} /> : t('No_content_was_provided')}
</GenericModal>
);
};
export default OutlookCalendarEventModal;

View File

@ -0,0 +1,67 @@
import type { ICalendarEvent, Serialized } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, Button, Palette } from '@rocket.chat/fuselage';
import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
import OutlookCalendarEventModal from '../OutlookCalendarEventModal';
import { useOutlookOpenCall } from '../hooks/useOutlookOpenCall';
const OutlookEventItem = ({ subject, description, startTime, meetingUrl }: Serialized<ICalendarEvent>) => {
const t = useTranslation();
const setModal = useSetModal();
const formatDateAndTime = useFormatDateAndTime();
const openCall = useOutlookOpenCall(meetingUrl);
const hovered = css`
&:hover {
cursor: pointer;
}
&:hover,
&:focus {
background: ${Palette.surface['surface-hover']};
}
`;
const handleOpenEvent = () => {
setModal(
<OutlookCalendarEventModal
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
subject={subject}
meetingUrl={meetingUrl}
description={description}
/>,
);
};
return (
<Box
className={hovered}
borderBlockEndWidth={1}
borderBlockEndColor='stroke-extra-light'
borderBlockEndStyle='solid'
pi='x24'
pb='x16'
display='flex'
justifyContent='space-between'
onClick={handleOpenEvent}
>
<Box>
<Box fontScale='h4'>{subject}</Box>
<Box fontScale='c1'>{formatDateAndTime(startTime)}</Box>
</Box>
<Box>
{meetingUrl && (
<Button onClick={openCall} small>
{t('Join')}
</Button>
)}
</Box>
</Box>
);
};
export default OutlookEventItem;

View File

@ -0,0 +1,25 @@
import { Box } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import React from 'react';
type SanitizeProps = {
html: string;
options?: {
[key: string]: string;
};
};
const OutlookEventItemContent = ({ html, options }: SanitizeProps) => {
const defaultOptions = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br'],
ALLOWED_ATTR: ['href'],
};
const sanitize = (dirtyHTML: SanitizeProps['html'], options: SanitizeProps['options']) => ({
__html: DOMPurify.sanitize(dirtyHTML, { ...defaultOptions, ...options }).toString(),
});
return <Box wordBreak='break-word' color='default' dangerouslySetInnerHTML={sanitize(html, options)} />;
};
export default OutlookEventItemContent;

View File

@ -0,0 +1,139 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, ButtonGroup, Button, Icon } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import { Virtuoso } from 'react-virtuoso';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarFooter,
ContextualbarSkeleton,
} from '../../../components/Contextualbar';
import ScrollableContentWrapper from '../../../components/ScrollableContentWrapper';
import { getErrorMessage } from '../../../lib/errorHandling';
import { useOutlookAuthentication } from '../hooks/useOutlookAuthentication';
import { useMutationOutlookCalendarSync, useOutlookCalendarListForToday } from '../hooks/useOutlookCalendarList';
import { NotOnDesktopError } from '../lib/NotOnDesktopError';
import OutlookEventItem from './OutlookEventItem';
type OutlookEventsListProps = {
onClose: () => void;
changeRoute: () => void;
};
const OutlookEventsList = ({ onClose, changeRoute }: OutlookEventsListProps): ReactElement => {
const t = useTranslation();
const outlookUrl = useSetting<string>('Outlook_Calendar_Outlook_Url');
const { authEnabled, isError, error } = useOutlookAuthentication();
const hasOutlookMethods = !(isError && error instanceof NotOnDesktopError);
const syncOutlookCalendar = useMutationOutlookCalendarSync();
const calendarListResult = useOutlookCalendarListForToday();
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver<HTMLElement>({
debounceDelay: 200,
});
if (calendarListResult.isLoading) {
return <ContextualbarSkeleton />;
}
const calendarEvents = calendarListResult.data;
const total = calendarEvents?.length || 0;
return (
<>
<ContextualbarHeader>
<ContextualbarIcon name='calendar' />
<ContextualbarTitle>{t('Outlook_calendar')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
{hasOutlookMethods && !authEnabled && total === 0 && (
<>
<ContextualbarContent paddingInline={0} ref={ref} color='default'>
<Box display='flex' flexDirection='column' justifyContent='center' height='100%'>
<States>
<StatesIcon name='user' />
<StatesTitle>{t('Log_in_to_sync')}</StatesTitle>
</States>
</Box>
</ContextualbarContent>
<ContextualbarFooter>
<ButtonGroup mbs='x8' stretch>
<Button primary disabled={syncOutlookCalendar.isLoading} onClick={() => syncOutlookCalendar.mutate()}>
{syncOutlookCalendar.isLoading ? t('Please_wait') : t('Login')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
)}
{(authEnabled || !hasOutlookMethods) && (
<>
<ContextualbarContent paddingInline={0} ref={ref} color='default'>
{(total === 0 || calendarListResult.isError) && (
<Box display='flex' flexDirection='column' justifyContent='center' height='100%'>
{calendarListResult.isError && (
<States>
<StatesIcon name='circle-exclamation' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesSubtitle>{getErrorMessage(calendarListResult.error)}</StatesSubtitle>
</States>
)}
{!calendarListResult.isError && total === 0 && (
<States>
<StatesIcon name='calendar' />
<StatesTitle>{t('No_history')}</StatesTitle>
</States>
)}
</Box>
)}
{calendarListResult.isSuccess && calendarListResult.data.length > 0 && (
<Box flexGrow={1} flexShrink={1} overflow='hidden' display='flex'>
<Virtuoso
style={{
height: blockSize,
width: inlineSize,
}}
totalCount={total}
overscan={25}
data={calendarEvents}
components={{ Scroller: ScrollableContentWrapper }}
itemContent={(_index, calendarData): ReactElement => <OutlookEventItem {...calendarData} />}
/>
</Box>
)}
</ContextualbarContent>
<ContextualbarFooter>
<ButtonGroup stretch>
{authEnabled && <Button onClick={changeRoute}>{t('Calendar_settings')}</Button>}
{outlookUrl && (
<Button onClick={() => window.open(outlookUrl, '_blank')}>
<Icon mie='x4' name='new-window' />
<Box is='span'>{t('Open_Outlook')}</Box>
</Button>
)}
</ButtonGroup>
{hasOutlookMethods && (
<ButtonGroup mbs='x8' stretch>
<Button primary disabled={syncOutlookCalendar.isLoading} onClick={() => syncOutlookCalendar.mutate()}>
{syncOutlookCalendar.isLoading ? t('Sync_in_progress') : t('Sync')}
</Button>
</ButtonGroup>
)}
</ContextualbarFooter>
</>
)}
</>
);
};
export default OutlookEventsList;

View File

@ -0,0 +1 @@
export { default } from './OutlookEventsList';

View File

@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { useTabBarClose } from '../room/contexts/ToolboxContext';
import OutlookEventsList from './OutlookEventsList';
import OutlookSettingsList from './OutlookSettingsList';
type OutlookCalendarRoutes = 'list' | 'settings';
const CALENDAR_ROUTES: { [key: string]: OutlookCalendarRoutes } = {
LIST: 'list',
SETTINGS: 'settings',
};
const OutlookEventsRoute = () => {
const closeTabBar = useTabBarClose();
const [calendarRoute, setCalendarRoute] = useState<OutlookCalendarRoutes>('list');
if (calendarRoute === CALENDAR_ROUTES.SETTINGS) {
return <OutlookSettingsList onClose={closeTabBar} changeRoute={() => setCalendarRoute(CALENDAR_ROUTES.LIST)} />;
}
return <OutlookEventsList onClose={closeTabBar} changeRoute={() => setCalendarRoute(CALENDAR_ROUTES.SETTINGS)} />;
};
export default OutlookEventsRoute;

View File

@ -0,0 +1,58 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Button, Palette } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
type OutlookSettingItemProps = {
id: string;
title: string;
subTitle: string;
enabled: boolean;
handleEnable: (value: boolean) => void;
};
const OutlookSettingItem = ({ id, title, subTitle, enabled, handleEnable }: OutlookSettingItemProps) => {
const t = useTranslation();
const hovered = css`
&:hover,
&:focus {
background: ${Palette.surface['surface-hover']};
.rcx-message {
background: ${Palette.surface['surface-hover']};
}
}
`;
return (
<Box
borderBlockEndWidth={1}
borderBlockEndColor='stroke-extra-light'
borderBlockEndStyle='solid'
className={hovered}
pi='x24'
pb='x16'
display='flex'
justifyContent='space-between'
>
<Box mie='x8'>
<Box fontScale='h4'>{title}</Box>
<Box fontScale='p2'>{subTitle}</Box>
</Box>
<Box>
{id === 'authentication' && (
<Button small onClick={() => handleEnable(!enabled)}>
{t('Disable')}
</Button>
)}
{id !== 'authentication' && (
<Button primary={!enabled} small onClick={() => handleEnable(!enabled)}>
{enabled ? t('Disable') : t('Enable')}
</Button>
)}
</Box>
</Box>
);
};
export default OutlookSettingItem;

View File

@ -0,0 +1,87 @@
import { ButtonGroup, Button } from '@rocket.chat/fuselage';
import { useTranslation, useUserPreference, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useCallback } from 'react';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarFooter,
} from '../../../components/Contextualbar';
import { useOutlookAuthentication, useOutlookAuthenticationMutationLogout } from '../hooks/useOutlookAuthentication';
import OutlookSettingItem from './OutlookSettingItem';
type OutlookSettingsListProps = {
onClose: () => void;
changeRoute: () => void;
};
const OutlookSettingsList = ({ onClose, changeRoute }: OutlookSettingsListProps): ReactElement => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences');
const notifyCalendarEvents = useUserPreference('notifyCalendarEvents') as boolean;
const { authEnabled } = useOutlookAuthentication();
const handleDisableAuth = useOutlookAuthenticationMutationLogout();
const handleNotifyCalendarEvents = useCallback(
(value: boolean) => {
try {
saveUserPreferences({ data: { notifyCalendarEvents: value } });
dispatchToastMessage({ type: 'success', message: t('Preferences_saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[saveUserPreferences, dispatchToastMessage, t],
);
const calendarSettings = [
{
id: 'notification',
title: t('Event_notifications'),
subTitle: t('Event_notifications_description'),
enabled: notifyCalendarEvents,
handleEnable: handleNotifyCalendarEvents,
},
{
id: 'authentication',
title: t('Outlook_authentication'),
subTitle: t('Outlook_authentication_description'),
enabled: authEnabled,
handleEnable: () =>
handleDisableAuth.mutate(undefined, {
onSuccess: changeRoute,
}),
},
];
return (
<>
<ContextualbarHeader>
<ContextualbarIcon name='calendar' />
<ContextualbarTitle>{t('Outlook_calendar_settings')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarContent paddingInline={0} color='default'>
{calendarSettings.map((setting, index) => {
if (setting.id === 'authentication' && !setting.enabled) {
return;
}
return <OutlookSettingItem key={index} {...setting} />;
})}
</ContextualbarContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={changeRoute}>{t('Back_to_calendar')}</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default OutlookSettingsList;

View File

@ -0,0 +1 @@
export { default } from './OutlookSettingsList';

View File

@ -0,0 +1,59 @@
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { NotOnDesktopError } from '../lib/NotOnDesktopError';
export const useOutlookAuthentication = () => {
const {
data: authEnabled,
isError,
error,
} = useQuery(
['outlook', 'auth'],
async () => {
const desktopApp = window.RocketChatDesktop;
if (!desktopApp?.hasOutlookCredentials) {
throw new NotOnDesktopError();
}
return Boolean(await desktopApp?.hasOutlookCredentials?.()) || false;
},
{
onError: (error) => {
console.error(error);
},
},
);
return { authEnabled: Boolean(authEnabled), isError, error };
};
export const useOutlookAuthenticationMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await queryClient.invalidateQueries(['outlook', 'auth']);
},
});
};
export const useOutlookAuthenticationMutationLogout = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const mutation = useOutlookAuthenticationMutation();
return useMutation({
mutationFn: async () => {
const desktopApp = window.RocketChatDesktop;
if (!desktopApp?.clearOutlookCredentials) {
throw new NotOnDesktopError();
}
await desktopApp.clearOutlookCredentials();
return mutation.mutateAsync();
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Outlook_authentication_disabled') });
},
});
};

View File

@ -0,0 +1,53 @@
import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { syncOutlookEvents } from '../lib/syncOutlookEvents';
import { useOutlookAuthenticationMutation } from './useOutlookAuthentication';
export const useOutlookCalendarListForToday = () => {
return useOutlookCalendarList(new Date());
};
export const useOutlookCalendarList = (date: Date) => {
const calendarData = useEndpoint('GET', '/v1/calendar-events.list');
return useQuery(
['outlook', 'calendar', 'list'],
async () => {
const { data } = await calendarData({ date: date.toISOString() });
return data;
},
{
refetchOnWindowFocus: false,
},
);
};
export const useMutationOutlookCalendarSync = () => {
const t = useTranslation();
const queryClient = useQueryClient();
const checkOutlookCredentials = useOutlookAuthenticationMutation();
const dispatchToastMessage = useToastMessageDispatch();
const syncMutation = useMutation({
mutationFn: async () => {
await syncOutlookEvents();
await queryClient.invalidateQueries(['outlook', 'calendar', 'list']);
await checkOutlookCredentials.mutateAsync();
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Outlook_Sync_Success') });
},
onError: (error) => {
if (error instanceof Error && error.message === 'abort') {
return;
}
dispatchToastMessage({ type: 'error', message: t('Outlook_Sync_Failed') });
},
});
return syncMutation;
};

View File

@ -0,0 +1,18 @@
import { useUser } from '@rocket.chat/ui-contexts';
import { useUserDisplayName } from '../../../hooks/useUserDisplayName';
import { useVideoConfOpenCall } from '../../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
export const useOutlookOpenCall = (meetingUrl?: string) => {
const user = useUser();
const handleOpenCall = useVideoConfOpenCall();
const userDisplayName = useUserDisplayName({ name: user?.name, username: user?.username });
const namedMeetingUrl = `${meetingUrl}&name=${userDisplayName}`;
if (!meetingUrl) {
return;
}
return () => handleOpenCall(namedMeetingUrl);
};

View File

@ -0,0 +1 @@
import './tabBar';

View File

@ -0,0 +1,5 @@
export class NotOnDesktopError extends Error {
constructor() {
super('Not on desktop');
}
}

View File

@ -0,0 +1,15 @@
import { NotOnDesktopError } from './NotOnDesktopError';
export const syncOutlookEvents = async () => {
const date = new Date();
const desktopApp = window.RocketChatDesktop;
if (!desktopApp?.getOutlookEvents) {
throw new NotOnDesktopError();
}
const response = await desktopApp.getOutlookEvents(date);
if (response.status === 'canceled') {
throw new Error('abort');
}
};

View File

@ -0,0 +1,23 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { addAction } from '../room/lib/Toolbox';
addAction('outlookCalendar', () => {
const outlookCalendarEnabled = useSetting('Outlook_Calendar_Enabled');
return useMemo(
() =>
outlookCalendarEnabled
? {
groups: ['channel', 'group', 'team'],
id: 'outlookCalendar',
icon: 'calendar',
title: 'Outlook_calendar',
template: lazy(() => import('./OutlookEventsRoute')),
order: 999,
}
: null,
[outlookCalendarEnabled],
);
});

View File

@ -3,21 +3,14 @@ import React, { useCallback } from 'react';
import VideoConfBlockModal from '../VideoConfBlockModal';
type WindowMaybeDesktop = typeof window & {
RocketChatDesktop?: {
openInternalVideoChatWindow?: (url: string, options: undefined) => void;
};
};
export const useVideoOpenCall = () => {
export const useVideoConfOpenCall = () => {
const setModal = useSetModal();
const handleOpenCall = useCallback(
(callUrl: string) => {
const windowMaybeDesktop = window as WindowMaybeDesktop;
if (windowMaybeDesktop.RocketChatDesktop?.openInternalVideoChatWindow) {
windowMaybeDesktop.RocketChatDesktop.openInternalVideoChatWindow(callUrl, undefined);
} else {
const desktopApp = window.RocketChatDesktop;
if (!desktopApp?.openInternalVideoChatWindow) {
const open = () => window.open(callUrl);
const popup = open();
@ -26,7 +19,10 @@ export const useVideoOpenCall = () => {
}
setModal(<VideoConfBlockModal onClose={(): void => setModal(null)} onConfirm={open} />);
return;
}
desktopApp.openInternalVideoChatWindow(callUrl, undefined);
},
[setModal],
);

View File

@ -1,10 +1,8 @@
import type { IRoom, Serialized } from '@rocket.chat/core-typings';
import { Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { FC } from 'react';
import React, { useMemo } from 'react';
import GenericModal from '../../../components/GenericModal';
import GenericModalSkeleton from '../../../components/GenericModal/GenericModalSkeleton';
import { useEndpointData } from '../../../hooks/useEndpointData';
import { AsyncStatePhase } from '../../../lib/asyncState';
import BaseConvertToChannelModal from './BaseConvertToChannelModal';
@ -18,18 +16,12 @@ type ConvertToChannelModalProps = {
};
const ConvertToChannelModal: FC<ConvertToChannelModalProps> = ({ onClose, onCancel, onConfirm, teamId, userId }) => {
const t = useTranslation();
const { value, phase } = useEndpointData('/v1/teams.listRoomsOfUser', {
params: useMemo(() => ({ teamId, userId, canUserDelete: 'true' }), [teamId, userId]),
});
if (phase === AsyncStatePhase.LOADING) {
return (
<GenericModal variant='warning' onClose={onClose} title={<Skeleton width='50%' />} confirmText={t('Cancel')} onConfirm={onClose}>
<Skeleton width='full' />
</GenericModal>
);
return <GenericModalSkeleton onClose={onClose} />;
}
return <BaseConvertToChannelModal onClose={onClose} onCancel={onCancel} onConfirm={onConfirm} rooms={value?.rooms} />;

View File

@ -14,7 +14,8 @@ export type BundleFeature =
| 'oauth-enterprise'
| 'federation'
| 'videoconference-enterprise'
| 'message-read-receipt';
| 'message-read-receipt'
| 'outlook-calendar';
interface IBundle {
[key: string]: BundleFeature[];
@ -38,6 +39,7 @@ const bundles: IBundle = {
'federation',
'videoconference-enterprise',
'message-read-receipt',
'outlook-calendar',
],
pro: [],
};

View File

@ -1,4 +1,5 @@
import './ldap';
import './oauth';
import './outlookCalendar';
import './saml';
import './videoConference';

View File

@ -0,0 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { Calendar } from '@rocket.chat/core-services';
import { onLicense } from '../../app/license/server';
import { addSettings } from '../settings/outlookCalendar';
Meteor.startup(() =>
onLicense('outlook-calendar', async () => {
addSettings();
await Calendar.setupNextNotification();
}),
);

View File

@ -0,0 +1,41 @@
import { settingsRegistry } from '../../../app/settings/server';
export function addSettings(): void {
void settingsRegistry.addGroup('Outlook_Calendar', async function () {
await this.with(
{
enterprise: true,
modules: ['outlook-calendar'],
},
async function () {
await this.add('Outlook_Calendar_Enabled', false, {
type: 'boolean',
public: true,
invalidValue: false,
});
await this.add('Outlook_Calendar_Exchange_Url', '', {
type: 'string',
public: true,
invalidValue: '',
});
await this.add('Outlook_Calendar_Outlook_Url', '', {
type: 'string',
public: true,
invalidValue: '',
});
await this.add(
'Calendar_MeetingUrl_Regex',
'(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)',
{
type: 'string',
public: true,
invalidValue: '',
},
);
},
);
});
}

View File

@ -727,6 +727,7 @@
"Away": "Away",
"Back": "Back",
"Back_to_applications": "Back to applications",
"Back_to_calendar": "Back to calendar",
"Back_to_chat": "Back to chat",
"Back_to_imports": "Back to imports",
"Back_to_integration_detail": "Back to the integration detail",
@ -817,6 +818,9 @@
"By": "By",
"by": "by",
"cache_cleared": "Cache cleared",
"Calendar_MeetingUrl_Regex": "Meeting url Regular Expression",
"Calendar_MeetingUrl_Regex_Description": "Expression used to detect meeting URLs in event descriptions. The first matching group with a valid url will be used. HTML encoded urls will be decoded automatically.",
"Calendar_settings": "Calendar settings",
"Call": "Call",
"Call_again": "Call again",
"Call_back": "Call back",
@ -2072,6 +2076,8 @@
"Esc_to": "Esc to",
"Estimated_wait_time": "Estimated wait time",
"Estimated_wait_time_in_minutes": "Estimated wait time (time in minutes)",
"Event_notifications": "Event notifications",
"Event_notifications_description": "By disabling this setting youll prevent the app from notifying you of upcoming events.",
"Event_Trigger": "Event Trigger",
"Event_Trigger_Description": "Select which type of event will trigger this Outgoing WebHook Integration",
"every_5_minutes": "Once every 5 minutes",
@ -3156,6 +3162,7 @@
"Logged_Out_Banner_Text": "Your workspace admin ended your session on this device. Please log in again to continue.",
"Logged_out_of_other_clients_successfully": "Logged out of other clients successfully",
"Login": "Login",
"Log_in_to_sync": "Log in to sync",
"Login_Attempts": "Failed Login Attempts",
"Login_Detected": "Login detected",
"Logged_In_Via": "Logged in via",
@ -3612,6 +3619,7 @@
"No_Canned_Responses_Yet-description": "Use canned responses to provide quick and consistent answers to frequently asked questions.",
"No_channels_in_team": "No Channels on this Team",
"No_channels_yet": "You aren't part of any channels yet",
"No_content_was_provided": "No content was provided",
"No_data_found": "No data found",
"No_direct_messages_yet": "No Direct Messages.",
"No_Discussions_found": "No discussions found",
@ -3679,6 +3687,7 @@
"Notifications_Sound_Volume": "Notifications sound volume",
"Notify_active_in_this_room": "Notify active users in this room",
"Notify_all_in_this_room": "Notify all in this room",
"Notify_Calendar_Events": "Notify calendar events",
"Now_Its_Visible_For_Everyone": "Now it's visible for everyone",
"Now_Its_Visible_Only_For_Admins": "Now it's visible only for admins",
"NPS_survey_enabled": "Enable NPS Survey",
@ -3779,6 +3788,7 @@
"Open_directory": "Open directory",
"Open_Livechats": "Chats in Progress",
"Open_menu": "Open_menu",
"Open_Outlook": "Open Outlook",
"Open_settings": "Open settings",
"Open-source_conference_call_solution": "Open-source conference call solution.",
"Open_thread": "Open Thread",
@ -3829,7 +3839,22 @@
"Outgoing": "Outgoing",
"Outgoing_WebHook": "Outgoing WebHook",
"Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.",
"Outlook_authentication": "Outlook authentication",
"Outlook_authentication_disabled": "Outlook authentication disabled",
"Outlook_authentication_description": "Disable this to clear any outlook credentials stored in this machine.",
"Outlook_calendar": "Outlook calendar",
"Outlook_calendar_event": "Outlook calendar event",
"Outlook_calendar_settings": "Outlook calendar settings",
"Outlook_Calendar": "Outlook Calendar",
"Outlook_Calendar_Enabled": "Enabled",
"Outlook_Calendar_Exchange_Url": "Exchange URL",
"Outlook_Calendar_Exchange_Url_Description": "Host URL for the EWS api.",
"Outlook_Calendar_Outlook_Url": "Outlook URL",
"Outlook_Calendar_Outlook_Url_Description": "URL used to launch the Outlook web app.",
"Output_format": "Output format",
"Outlook_Sync_Failed": "Failed to load outlook events.",
"Outlook_Sync_Success": "Outlook events synchronized.",
"Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Override URL to which files are uploaded. This url also used for downloads unless a CDN is given",
"Override_Destination_Channel": "Allow to overwrite destination channel in the body parameters",
"Owner": "Owner",
@ -4100,6 +4125,7 @@
"Reload": "Reload",
"Reload_page": "Reload Page",
"Reload_Pages": "Reload Pages",
"Remember_my_credentials": "Remember my credentials",
"Remove": "Remove",
"Remove_Admin": "Remove Admin",
"Remove_Association": "Remove Association",

View File

@ -36,6 +36,7 @@ type UserPreferences = {
dontAskAgainList: { action: string; label: string }[];
themeAppearence: 'auto' | 'light' | 'dark';
receiveLoginDetectionEmail: boolean;
notifyCalendarEvents: boolean;
};
declare module '@rocket.chat/ui-contexts' {
@ -78,6 +79,7 @@ export const saveUserPreferences = async (settings: Partial<UserPreferences>, us
muteFocusedConversations: Match.Optional(Boolean),
omnichannelTranscriptEmail: Match.Optional(Boolean),
omnichannelTranscriptPDF: Match.Optional(Boolean),
notifyCalendarEvents: Match.Optional(Boolean),
};
check(settings, Match.ObjectIncluding(keys));
const user = await Users.findOneById(userId);

View File

@ -0,0 +1,6 @@
import { registerModel } from '@rocket.chat/models';
import { db } from '../database/utils';
import { CalendarEventRaw } from './raw/CalendarEvent';
registerModel('ICalendarEventModel', new CalendarEventRaw(db));

View File

@ -0,0 +1,124 @@
import type { FindCursor, IndexDescription, Collection, Db, UpdateResult } from 'mongodb';
import type { ICalendarEvent, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ICalendarEventModel } from '@rocket.chat/model-typings';
import { BaseRaw } from './BaseRaw';
export class CalendarEventRaw extends BaseRaw<ICalendarEvent> implements ICalendarEventModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ICalendarEvent>>) {
super(db, 'calendar_event', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{
key: { startTime: -1, uid: 1, externalId: 1 },
},
{
key: { reminderTime: -1, notificationSent: 1 },
},
];
}
public async findOneByExternalIdAndUserId(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null> {
return this.findOne({
externalId,
uid,
});
}
public findByUserIdAndDate(uid: IUser['_id'], date: Date): FindCursor<ICalendarEvent> {
const startTime = new Date(date.toISOString());
startTime.setHours(0, 0, 0, 0);
const finalTime = new Date(date.valueOf());
finalTime.setDate(finalTime.getDate() + 1);
return this.find(
{
uid,
startTime: { $gte: startTime, $lt: finalTime },
},
{
sort: { startTime: 1 },
},
);
}
public async updateEvent(
eventId: ICalendarEvent['_id'],
{ subject, description, startTime, meetingUrl, reminderMinutesBeforeStart, reminderTime }: Partial<ICalendarEvent>,
): Promise<UpdateResult> {
return this.updateOne(
{ _id: eventId },
{
$set: {
...(subject !== undefined ? { subject } : {}),
...(description !== undefined ? { description } : {}),
...(startTime ? { startTime } : {}),
...(meetingUrl !== undefined ? { meetingUrl } : {}),
...(reminderMinutesBeforeStart ? { reminderMinutesBeforeStart } : {}),
...(reminderTime ? { reminderTime } : {}),
},
},
);
}
public async findNextNotificationDate(): Promise<Date | null> {
const nextEvent = await this.findOne<Pick<ICalendarEvent, 'reminderTime'>>(
{
reminderTime: {
$gt: new Date(),
},
notificationSent: false,
},
{
sort: {
reminderTime: 1,
},
projection: {
reminderTime: 1,
},
},
);
return nextEvent?.reminderTime || null;
}
public findEventsToNotify(notificationTime: Date, minutes: number): FindCursor<ICalendarEvent> {
// Find all the events between notificationTime and +minutes that have not been notified yet
const maxDate = new Date(notificationTime.toISOString());
maxDate.setMinutes(maxDate.getMinutes() + minutes);
return this.find(
{
reminderTime: {
$gte: notificationTime,
$lt: maxDate,
},
notificationSent: false,
},
{
sort: {
reminderTime: 1,
},
},
);
}
public async flagNotificationSent(eventId: ICalendarEvent['_id']): Promise<UpdateResult> {
return this.updateOne(
{
_id: eventId,
},
{
$set: {
notificationSent: true,
},
},
);
}
}

View File

@ -5,6 +5,7 @@ import './AppsPersistence';
import './Avatars';
import './Banners';
import './BannersDismiss';
import './CalendarEvent';
import './CredentialTokens';
import './CustomSounds';
import './CustomUserStatus';

View File

@ -391,6 +391,10 @@ export class ListenersModule {
notifications.notifyAllInThisInstance('updateCustomSound', data);
});
service.onEvent('notify.calendar', (uid, data): void => {
notifications.notifyUserInThisInstance(uid, 'calendar', data);
});
service.onEvent('connector.statuschanged', (enabled): void => {
notifications.notifyLoggedInThisInstance('voip.statuschanged', enabled);
});

View File

@ -0,0 +1,233 @@
import type { UpdateResult, DeleteResult } from 'mongodb';
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CalendarEvent } from '@rocket.chat/models';
import type { ICalendarService } from '@rocket.chat/core-services';
import { ServiceClassInternal, api } from '@rocket.chat/core-services';
import { cronJobs } from '@rocket.chat/cron';
import { settings } from '../../../app/settings/server';
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
import { Logger } from '../../lib/logger/Logger';
const logger = new Logger('Calendar');
const defaultMinutesForNotifications = 5;
export class CalendarService extends ServiceClassInternal implements ICalendarService {
protected name = 'calendar';
public async create(data: Omit<InsertionModel<ICalendarEvent>, 'reminderTime' | 'notificationSent'>): Promise<ICalendarEvent['_id']> {
const { uid, startTime, subject, description, reminderMinutesBeforeStart, meetingUrl } = data;
const minutes = reminderMinutesBeforeStart ?? defaultMinutesForNotifications;
const reminderTime = minutes ? this.getShiftedTime(startTime, -minutes) : undefined;
const insertData: InsertionModel<ICalendarEvent> = {
uid,
startTime,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart: minutes,
reminderTime,
notificationSent: false,
};
const insertResult = await CalendarEvent.insertOne(insertData);
await this.setupNextNotification();
return insertResult.insertedId;
}
public async import(data: Omit<InsertionModel<ICalendarEvent>, 'notificationSent'>): Promise<ICalendarEvent['_id']> {
const { externalId } = data;
if (!externalId) {
return this.create(data);
}
const { uid, startTime, subject, description, reminderMinutesBeforeStart } = data;
const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description);
const reminderTime = reminderMinutesBeforeStart ? this.getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
const updateData: Omit<InsertionModel<ICalendarEvent>, 'uid' | 'notificationSent'> = {
startTime,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
externalId,
};
const event = await this.findImportedEvent(externalId, uid);
if (!event) {
const insertResult = await CalendarEvent.insertOne({
uid,
notificationSent: false,
...updateData,
});
await this.setupNextNotification();
return insertResult.insertedId;
}
const updateResult = await CalendarEvent.updateEvent(event._id, updateData);
if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();
}
return event._id;
}
public async get(eventId: ICalendarEvent['_id']): Promise<ICalendarEvent | null> {
return CalendarEvent.findOne({ _id: eventId });
}
public async list(uid: IUser['_id'], date: Date): Promise<ICalendarEvent[]> {
return CalendarEvent.findByUserIdAndDate(uid, date).toArray();
}
public async update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult> {
const { startTime, subject, description, reminderMinutesBeforeStart } = data;
const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description || '');
const reminderTime = reminderMinutesBeforeStart && startTime ? this.getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
const updateData: Partial<ICalendarEvent> = {
startTime,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
};
const updateResult = await CalendarEvent.updateEvent(eventId, updateData);
if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();
}
return updateResult;
}
public async delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult> {
return CalendarEvent.deleteOne({
_id: eventId,
});
}
public async setupNextNotification(): Promise<void> {
return this.doSetupNextNotification(false);
}
private async doSetupNextNotification(isRecursive: boolean): Promise<void> {
const date = await CalendarEvent.findNextNotificationDate();
if (!date) {
if (await cronJobs.has('calendar-reminders')) {
await cronJobs.remove('calendar-reminders');
}
return;
}
date.setSeconds(0);
if (!isRecursive && date.valueOf() < Date.now()) {
return this.sendCurrentNotifications(date);
}
await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date));
}
public async sendCurrentNotifications(date: Date): Promise<void> {
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();
for await (const event of events) {
await this.sendEventNotification(event);
await CalendarEvent.flagNotificationSent(event._id);
}
await this.doSetupNextNotification(true);
}
public async sendEventNotification(event: ICalendarEvent): Promise<void> {
if (!(await getUserPreference(event.uid, 'notifyCalendarEvents'))) {
return;
}
return api.broadcast('notify.calendar', event.uid, {
title: event.subject,
text: event.startTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric', dayPeriod: 'narrow' }),
payload: {
_id: event._id,
},
});
}
public async findImportedEvent(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null> {
return CalendarEvent.findOneByExternalIdAndUserId(externalId, uid);
}
public async parseDescriptionForMeetingUrl(description: string): Promise<string | undefined> {
if (!description) {
return;
}
const defaultPattern = '(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)';
const pattern = (settings.get<string>('Calendar_MeetingUrl_Regex') || defaultPattern).trim();
if (!pattern) {
return;
}
const regex: RegExp | undefined = (() => {
try {
return new RegExp(pattern, 'im');
} catch {
logger.error('Failed to parse regular expression for meeting url.');
}
})();
if (!regex) {
return;
}
const results = description.match(regex);
if (!results) {
return;
}
const [, ...urls] = results;
for (const encodedUrl of urls) {
if (!encodedUrl) {
continue;
}
let url = encodedUrl;
while (!url.includes('://')) {
const decodedUrl = decodeURIComponent(url);
if (decodedUrl === url) {
break;
}
url = decodedUrl;
}
if (url.includes('://')) {
return url;
}
}
return undefined;
}
private getShiftedTime(time: Date, minutes: number): Date {
const newTime = new Date(time.valueOf());
newTime.setMinutes(newTime.getMinutes() + minutes);
return newTime;
}
}

View File

@ -6,6 +6,7 @@ import { AnalyticsService } from './analytics/service';
import { AppsEngineService } from './apps-engine/service';
import { AuthorizationLivechat } from '../../app/livechat/server/roomAccessValidator.internalService';
import { BannerService } from './banner/service';
import { CalendarService } from './calendar/service';
import { LDAPService } from './ldap/service';
import { MediaService } from './image/service';
import { MeteorService } from './meteor/service';
@ -34,6 +35,7 @@ api.registerService(new AppsEngineService());
api.registerService(new AnalyticsService());
api.registerService(new AuthorizationLivechat());
api.registerService(new BannerService());
api.registerService(new CalendarService());
api.registerService(new LDAPService());
api.registerService(new MediaService());
api.registerService(new MeteorService());

View File

@ -686,6 +686,12 @@ export const createAccountSettings = () =>
public: true,
i18nLabel: 'Omnichannel_transcript_email',
});
await this.add('Accounts_Default_User_Preferences_notifyCalendarEvents', true, {
type: 'boolean',
public: true,
i18nLabel: 'Notify_Calendar_Events',
});
});
await this.section('Avatar', async function () {

View File

@ -30,6 +30,7 @@ export const preferences = {
hideFlexTab: false,
sendOnEnter: 'normal',
idleTimeLimit: 3600,
notifyCalendarEvents: false,
},
};

View File

@ -173,6 +173,7 @@ describe('miscellaneous', function () {
'sidebarDisplayAvatar',
'sidebarGroupByType',
'muteFocusedConversations',
'notifyCalendarEvents',
].filter((p) => Boolean(p));
expect(res.body).to.have.property('success', true);

View File

@ -0,0 +1,660 @@
import { expect } from 'chai';
import type { Response } from 'supertest';
import { createUser, login } from '../../data/users.helper';
import { getCredentials, api, request, credentials } from '../../data/api-data.js';
import { password } from '../../data/user';
describe('[Calendar Events]', function () {
this.retries(0);
let user2: Awaited<ReturnType<typeof createUser>> | undefined;
let userCredentials: Awaited<ReturnType<typeof login>> | undefined;
before((done) => getCredentials(done));
before(async () => {
user2 = await createUser();
userCredentials = await login(user2.username, password);
});
describe('[/calendar-events.create]', () => {
it('should successfully create an event in the calendar', async function () {
let eventId: string | undefined;
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
eventId = res.body.id;
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
});
});
it('should fail to create an event without a start time', async function () {
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to create an event without a subject', async function () {
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
description: 'Description',
startTime: new Date().toISOString(),
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to create an event without a description', async function () {
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
subject: 'Subject',
startTime: new Date().toISOString(),
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should successfully create an event without reminder information', async function () {
let eventId: string | undefined;
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
eventId = res.body.id;
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
});
});
});
describe('[/calendar-events.list]', () => {
const testSubject = `calendar-events.list-${Date.now()}`;
const testSubject2 = `calendar-events.list-${Date.now()}`;
let eventId: string | undefined;
let eventId2: string | undefined;
let eventId3: string | undefined;
before('create sample events', async () => {
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: testSubject,
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId = res.body.id;
});
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
subject: testSubject2,
description: 'Future Event',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId2 = res.body.id;
});
await request
.post(api('calendar-events.create'))
.set(userCredentials)
.send({
startTime: new Date().toISOString(),
subject: testSubject,
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId3 = res.body.id;
});
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId: eventId2 });
await request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId: eventId3 });
});
it('should list only the events with the same date', async function () {
await request
.get(api('calendar-events.list'))
.set(credentials)
.query({
date: new Date().toISOString(),
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('data').that.is.an('array');
const events = res.body.data.map((event: any) => event._id);
expect(events).to.be.an('array').that.includes(eventId);
expect(events).to.not.includes(eventId2);
});
});
it('should nost list events from other users', async function () {
await request
.get(api('calendar-events.list'))
.set(userCredentials)
.query({
date: new Date().toISOString(),
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('data').that.is.an('array');
const events = res.body.data.map((event: any) => event._id);
expect(events).to.be.an('array').that.includes(eventId3);
expect(events).to.not.includes(eventId);
});
});
});
describe('[/calendar-events.info]', () => {
const testSubject = `calendar-events.info-${Date.now()}`;
let eventId: string | undefined;
let eventId2: string | undefined;
before('create sample events', async () => {
await request
.post(api('calendar-events.create'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: testSubject,
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId = res.body.id;
});
await request
.post(api('calendar-events.create'))
.set(userCredentials)
.send({
startTime: new Date().toISOString(),
subject: testSubject,
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId2 = res.body.id;
});
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
await request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId: eventId2 });
});
it('should return the event information', async function () {
await request
.get(api('calendar-events.info'))
.set(credentials)
.query({
id: eventId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', testSubject);
});
});
it('should return the event information - regular user', async function () {
await request
.get(api('calendar-events.info'))
.set(userCredentials)
.query({
id: eventId2,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', testSubject);
});
});
it('should fail when querying an invalid event', async function () {
await request
.get(api('calendar-events.info'))
.set(credentials)
.query({
id: 'something-random',
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail when querying an event from another user', async function () {
await request
.get(api('calendar-events.info'))
.set(credentials)
.query({
id: eventId2,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
});
describe('[/calendar-events.import]', () => {
it('should successfully import an event to the calendar', async function () {
let eventId: string | undefined;
const externalId = `calendar-events.import-${Date.now()}`;
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
externalId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
eventId = res.body.id;
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
});
});
it('should fail to import an event without an external id', async function () {
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to import an event without a start time', async function () {
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to import an event without a subject', async function () {
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
description: 'Description',
startTime: new Date().toISOString(),
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to import an event without a description', async function () {
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
subject: 'Subject',
startTime: new Date().toISOString(),
reminderMinutesBeforeStart: 10,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should successfully import an event without reminder information', async function () {
let eventId: string | undefined;
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
externalId: `calendar-events.import-external-id-${Date.now()}`,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
eventId = res.body.id;
});
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
});
});
it('should import a new event even if it was already imported by another user', async function () {
let eventId: string | undefined;
let eventId2: string | undefined;
const externalId = `calendar-events.import-${Date.now()}`;
after(async function () {
await request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId });
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId: eventId2 });
});
await request
.post(api('calendar-events.import'))
.set(userCredentials)
.send({
startTime: new Date().toISOString(),
subject: 'First User',
description: 'Description',
reminderMinutesBeforeStart: 10,
externalId,
})
.then((res) => {
eventId = res.body.id;
});
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Second User',
description: 'Description',
reminderMinutesBeforeStart: 10,
externalId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body.id).to.not.be.equal(eventId);
eventId2 = res.body.id;
});
await request
.get(api('calendar-events.info'))
.set(userCredentials)
.query({ id: eventId })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'First User');
});
await request
.get(api('calendar-events.info'))
.set(credentials)
.query({ id: eventId2 })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'Second User');
});
});
it('should update an event that has the same external id', async function () {
let eventId: string | undefined;
const externalId = `calendar-events.import-twice-${Date.now()}`;
after(async function () {
await request.post(api('calendar-events.delete')).set(credentials).send({ eventId });
});
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
externalId,
})
.then((res) => {
eventId = res.body.id;
});
await request
.post(api('calendar-events.import'))
.set(credentials)
.send({
startTime: new Date().toISOString(),
subject: 'New Subject',
description: 'New Description',
reminderMinutesBeforeStart: 15,
externalId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect(async (res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('id', eventId);
});
await request
.get(api('calendar-events.info'))
.set(credentials)
.query({ id: eventId })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'New Subject');
});
});
});
describe('[/calendar-events.update]', () => {
const testSubject = `calendar-events.update-${Date.now()}`;
let eventId: string | undefined;
before('create sample events', async () => {
await request
.post(api('calendar-events.create'))
.set(userCredentials)
.send({
startTime: new Date().toISOString(),
subject: 'Old Subject',
description: 'Old Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId = res.body.id;
});
});
after(async function () {
await request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId });
});
it('should update the event with the new data', async function () {
await request
.post(api('calendar-events.update'))
.set(userCredentials)
.send({
eventId,
startTime: new Date().toISOString(),
subject: testSubject,
description: 'New Description',
reminderMinutesBeforeStart: 15,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect(async (res: Response) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(api('calendar-events.info'))
.set(userCredentials)
.query({ id: eventId })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('event').that.is.an('object');
expect(res.body.event).to.have.property('subject', testSubject);
expect(res.body.event).to.have.property('description', 'New Description');
});
});
it('should fail to update an event that doesnt exist', async function () {
await request
.post(api('calendar-events.update'))
.set(userCredentials)
.send({
eventId: 'something-random',
startTime: new Date().toISOString(),
subject: testSubject,
description: 'New Description',
reminderMinutesBeforeStart: 15,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to update an event from another user', async function () {
await request
.post(api('calendar-events.update'))
.set(credentials)
.send({
eventId,
startTime: new Date().toISOString(),
subject: 'Another Subject',
description: 'Third Description',
reminderMinutesBeforeStart: 20,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
});
describe('[/calendar-events.delete]', () => {
let eventId: string | undefined;
before('create sample events', async () => {
await request
.post(api('calendar-events.create'))
.set(userCredentials)
.send({
startTime: new Date().toISOString(),
subject: 'Subject',
description: 'Description',
reminderMinutesBeforeStart: 10,
})
.then((res) => {
eventId = res.body.id;
});
});
it('should fail to delete an event from another user', async function () {
await request
.post(api('calendar-events.delete'))
.set(credentials)
.send({
eventId,
})
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should delete the specified event', async function () {
await request
.post(api('calendar-events.delete'))
.set(userCredentials)
.send({
eventId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect(async (res: Response) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(api('calendar-events.info'))
.set(userCredentials)
.query({ id: eventId })
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should fail to delete an event that doesnt exist', async function () {
await request
.post(api('calendar-events.delete'))
.set(userCredentials)
.send({
eventId: 'something-random',
})
.expect('Content-Type', 'application/json')
.expect(400);
});
});
});

View File

@ -27,6 +27,7 @@ import type {
UserStatus,
ILivechatPriority,
VideoConference,
ICalendarNotification,
AtLeast,
ILivechatInquiryRecord,
} from '@rocket.chat/core-typings';
@ -83,6 +84,7 @@ export type EventSignatures = {
): void;
'notify.deleteCustomSound'(data: { soundData: ICustomSound }): void;
'notify.updateCustomSound'(data: { soundData: ICustomSound }): void;
'notify.calendar'(uid: string, data: ICalendarNotification): void;
'permission.changed'(data: { clientAction: ClientAction; data: any }): void;
'room'(data: { action: string; room: Partial<IRoom> }): void;
'room.avatarUpdate'(room: Pick<IRoom, '_id' | 'avatarETag'>): void;

View File

@ -36,6 +36,7 @@ import type { IDeviceManagementService } from './types/IDeviceManagementService'
import type { IPushService } from './types/IPushService';
import type { IOmnichannelService } from './types/IOmnichannelService';
import type { ITelemetryEvent, TelemetryMap, TelemetryEvents } from './types/ITelemetryEvent';
import type { ICalendarService } from './types/ICalendarService';
import type { IOmnichannelTranscriptService } from './types/IOmnichannelTranscriptService';
import type { IQueueWorkerService, HealthAggResult } from './types/IQueueWorkerService';
import type { ITranslationService } from './types/ITranslationService';
@ -108,6 +109,7 @@ export {
ISendFileMessageParams,
IUploadFileParams,
IUploadService,
ICalendarService,
IOmnichannelTranscriptService,
IQueueWorkerService,
HealthAggResult,
@ -140,6 +142,7 @@ export const SAUMonitor = proxifyWithWait<ISAUMonitorService>('sau-monitor');
export const DeviceManagement = proxifyWithWait<IDeviceManagementService>('device-management');
export const VideoConf = proxifyWithWait<IVideoConfService>('video-conference');
export const Upload = proxifyWithWait<IUploadService>('upload');
export const Calendar = proxifyWithWait<ICalendarService>('calendar');
export const QueueWorker = proxifyWithWait<IQueueWorkerService>('queue-worker');
export const OmnichannelTranscript = proxifyWithWait<IOmnichannelTranscriptService>('omnichannel-transcript');
export const Message = proxifyWithWait<IMessageService>('message');

View File

@ -0,0 +1,15 @@
import type { UpdateResult, DeleteResult } from 'mongodb';
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';
import type { InsertionModel } from '@rocket.chat/model-typings';
export interface ICalendarService {
create(data: Omit<InsertionModel<ICalendarEvent>, 'reminderTime' | 'notificationSent'>): Promise<ICalendarEvent['_id']>;
import(data: Omit<InsertionModel<ICalendarEvent>, 'notificationSent'>): Promise<ICalendarEvent['_id']>;
get(eventId: ICalendarEvent['_id']): Promise<ICalendarEvent | null>;
list(uid: IUser['_id'], date: Date): Promise<ICalendarEvent[]>;
update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult>;
delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult>;
findImportedEvent(externalId: Required<ICalendarEvent>['externalId'], uid: ICalendarEvent['uid']): Promise<ICalendarEvent | null>;
parseDescriptionForMeetingUrl(description: string): Promise<string | undefined>;
setupNextNotification(): Promise<void>;
}

View File

@ -0,0 +1,16 @@
import type { IRocketChatRecord } from './IRocketChatRecord';
import type { IUser } from './IUser';
export interface ICalendarEvent extends IRocketChatRecord {
startTime: Date;
uid: IUser['_id'];
subject: string;
description: string;
notificationSent: boolean;
externalId?: string;
meetingUrl?: string;
reminderMinutesBeforeStart?: number;
reminderTime?: Date;
}

View File

@ -1,3 +1,4 @@
import type { ICalendarEvent } from './ICalendarEvent';
import type { IMessage } from './IMessage';
import type { IRoom } from './IRoom';
@ -64,3 +65,11 @@ export interface INotificationDesktop {
};
};
}
export interface ICalendarNotification {
title: string;
text: string;
payload: {
_id: ICalendarEvent['_id'];
};
}

View File

@ -122,6 +122,7 @@ export * from './VideoConferenceCapabilities';
export * from './VideoConferenceOptions';
export * from './SpotlightUser';
export * from './ICalendarEvent';
export * from './search';
export * from './omnichannel';

View File

@ -36,8 +36,22 @@ const runCronJobFunctionAndPersistResult = async (fn: () => Promise<any>, jobNam
}
};
type ReservedJob = {
name: string;
callback: () => any | Promise<any>;
} & (
| {
schedule: string;
timestamped: false;
}
| {
when: Date;
timestamped: true;
}
);
export class AgendaCronJobs {
private reservedJobs: { name: string; schedule: string; callback: () => any | Promise<any> }[] = [];
private reservedJobs: ReservedJob[] = [];
private scheduler: Agenda | undefined;
@ -53,8 +67,12 @@ export class AgendaCronJobs {
});
await this.scheduler.start();
for await (const { name, schedule, callback } of this.reservedJobs) {
await this.add(name, schedule, callback);
for await (const job of this.reservedJobs) {
if (job.timestamped) {
await this.addAtTimestamp(job.name, job.when, job.callback);
} else {
await this.add(job.name, job.schedule, job.callback);
}
}
this.reservedJobs = [];
@ -62,15 +80,22 @@ export class AgendaCronJobs {
public async add(name: string, schedule: string, callback: () => any | Promise<any>): Promise<void> {
if (!this.scheduler) {
return this.reserve(name, schedule, callback);
return this.reserve({ name, schedule, callback, timestamped: false });
}
this.scheduler.define(name, async () => {
await runCronJobFunctionAndPersistResult(async () => callback(), name);
});
await this.define(name, callback);
await this.scheduler.every(schedule, name, {}, {});
}
public async addAtTimestamp(name: string, when: Date, callback: () => any | Promise<any>): Promise<void> {
if (!this.scheduler) {
return this.reserve({ name, when, callback, timestamped: true });
}
await this.define(name, callback);
await this.scheduler.schedule(when, name, {});
}
public async remove(name: string): Promise<void> {
if (!this.scheduler) {
return this.unreserve(name);
@ -87,13 +112,23 @@ export class AgendaCronJobs {
return this.scheduler.has({ name: jobName });
}
private async reserve(name: string, schedule: string, callback: () => any | Promise<any>): Promise<void> {
this.reservedJobs = [...this.reservedJobs, { name, schedule, callback }];
private async reserve(config: ReservedJob): Promise<void> {
this.reservedJobs = [...this.reservedJobs, config];
}
private async unreserve(jobName: string): Promise<void> {
this.reservedJobs = this.reservedJobs.filter(({ name }) => name !== jobName);
}
private async define(jobName: string, callback: () => any | Promise<any>): Promise<void> {
if (!this.scheduler) {
throw new Error('Scheduler is not running.');
}
this.scheduler.define(jobName, async () => {
await runCronJobFunctionAndPersistResult(async () => callback(), jobName);
});
}
}
export const cronJobs = new AgendaCronJobs();

View File

@ -67,6 +67,7 @@ export * from './models/IVoipRoomModel';
export * from './models/IWebdavAccountsModel';
export * from './models/IMatrixBridgeRoomModel';
export * from './models/IMatrixBridgeUserModel';
export * from './models/ICalendarEventModel';
export * from './models/IOmnichannelServiceLevelAgreementsModel';
export * from './models/IAppLogsModel';
export * from './models/IAppsModel';

View File

@ -0,0 +1,16 @@
import type { FindCursor, UpdateResult } from 'mongodb';
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';
import type { IBaseModel } from './IBaseModel';
export interface ICalendarEventModel extends IBaseModel<ICalendarEvent> {
findByUserIdAndDate(uid: IUser['_id'], date: Date): FindCursor<ICalendarEvent>;
updateEvent(eventId: ICalendarEvent['_id'], eventData: Partial<ICalendarEvent>): Promise<UpdateResult>;
findNextNotificationDate(): Promise<Date | null>;
findEventsToNotify(notificationTime: Date, minutes: number): FindCursor<ICalendarEvent>;
flagNotificationSent(eventId: ICalendarEvent['_id']): Promise<UpdateResult>;
findOneByExternalIdAndUserId(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null>;
}

View File

@ -66,6 +66,7 @@ import type {
IWebdavAccountsModel,
IMatrixBridgedRoomModel,
IMatrixBridgedUserModel,
ICalendarEventModel,
IOmnichannelServiceLevelAgreementsModel,
IAppsModel,
IAppsPersistenceModel,
@ -163,6 +164,7 @@ export const VoipRoom = proxify<IVoipRoomModel>('IVoipRoomModel');
export const WebdavAccounts = proxify<IWebdavAccountsModel>('IWebdavAccountsModel');
export const MatrixBridgedRoom = proxify<IMatrixBridgedRoomModel>('IMatrixBridgedRoomModel');
export const MatrixBridgedUser = proxify<IMatrixBridgedUserModel>('IMatrixBridgedUserModel');
export const CalendarEvent = proxify<ICalendarEventModel>('ICalendarEventModel');
export const OmnichannelServiceLevelAgreements = proxify<IOmnichannelServiceLevelAgreementsModel>(
'IOmnichannelServiceLevelAgreementsModel',
);

View File

@ -43,6 +43,7 @@ import type { CommandsEndpoints } from './v1/commands';
import type { MeEndpoints } from './v1/me';
import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints';
import type { ImportEndpoints } from './v1/import';
import type { CalendarEndpoints } from './v1/calendar';
import type { FederationEndpoints } from './v1/federation';
import type { ModerationEndpoints } from './v1/moderation';
import type { AuthEndpoints } from './v1/auth';
@ -93,7 +94,9 @@ export interface Endpoints
OAuthAppsEndpoint,
SubscriptionsEndpoints,
AutoTranslateEndpoints,
ImportEndpoints,
FederationEndpoints,
CalendarEndpoints,
AuthEndpoints,
ImportEndpoints,
DefaultEndpoints {}
@ -257,6 +260,7 @@ export * from './v1/e2e/e2eUpdateGroupKeyParamsPOST';
export * from './v1/import';
export * from './v1/voip';
export * from './v1/email-inbox';
export * from './v1/calendar';
export * from './v1/federation';
export * from './v1/rooms';
export * from './v1/groups';

View File

@ -0,0 +1,47 @@
import type { JSONSchemaType } from 'ajv';
import Ajv from 'ajv';
const ajv = new Ajv();
export type CalendarEventCreateProps = {
startTime: string;
externalId?: string;
subject: string;
description: string;
meetingUrl?: string;
reminderMinutesBeforeStart?: number;
};
const calendarEventCreatePropsSchema: JSONSchemaType<CalendarEventCreateProps> = {
type: 'object',
properties: {
startTime: {
type: 'string',
nullable: false,
},
externalId: {
type: 'string',
nullable: true,
},
subject: {
type: 'string',
nullable: false,
},
description: {
type: 'string',
nullable: false,
},
meetingUrl: {
type: 'string',
nullable: true,
},
reminderMinutesBeforeStart: {
type: 'number',
nullable: true,
},
},
required: ['startTime', 'subject', 'description'],
additionalProperties: false,
};
export const isCalendarEventCreateProps = ajv.compile(calendarEventCreatePropsSchema);

View File

@ -0,0 +1,22 @@
import type { ICalendarEvent } from '@rocket.chat/core-typings';
import type { JSONSchemaType } from 'ajv';
import Ajv from 'ajv';
const ajv = new Ajv();
export type CalendarEventDeleteProps = {
eventId: ICalendarEvent['_id'];
};
const calendarEventDeletePropsSchema: JSONSchemaType<CalendarEventDeleteProps> = {
type: 'object',
properties: {
eventId: {
type: 'string',
},
},
required: ['eventId'],
additionalProperties: false,
};
export const isCalendarEventDeleteProps = ajv.compile(calendarEventDeletePropsSchema);

View File

@ -0,0 +1,47 @@
import type { JSONSchemaType } from 'ajv';
import Ajv from 'ajv';
const ajv = new Ajv();
export type CalendarEventImportProps = {
startTime: string;
externalId: string;
subject: string;
description: string;
meetingUrl?: string;
reminderMinutesBeforeStart?: number;
};
const calendarEventImportPropsSchema: JSONSchemaType<CalendarEventImportProps> = {
type: 'object',
properties: {
startTime: {
type: 'string',
nullable: false,
},
externalId: {
type: 'string',
nullable: false,
},
subject: {
type: 'string',
nullable: false,
},
description: {
type: 'string',
nullable: false,
},
meetingUrl: {
type: 'string',
nullable: true,
},
reminderMinutesBeforeStart: {
type: 'number',
nullable: true,
},
},
required: ['startTime', 'externalId', 'subject', 'description'],
additionalProperties: false,
};
export const isCalendarEventImportProps = ajv.compile(calendarEventImportPropsSchema);

View File

@ -0,0 +1,22 @@
import Ajv from 'ajv';
import type { JSONSchemaType } from 'ajv';
const ajv = new Ajv({
coerceTypes: true,
});
export type CalendarEventInfoProps = { id: string };
const calendarEventInfoPropsSchema: JSONSchemaType<CalendarEventInfoProps> = {
type: 'object',
properties: {
id: {
type: 'string',
nullable: false,
},
},
required: ['id'],
additionalProperties: false,
};
export const isCalendarEventInfoProps = ajv.compile(calendarEventInfoPropsSchema);

View File

@ -0,0 +1,22 @@
import Ajv from 'ajv';
import type { JSONSchemaType } from 'ajv';
const ajv = new Ajv({
coerceTypes: true,
});
export type CalendarEventListProps = { date: string };
const calendarEventListPropsSchema: JSONSchemaType<CalendarEventListProps> = {
type: 'object',
properties: {
date: {
type: 'string',
nullable: false,
},
},
required: ['date'],
additionalProperties: false,
};
export const isCalendarEventListProps = ajv.compile(calendarEventListPropsSchema);

View File

@ -0,0 +1,48 @@
import type { ICalendarEvent } from '@rocket.chat/core-typings';
import type { JSONSchemaType } from 'ajv';
import Ajv from 'ajv';
const ajv = new Ajv();
export type CalendarEventUpdateProps = {
eventId: ICalendarEvent['_id'];
startTime: string;
subject: string;
description: string;
meetingUrl?: string;
reminderMinutesBeforeStart?: number;
};
const calendarEventUpdatePropsSchema: JSONSchemaType<CalendarEventUpdateProps> = {
type: 'object',
properties: {
eventId: {
type: 'string',
nullable: false,
},
startTime: {
type: 'string',
nullable: false,
},
subject: {
type: 'string',
nullable: false,
},
description: {
type: 'string',
nullable: false,
},
meetingUrl: {
type: 'string',
nullable: true,
},
reminderMinutesBeforeStart: {
type: 'number',
nullable: true,
},
},
required: ['eventId', 'startTime', 'subject', 'description'],
additionalProperties: false,
};
export const isCalendarEventUpdateProps = ajv.compile(calendarEventUpdatePropsSchema);

View File

@ -0,0 +1,41 @@
import type { ICalendarEvent } from '@rocket.chat/core-typings';
import type { CalendarEventCreateProps } from './CalendarEventCreateProps';
import type { CalendarEventListProps } from './CalendarEventListProps';
import type { CalendarEventImportProps } from './CalendarEventImportProps';
import type { CalendarEventInfoProps } from './CalendarEventInfoProps';
import type { CalendarEventUpdateProps } from './CalendarEventUpdateProps';
import type { CalendarEventDeleteProps } from './CalendarEventDeleteProps';
export * from './CalendarEventCreateProps';
export * from './CalendarEventDeleteProps';
export * from './CalendarEventImportProps';
export * from './CalendarEventInfoProps';
export * from './CalendarEventUpdateProps';
export * from './CalendarEventListProps';
export type CalendarEndpoints = {
'/v1/calendar-events.create': {
POST: (params: CalendarEventCreateProps) => { id: ICalendarEvent['_id'] };
};
'/v1/calendar-events.list': {
GET: (params: CalendarEventListProps) => { data: ICalendarEvent[] };
};
'/v1/calendar-events.info': {
GET: (params: CalendarEventInfoProps) => { event: ICalendarEvent };
};
'/v1/calendar-events.import': {
POST: (params: CalendarEventImportProps) => { id: ICalendarEvent['_id'] };
};
'/v1/calendar-events.update': {
POST: (params: CalendarEventUpdateProps) => void;
};
'/v1/calendar-events.delete': {
POST: (params: CalendarEventDeleteProps) => void;
};
};

View File

@ -41,6 +41,7 @@ export type UsersSetPreferencesParamsPOST = {
dontAskAgainList?: Array<{ action: string; label: string }>;
themeAppearence?: 'auto' | 'light' | 'dark';
receiveLoginDetectionEmail?: boolean;
notifyCalendarEvents?: boolean;
idleTimeLimit?: number;
omnichannelTranscriptEmail?: boolean;
omnichannelTranscriptPDF?: boolean;
@ -204,6 +205,10 @@ const UsersSetPreferencesParamsPostSchema = {
type: 'boolean',
nullable: true,
},
notifyCalendarEvents: {
type: 'boolean',
nullable: true,
},
idleTimeLimit: {
type: 'number',
nullable: true,

View File

@ -17,6 +17,7 @@ import type {
IOmnichannelCannedResponse,
IIntegrationHistory,
IUserDataEvent,
ICalendarNotification,
IUserStatus,
ILivechatInquiryRecord,
} from '@rocket.chat/core-typings';
@ -154,6 +155,7 @@ export interface StreamerEvents {
},
];
},
{ key: `${string}/calendar`; args: [ICalendarNotification] },
];
'importers': [