diff --git a/.changeset/thirty-lemons-talk.md b/.changeset/thirty-lemons-talk.md new file mode 100644 index 0000000000000..58da0b7ed5045 --- /dev/null +++ b/.changeset/thirty-lemons-talk.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds a new setting to override outlook calendar settings per user email domain diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts new file mode 100644 index 0000000000000..d7f2dadd5af2d --- /dev/null +++ b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts @@ -0,0 +1,196 @@ +import { getUserInfo } from './getUserInfo'; +import type { CachedSettings } from '../../../settings/server/CachedSettings'; + +jest.mock('@rocket.chat/models', () => ({ + Users: { + findOneById: jest.fn().mockResolvedValue({ + id: '123', + name: 'Test User', + emails: [{ address: 'test@example.com' }], + }), + }, +})); + +const settings = new Map(); + +jest.mock('../../../settings/server', () => ({ + settings: { + getByRegexp(_id) { + return [...settings].filter(([key]) => key.match(_id)) as any; + }, + get(_id) { + return settings.get(_id) as any; + }, + set(record) { + settings.set(record._id, record.value); + }, + } satisfies Partial, +})); + +// @ts-expect-error __meteor_runtime_config__ is not defined in the type definitions +global.__meteor_runtime_config__ = { + ROOT_URL: 'http://localhost:3000', + ROOT_URL_PATH_PREFIX: '', +}; + +describe('getUserInfo', () => { + let user: Parameters[0]; + + beforeEach(() => { + settings.clear(); + settings.set('Site_Url', 'http://localhost:3000'); + user = { + _id: '123', + createdAt: new Date(), + roles: [], + type: 'user', + active: true, + _updatedAt: new Date(), + }; + }); + + it('should return user info', async () => { + const userInfo = await getUserInfo(user); + + expect(userInfo).toEqual( + expect.objectContaining({ + _id: '123', + type: 'user', + roles: [], + active: true, + _updatedAt: expect.any(Date), + createdAt: expect.any(Date), + email: undefined, + avatarUrl: 'http://localhost:3000/avatar/undefined', + settings: { + calendar: {}, + profile: {}, + preferences: {}, + }, + }), + ); + }); + + describe('email handling', () => { + it('should not include email if no emails are present', async () => { + user.emails = []; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + + it('should include email if one email is present and verified', async () => { + user.emails = [{ address: 'test@example.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toEqual('test@example.com'); + }); + + it('should not include email if one email is present and not verified', async () => { + user.emails = [{ address: 'test@example.com', verified: false }]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + + it('should include email if multiple emails are present and one is verified', async () => { + user.emails = [ + { address: 'test@example.com', verified: false }, + { address: 'test2@example.com', verified: true }, + ]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toEqual('test2@example.com'); + }); + + it('should not include email if multiple emails are present and none are verified', async () => { + user.emails = [ + { address: 'test@example.com', verified: false }, + { address: 'test2@example.com', verified: false }, + ]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + }); + + describe('outlook calendar settings', () => { + beforeEach(() => { + settings.set('Outlook_Calendar_Enabled', true); + settings.set('Outlook_Calendar_Exchange_Url', 'https://outlook.office365.com/'); + settings.set('Outlook_Calendar_Outlook_Url', 'https://outlook.office365.com/owa/#calendar/view/month'); + settings.set('Outlook_Calendar_Url_Mapping', JSON.stringify({})); + user.emails = [{ address: 'test@example.com', verified: true }]; + }); + + it('should return empty calendar settings if Outlook is disabled', async () => { + settings.set('Outlook_Calendar_Enabled', false); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({}); + }); + + it('should return calendar settings with Outlook enabled and default URLs', async () => { + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar?.outlook).toEqual({ + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }); + }); + + it('should return calendar settings with Outlook enabled and domain mapping', async () => { + settings.set( + 'Outlook_Calendar_Url_Mapping', + JSON.stringify({ + 'example.com': { Exchange_Url: 'https://custom.exchange.com/', Outlook_Url: 'https://custom.outlook.com/' }, + }), + ); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://custom.exchange.com/', + Outlook_Url: 'https://custom.outlook.com/', + }, + }); + }); + + it('should return calendar settings with outlook disabled but enabled for specific domain', async () => { + settings.set('Outlook_Calendar_Enabled', false); + settings.set( + 'Outlook_Calendar_Url_Mapping', + JSON.stringify({ + 'specific.com': { Enabled: true, Exchange_Url: 'https://specific.exchange.com/', Outlook_Url: 'https://specific.outlook.com/' }, + }), + ); + user.emails = [{ address: 'me@specific.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://specific.exchange.com/', + Outlook_Url: 'https://specific.outlook.com/', + }, + }); + }); + + it('should return calendar settings with Outlook enabled and default mapping for unknown domain', async () => { + user.emails = [{ address: 'unknown@example.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }, + }); + }); + + it('should handle invalid JSON in Outlook_Calendar_Url_Mapping', async () => { + settings.set('Outlook_Calendar_Url_Mapping', 'invalid json'); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }, + }); + }); + }); +}); diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts index 6819c55897a52..4918aaf3fee22 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts @@ -1,4 +1,4 @@ -import { isOAuthUser, type IUser, type IUserEmail } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; import { settings } from '../../../settings/server'; import { getURL } from '../../../utils/server/getURL'; @@ -25,13 +25,49 @@ const getUserPreferences = async (me: IUser): Promise> = return accumulator; }; +/** + * Returns the user's calendar settings based on their email domain and the configured mapping. + * If the email is not provided or the domain is not found in the mapping, + * it returns the default Outlook calendar settings. + * @param email - The user's email object, which may contain the address and verification status. + * @returns The calendar settings for the user, including Outlook calendar settings if enabled. + */ +const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar => { + const calendarSettings: IUserCalendar = {}; + + const outlook = { + Enabled: settings.get('Outlook_Calendar_Enabled'), + Exchange_Url: settings.get('Outlook_Calendar_Exchange_Url'), + Outlook_Url: settings.get('Outlook_Calendar_Outlook_Url'), + }; + + const domain = email ? email.address.split('@').pop() : undefined; + const outlookCalendarUrlMapping = settings.get('Outlook_Calendar_Url_Mapping'); + + if (domain && outlookCalendarUrlMapping && outlookCalendarUrlMapping.includes(domain)) { + try { + const mappingObject = JSON.parse(outlookCalendarUrlMapping); + const mappedSettings = mappingObject[domain]; + if (mappedSettings) { + outlook.Enabled = mappedSettings.Enabled ?? outlook.Enabled; + outlook.Exchange_Url = mappedSettings.Exchange_Url ?? outlook.Exchange_Url; + outlook.Outlook_Url = mappedSettings.Outlook_Url ?? outlook.Outlook_Url; + } + } catch (error) { + console.error('Invalid Outlook Calendar URL Mapping JSON:', error); + } + } + + if (outlook.Enabled) { + calendarSettings.outlook = outlook; + } + + return calendarSettings; +}; + export async function getUserInfo(me: IUser): Promise< IUser & { email?: string; - settings?: { - profile: Record; - preferences: unknown; - }; avatarUrl: string; } > { @@ -48,6 +84,7 @@ export async function getUserInfo(me: IUser): Promise< ...(await getUserPreferences(me)), ...userPreferences, }, + calendar: getUserCalendar(verifiedEmail), }, avatarUrl: getURL(`/avatar/${me.username}`, { cdn: false, full: true }), isOAuthUser: isOAuthUser(me), diff --git a/apps/meteor/client/hooks/notification/useNotificationUserCalendar.ts b/apps/meteor/client/hooks/notification/useNotificationUserCalendar.ts index 47af5ae6e06eb..febd483a57d47 100644 --- a/apps/meteor/client/hooks/notification/useNotificationUserCalendar.ts +++ b/apps/meteor/client/hooks/notification/useNotificationUserCalendar.ts @@ -1,6 +1,6 @@ import type { ICalendarNotification, IUser } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useStream, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useStream, useUserPreference } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; import { imperativeModal } from '../../lib/imperativeModal'; @@ -8,7 +8,6 @@ import OutlookCalendarEventModal from '../../views/outlookCalendar/OutlookCalend export const useNotificationUserCalendar = (user: IUser) => { const requireInteraction = useUserPreference('desktopNotificationRequireInteraction'); - const outLookEnabled = useSetting('Outlook_Calendar_Enabled'); const notifyUserStream = useStream('notify-user'); const notifyUserCalendar = useEffectEvent(async (notification: ICalendarNotification) => { @@ -34,10 +33,10 @@ export const useNotificationUserCalendar = (user: IUser) => { }); useEffect(() => { - if (!user?._id || !outLookEnabled) { + if (!user?._id || !user.settings?.calendar?.outlook?.Enabled) { return; } return notifyUserStream(`${user._id}/calendar`, notifyUserCalendar); - }, [notifyUserCalendar, notifyUserStream, outLookEnabled, user?._id]); + }, [notifyUserCalendar, notifyUserStream, user.settings?.calendar?.outlook?.Enabled, user?._id]); }; diff --git a/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts b/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts index 140e0b913fdf4..3f6485d25a6fc 100644 --- a/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts @@ -1,4 +1,4 @@ -import { useSetting } from '@rocket.chat/ui-contexts'; +import { useUser } from '@rocket.chat/ui-contexts'; import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; @@ -6,10 +6,10 @@ import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomTool const OutlookEventsRoute = lazy(() => import('../../views/outlookCalendar/OutlookEventsRoute')); export const useOutlookCalenderRoomAction = () => { - const enabled = useSetting('Outlook_Calendar_Enabled', false); + const user = useUser(); return useMemo((): RoomToolboxActionConfig | undefined => { - if (!enabled) { + if (!user?.settings?.calendar?.outlook?.Enabled) { return undefined; } @@ -21,5 +21,5 @@ export const useOutlookCalenderRoomAction = () => { tabComponent: OutlookEventsRoute, order: 999, }; - }, [enabled]); + }, [user?.settings?.calendar?.outlook?.Enabled]); }; diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index 5188798961d86..1c3ff29673b04 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -2,7 +2,7 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; import { AccordionItem, Button, Field, FieldGroup, FieldHint, FieldLabel, FieldRow, Select, ToggleSwitch } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useSetting, useUserPreference, useUser } from '@rocket.chat/ui-contexts'; import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -22,6 +22,7 @@ const emailNotificationOptionsLabelMap = { const PreferencesNotificationsSection = () => { const { t, i18n } = useTranslation(); + const user = useUser(); const [notificationsPermission, setNotificationsPermission] = useState(); @@ -36,7 +37,6 @@ const PreferencesNotificationsSection = () => { 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 showMobileRinging = useSetting('VideoConf_Mobile_Ringing'); const notify = useNotification(); @@ -95,6 +95,8 @@ const PreferencesNotificationsSection = () => { const enableMobileRingingId = useId(); const desktopNotificationsLabelId = useId(); + const showCalendarPreference = user?.settings?.calendar?.outlook?.Enabled; + return ( diff --git a/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventsList.tsx b/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventsList.tsx index 62a3ab163dc8a..b1ca365ce753a 100644 --- a/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventsList.tsx +++ b/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventsList.tsx @@ -1,6 +1,6 @@ import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, ButtonGroup, Button, Throbber } from '@rocket.chat/fuselage'; import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { Virtuoso } from 'react-virtuoso'; @@ -27,7 +27,7 @@ type OutlookEventsListProps = { const OutlookEventsList = ({ onClose, changeRoute }: OutlookEventsListProps): ReactElement => { const t = useTranslation(); - const outlookUrl = useSetting('Outlook_Calendar_Outlook_Url', ''); + const user = useUser(); const { authEnabled, isError, error } = useOutlookAuthentication(); const hasOutlookMethods = !(isError && error instanceof NotOnDesktopError); @@ -43,6 +43,8 @@ const OutlookEventsList = ({ onClose, changeRoute }: OutlookEventsListProps): Re const calendarEvents = calendarListResult.data; const total = calendarEvents?.length || 0; + const outlookUrl = user?.settings?.calendar?.outlook?.Outlook_Url; + return ( diff --git a/apps/meteor/ee/server/settings/outlookCalendar.ts b/apps/meteor/ee/server/settings/outlookCalendar.ts index 15f8cedb8f711..7252a388e9f52 100644 --- a/apps/meteor/ee/server/settings/outlookCalendar.ts +++ b/apps/meteor/ee/server/settings/outlookCalendar.ts @@ -18,12 +18,14 @@ export function addSettings(): void { type: 'string', public: true, invalidValue: '', + placeholder: 'https://exchange.example.com/', }); await this.add('Outlook_Calendar_Outlook_Url', '', { type: 'string', public: true, invalidValue: '', + placeholder: 'https://exchange.example.com/owa/#path=/calendar/view/Month', }); await this.add( @@ -41,6 +43,23 @@ export function addSettings(): void { public: true, invalidValue: false, }); + + /** + * const defaultMapping = { + * 'rocket.chat': { + * Enabled: true, + * Exchange_Url: 'https://owa.dev.rocket.chat/', + * Outlook_Url: 'https://owa.dev.rocket.chat/owa/#path=/calendar' + * }, + * }; + */ + await this.add('Outlook_Calendar_Url_Mapping', '{}', { + type: 'code', + multiline: true, + public: true, + code: 'application/json', + invalidValue: '{}', + }); }, ); }); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index f43130102daae..92cc6f4706569 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -41,6 +41,7 @@ export default { '/server/lib/auditServerEvents/**.spec.ts', '/server/cron/**.spec.ts', '/app/api/server/**.spec.ts', + '/app/api/server/helpers/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], diff --git a/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts deleted file mode 100644 index 0b818a23dedf1..0000000000000 --- a/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ICalendarEvent, IUser, UserStatus } from '@rocket.chat/core-typings'; -import { cronJobs } from '@rocket.chat/cron'; - -import { applyStatusChange } from './applyStatusChange'; -import { generateCronJobId } from './generateCronJobId'; -import { handleOverlappingEvents } from './handleOverlappingEvents'; -import { settings } from '../../../../app/settings/server'; - -export async function setupAppointmentStatusChange( - eventId: ICalendarEvent['_id'], - uid: IUser['_id'], - startTime: Date, - endTime?: Date, - status?: UserStatus, - shouldScheduleRemoval?: boolean, -): Promise { - const hasBusyStatusSetting = settings.get('Calendar_BusyStatus_Enabled'); - if (!endTime || !hasBusyStatusSetting) { - return; - } - - if (shouldScheduleRemoval) { - const { shouldProceed } = await handleOverlappingEvents(eventId, uid, startTime, endTime, status); - if (!shouldProceed) { - return; - } - } - - const scheduledTime = shouldScheduleRemoval ? startTime : endTime; - const cronJobId = generateCronJobId(eventId, uid, 'status'); - - if (await cronJobs.has(cronJobId)) { - await cronJobs.remove(cronJobId); - } - - await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => { - await applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval }); - - if (!shouldScheduleRemoval) { - if (await cronJobs.has('calendar-next-status-change')) { - await cronJobs.remove('calendar-next-status-change'); - } - } - }); -} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 35fa08ded732b..68fc37f5f3ee4 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -157,11 +157,20 @@ export interface IUserEmail { verified?: boolean; } +export interface IOutlook { + Enabled: boolean; + Exchange_Url: string; + Outlook_Url: string; +} + +export interface IUserCalendar { + outlook?: IOutlook; +} + export interface IUserSettings { - profile?: any; - preferences?: { - [key: string]: any; - }; + profile?: Record; + preferences?: Record; + calendar?: IUserCalendar; } export interface IGetRoomRoles { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index f46d7698c256d..6fbaa9afd3775 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -883,6 +883,8 @@ "CROWD_Allow_Custom_Username": "Allow custom username in Rocket.Chat", "CROWD_Reject_Unauthorized": "Reject Unauthorized", "CSV": "CSV", + "Calendar_BusyStatus_Enabled": "Outlook calendar status sync", + "Calendar_BusyStatus_Enabled_Description": "Automatically sets user status to busy during scheduled Outlook meetings and returns it to the previous status afterward.", "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", @@ -3836,9 +3838,11 @@ "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_Exchange_Url_Description": "Default 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.", + "Outlook_Calendar_Outlook_Url_Description": "Default URL used to launch the Outlook web app.", + "Outlook_Calendar_Url_Mapping": "Domain to Outlook URL mapping", + "Outlook_Calendar_Url_Mapping_Description": "Map the domain of the calendar to the Outlook URL. This is useful if you have multiple domains and want to use different Outlook URLs for each.", "Outlook_Sync_Failed": "Failed to load outlook events.", "Outlook_Sync_Success": "Outlook events synchronized.", "Outlook_authentication": "Outlook authentication",