diff --git a/.changeset/lovely-ways-move.md b/.changeset/lovely-ways-move.md new file mode 100644 index 0000000000000..aea0865a6f7a4 --- /dev/null +++ b/.changeset/lovely-ways-move.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Adds automatic presence sync based on calendar events, updating the user’s status to “busy” when a meeting starts and reverting it afterward. diff --git a/apps/meteor/app/api/server/v1/calendar.ts b/apps/meteor/app/api/server/v1/calendar.ts index 4f189229c3227..943967b3ad426 100644 --- a/apps/meteor/app/api/server/v1/calendar.ts +++ b/apps/meteor/app/api/server/v1/calendar.ts @@ -50,11 +50,12 @@ API.v1.addRoute( { async post() { const { userId: uid } = this; - const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams; + const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams; const id = await Calendar.create({ uid, startTime: new Date(startTime), + ...(endTime ? { endTime: new Date(endTime) } : {}), externalId, subject, description, diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index c645535ea40cd..3018d3fa7d6ea 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,4 +1,4 @@ -import { MeteorError, Team, api } from '@rocket.chat/core-services'; +import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services'; import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { @@ -19,7 +19,7 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, } from '@rocket.chat/rest-typings'; -import { getLoginExpirationInMs } from '@rocket.chat/tools'; +import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -1337,6 +1337,8 @@ API.v1.addRoute( user: { status, _id, username, statusText, roles, name }, previousStatus: user.status, }); + + void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress(); } else { throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { method: 'users.setStatus', diff --git a/apps/meteor/ee/server/settings/outlookCalendar.ts b/apps/meteor/ee/server/settings/outlookCalendar.ts index 02dd51f795108..15f8cedb8f711 100644 --- a/apps/meteor/ee/server/settings/outlookCalendar.ts +++ b/apps/meteor/ee/server/settings/outlookCalendar.ts @@ -35,6 +35,12 @@ export function addSettings(): void { invalidValue: '', }, ); + + await this.add('Calendar_BusyStatus_Enabled', true, { + type: 'boolean', + public: true, + invalidValue: false, + }); }, ); }); diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index e3f3e0af83d95..80f6d7aac6a7e 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -1,12 +1,17 @@ import type { ICalendarService } from '@rocket.chat/core-services'; import { ServiceClassInternal, api } from '@rocket.chat/core-services'; import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; import { cronJobs } from '@rocket.chat/cron'; import { Logger } from '@rocket.chat/logger'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { CalendarEvent } from '@rocket.chat/models'; import type { UpdateResult, DeleteResult } from 'mongodb'; +import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges'; +import { removeCronJobs } from './statusEvents/removeCronJobs'; +import { setupAppointmentStatusChange } from './statusEvents/setupAppointmentStatusChange'; +import { getShiftedTime } from './utils/getShiftedTime'; import { settings } from '../../../app/settings/server'; import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference'; @@ -18,24 +23,28 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe protected name = 'calendar'; public async create(data: Omit, 'reminderTime' | 'notificationSent'>): Promise { - const { uid, startTime, subject, description, reminderMinutesBeforeStart, meetingUrl } = data; - + const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, meetingUrl, busy } = data; const minutes = reminderMinutesBeforeStart ?? defaultMinutesForNotifications; - const reminderTime = minutes ? this.getShiftedTime(startTime, -minutes) : undefined; + const reminderTime = minutes ? getShiftedTime(startTime, -minutes) : undefined; const insertData: InsertionModel = { uid, startTime, + ...(endTime && { endTime }), subject, description, meetingUrl, reminderMinutesBeforeStart: minutes, reminderTime, notificationSent: false, + ...(busy !== undefined && { busy }), }; const insertResult = await CalendarEvent.insertOne(insertData); await this.setupNextNotification(); + if (busy !== false) { + await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true); + } return insertResult.insertedId; } @@ -46,18 +55,20 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return this.create(data); } - const { uid, startTime, subject, description, reminderMinutesBeforeStart } = data; + const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description); - const reminderTime = reminderMinutesBeforeStart ? this.getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; + const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; const updateData: Omit, 'uid' | 'notificationSent'> = { startTime, + ...(endTime && { endTime }), subject, description, meetingUrl, reminderMinutesBeforeStart, reminderTime, externalId, + ...(busy !== undefined && { busy }), }; const event = await this.findImportedEvent(externalId, uid); @@ -70,12 +81,18 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe }); await this.setupNextNotification(); + if (busy !== false) { + await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true); + } return insertResult.insertedId; } const updateResult = await CalendarEvent.updateEvent(event._id, updateData); if (updateResult.modifiedCount > 0) { await this.setupNextNotification(); + if (busy !== false) { + await setupAppointmentStatusChange(event._id, uid, startTime, endTime, UserStatus.BUSY, true); + } } return event._id; @@ -89,30 +106,58 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return CalendarEvent.findByUserIdAndDate(uid, date).toArray(); } - public async update(eventId: ICalendarEvent['_id'], data: Partial): Promise { - 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; + public async update(eventId: ICalendarEvent['_id'], data: Partial): Promise { + const event = await this.get(eventId); + if (!event) { + return null; + } + + const { startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; + + const meetingUrl = await this.getMeetingUrl(data); + const reminderTime = reminderMinutesBeforeStart && startTime ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; const updateData: Partial = { startTime, + ...(endTime && { endTime }), subject, description, meetingUrl, reminderMinutesBeforeStart, reminderTime, + ...(busy !== undefined && { busy }), }; const updateResult = await CalendarEvent.updateEvent(eventId, updateData); if (updateResult.modifiedCount > 0) { await this.setupNextNotification(); + + if (startTime || endTime) { + await removeCronJobs(eventId, event.uid); + + const isBusy = busy !== undefined ? busy : event.busy !== false; + if (isBusy) { + const effectiveStartTime = startTime || event.startTime; + const effectiveEndTime = endTime || event.endTime; + + // Only proceed if we have both valid start and end times + if (effectiveStartTime && effectiveEndTime) { + await setupAppointmentStatusChange(eventId, event.uid, effectiveStartTime, effectiveEndTime, UserStatus.BUSY, true); + } + } + } } return updateResult; } public async delete(eventId: ICalendarEvent['_id']): Promise { + const event = await this.get(eventId); + if (event) { + await removeCronJobs(eventId, event.uid); + } + return CalendarEvent.deleteOne({ _id: eventId, }); @@ -122,6 +167,22 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return this.doSetupNextNotification(false); } + public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise { + return cancelUpcomingStatusChanges(uid, endTime); + } + + private async getMeetingUrl(eventData: Partial): Promise { + if (eventData.meetingUrl !== undefined) { + return eventData.meetingUrl; + } + + if (eventData.description !== undefined) { + return this.parseDescriptionForMeetingUrl(eventData.description); + } + + return undefined; + } + private async doSetupNextNotification(isRecursive: boolean): Promise { const date = await CalendarEvent.findNextNotificationDate(); if (!date) { @@ -139,19 +200,17 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date)); } - public async sendCurrentNotifications(date: Date): Promise { + private async sendCurrentNotifications(date: Date): Promise { 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 { + private async sendEventNotification(event: ICalendarEvent): Promise { if (!(await getUserPreference(event.uid, 'notifyCalendarEvents'))) { return; } @@ -165,14 +224,14 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe }); } - public async findImportedEvent( + private async findImportedEvent( externalId: Required['externalId'], uid: ICalendarEvent['uid'], ): Promise { return CalendarEvent.findOneByExternalIdAndUserId(externalId, uid); } - public async parseDescriptionForMeetingUrl(description: string): Promise { + private async parseDescriptionForMeetingUrl(description: string): Promise { if (!description) { return; } @@ -224,10 +283,4 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return undefined; } - - private getShiftedTime(time: Date, minutes: number): Date { - const newTime = new Date(time.valueOf()); - newTime.setMinutes(newTime.getMinutes() + minutes); - return newTime; - } } diff --git a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts new file mode 100644 index 0000000000000..d47fe85237ef0 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts @@ -0,0 +1,47 @@ +import { api } from '@rocket.chat/core-services'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { setupAppointmentStatusChange } from './setupAppointmentStatusChange'; + +export async function applyStatusChange({ + eventId, + uid, + startTime, + endTime, + status, + shouldScheduleRemoval, +}: { + eventId: ICalendarEvent['_id']; + uid: IUser['_id']; + startTime: Date; + endTime?: Date; + status?: UserStatus; + shouldScheduleRemoval?: boolean; +}): Promise { + const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } }); + if (!user || user.status === UserStatus.OFFLINE) { + return; + } + + const newStatus = status ?? UserStatus.BUSY; + const previousStatus = user.status; + + await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); + + await api.broadcast('presence.status', { + user: { + status: newStatus, + _id: uid, + roles: user.roles, + username: user.username, + name: user.name, + }, + previousStatus, + }); + + if (shouldScheduleRemoval && endTime) { + await setupAppointmentStatusChange(eventId, uid, startTime, endTime, previousStatus, false); + } +} diff --git a/apps/meteor/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.ts b/apps/meteor/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.ts new file mode 100644 index 0000000000000..aab3df3a7c7e2 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.ts @@ -0,0 +1,22 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { CalendarEvent } from '@rocket.chat/models'; + +import { generateCronJobId } from './generateCronJobId'; +import { settings } from '../../../../app/settings/server'; + +export async function cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise { + const hasBusyStatusSetting = settings.get('Calendar_BusyStatus_Enabled'); + if (!hasBusyStatusSetting) { + return; + } + + const events = await CalendarEvent.findEligibleEventsForCancelation(uid, endTime).toArray(); + + for await (const event of events) { + const statusChangeJobId = generateCronJobId(event._id, event.uid, 'status'); + if (await cronJobs.has(statusChangeJobId)) { + await cronJobs.remove(statusChangeJobId); + } + } +} diff --git a/apps/meteor/server/services/calendar/statusEvents/generateCronJobId.ts b/apps/meteor/server/services/calendar/statusEvents/generateCronJobId.ts new file mode 100644 index 0000000000000..b007efb607eb2 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/generateCronJobId.ts @@ -0,0 +1,13 @@ +import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings'; + +export function generateCronJobId(eventId: ICalendarEvent['_id'], uid: IUser['_id'], eventType: 'status' | 'reminder'): string { + if (!eventId || !uid || !eventType || (eventType !== 'status' && eventType !== 'reminder')) { + throw new Error('Missing required parameters. Please provide eventId, uid and eventType (status or reminder)'); + } + + if (eventType === 'status') { + return `calendar-presence-status-${eventId}-${uid}`; + } + + return `calendar-reminder-${eventId}-${uid}`; +} diff --git a/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts b/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts new file mode 100644 index 0000000000000..96ca535b98c15 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts @@ -0,0 +1,61 @@ +import type { UserStatus, IUser, ICalendarEvent } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { CalendarEvent } from '@rocket.chat/models'; + +import { applyStatusChange } from './applyStatusChange'; +import { generateCronJobId } from './generateCronJobId'; + +export async function handleOverlappingEvents( + eventId: ICalendarEvent['_id'], + uid: IUser['_id'], + startTime: Date, + endTime: Date, + status?: UserStatus, +): Promise<{ shouldProceed: boolean }> { + const overlappingEvents = await CalendarEvent.findOverlappingEvents(eventId, uid, startTime, endTime).toArray(); + + if (overlappingEvents.length === 0) { + return { shouldProceed: true }; + } + + const allEvents = [...overlappingEvents, { endTime, startTime }]; + + const latestEndingEvent = allEvents.reduce<{ endTime: Date | null; startTime: Date | null }>( + (latest, event) => { + if (!event.endTime) return latest; + if (!latest.endTime || event.endTime > latest.endTime) { + return { endTime: event.endTime, startTime: event.startTime }; + } + return latest; + }, + { endTime: null, startTime: null }, + ); + + // If this event doesn't have the latest end time, don't schedule removal + // because another event will handle it + if (latestEndingEvent.endTime && latestEndingEvent.endTime.getTime() !== endTime.getTime()) { + const scheduledTime = startTime; + const cronJobId = generateCronJobId(eventId, uid, 'status'); + + if (await cronJobs.has(cronJobId)) { + await cronJobs.remove(cronJobId); + } + + await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => + applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval: false }), + ); + return { shouldProceed: false }; + } + + // For any existing events that end before this one, remove their status removal jobs + for await (const event of overlappingEvents) { + if (event.endTime && event.endTime < endTime) { + const eventCronJobId = generateCronJobId(event._id, uid, 'status'); + if (await cronJobs.has(eventCronJobId)) { + await cronJobs.remove(eventCronJobId); + } + } + } + + return { shouldProceed: true }; +} diff --git a/apps/meteor/server/services/calendar/statusEvents/index.ts b/apps/meteor/server/services/calendar/statusEvents/index.ts new file mode 100644 index 0000000000000..ff4133ca8bc56 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/index.ts @@ -0,0 +1,15 @@ +import { applyStatusChange } from './applyStatusChange'; +import { cancelUpcomingStatusChanges } from './cancelUpcomingStatusChanges'; +import { generateCronJobId } from './generateCronJobId'; +import { handleOverlappingEvents } from './handleOverlappingEvents'; +import { removeCronJobs } from './removeCronJobs'; +import { setupAppointmentStatusChange } from './setupAppointmentStatusChange'; + +export const statusEventManager = { + applyStatusChange, + cancelUpcomingStatusChanges, + generateCronJobId, + handleOverlappingEvents, + removeCronJobs, + setupAppointmentStatusChange, +} as const; diff --git a/apps/meteor/server/services/calendar/statusEvents/removeCronJobs.ts b/apps/meteor/server/services/calendar/statusEvents/removeCronJobs.ts new file mode 100644 index 0000000000000..30c79098a3eaf --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/removeCronJobs.ts @@ -0,0 +1,17 @@ +import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; + +import { generateCronJobId } from './generateCronJobId'; + +export async function removeCronJobs(eventId: ICalendarEvent['_id'], uid: IUser['_id']): Promise { + const statusChangeJobId = generateCronJobId(eventId, uid, 'status'); + const reminderJobId = generateCronJobId(eventId, uid, 'reminder'); + + if (await cronJobs.has(statusChangeJobId)) { + await cronJobs.remove(statusChangeJobId); + } + + if (await cronJobs.has(reminderJobId)) { + await cronJobs.remove(reminderJobId); + } +} diff --git a/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts new file mode 100644 index 0000000000000..907f2a6bf2c66 --- /dev/null +++ b/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts @@ -0,0 +1,39 @@ +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 () => + applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval }), + ); +} diff --git a/apps/meteor/server/services/calendar/utils/getShiftedTime.ts b/apps/meteor/server/services/calendar/utils/getShiftedTime.ts new file mode 100644 index 0000000000000..aae5ccfb957eb --- /dev/null +++ b/apps/meteor/server/services/calendar/utils/getShiftedTime.ts @@ -0,0 +1,5 @@ +export function getShiftedTime(time: Date, minutes: number): Date { + const newTime = new Date(time.valueOf()); + newTime.setMinutes(newTime.getMinutes() + minutes); + return newTime; +} diff --git a/apps/meteor/tests/e2e/presence.spec.ts b/apps/meteor/tests/e2e/presence.spec.ts index ad96c3cee4bff..4f5dfe7a6db70 100644 --- a/apps/meteor/tests/e2e/presence.spec.ts +++ b/apps/meteor/tests/e2e/presence.spec.ts @@ -1,4 +1,5 @@ import { DEFAULT_USER_CREDENTIALS, IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; import { Registration } from './page-objects'; import { setSettingValueById } from './utils/setSettingValueById'; import { test, expect } from './utils/test'; @@ -45,4 +46,109 @@ test.describe.serial('Presence', () => { await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); }); }); + + // This test is supposed to be ran locally because it is too slow. + // It is also a workaround until we find a better way to test this. + test.describe.skip('Calendar appointment automatic status', () => { + test.describe.configure({ timeout: 1000 * 60 * 10 }); + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Calendar_BusyStatus_Enabled', true); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Calendar_BusyStatus_Enabled', false); + }); + + test('Should change user status to busy when there is an appointment', async ({ page, api }) => { + await page.goto('/home'); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); + expect( + ( + await api.post('/calendar-events.create', { + startTime: new Date(new Date().getTime() + 1000 * 60 * 2).toISOString(), + endTime: new Date(new Date().getTime() + 1000 * 60 * 3).toISOString(), + subject: 'Test appointment', + description: 'Test appointment description', + meetingUrl: 'https://rocket.chat/', + }) + ).status(), + ).toBe(200); + + await test.step('Should change status to busy', async () => { + // wait 2 minutes to ensure the status is changed + await page.waitForTimeout(1000 * 60 * 2); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--busy')).toBeVisible(); + }); + + await test.step('Should revert status to online', async () => { + // wait 2 minutes to ensure the status is changed + await page.waitForTimeout(1000 * 60); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); + }); + }); + + test('Should not change status to busy if the event is deleted', async ({ page, api }) => { + await page.goto('/home'); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); + + const apiResponse = await api.post('/calendar-events.create', { + startTime: new Date(new Date().getTime() + 1000 * 60 * 2).toISOString(), + endTime: new Date(new Date().getTime() + 1000 * 60 * 3).toISOString(), + subject: 'Test appointment', + description: 'Test appointment description', + meetingUrl: 'https://rocket.chat/', + }); + + expect(apiResponse.status()).toBe(200); + + const eventId = (await apiResponse.json()).id; + + expect((await api.post('/calendar-events.delete', { eventId })).status()).toBe(200); + + await page.waitForTimeout(1000 * 60 * 2); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); + }); + + test('Should update status to busy when the event is updated', async ({ page, api }) => { + await page.goto('/home'); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--online')).toBeVisible(); + + const apiResponse = await api.post('/calendar-events.create', { + startTime: new Date(new Date().getTime() + 1000 * 60 * 50).toISOString(), + endTime: new Date(new Date().getTime() + 1000 * 60 * 55).toISOString(), + subject: 'Test appointment', + description: 'Test appointment description', + meetingUrl: 'https://rocket.chat/', + }); + + expect(apiResponse.status()).toBe(200); + + const eventId = (await apiResponse.json()).id; + + expect( + ( + await api.post('/calendar-events.update', { + eventId, + startTime: new Date(new Date().getTime() + 1000 * 60 * 2).toISOString(), + subject: 'Test appointment updated', + description: 'Test appointment description updated', + meetingUrl: 'https://rocket.chat/updated', + }) + ).status(), + ).toBe(200); + + // wait 2 minutes to ensure the status is changed + await page.waitForTimeout(1000 * 60 * 2); + + await expect(page.getByRole('button', { name: 'User menu' }).locator('.rcx-status-bullet--busy')).toBeVisible(); + }); + }); }); diff --git a/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts b/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts new file mode 100644 index 0000000000000..902e90fc8ae8d --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts @@ -0,0 +1,32 @@ +import type { AgendaCronJobs } from '@rocket.chat/cron'; + +// #TODO: Move this to a package and write unit tests there ensuring that the behavior of the mock and the real class match 1:1 +export class MockedCronJobs { + public jobNames = new Set(); + + private _started = false; + + public get started(): boolean { + return this._started; + } + + start: AgendaCronJobs['start'] = async () => { + this._started = true; + }; + + add: AgendaCronJobs['add'] = async (name) => { + this.jobNames.add(name); + }; + + addAtTimestamp: AgendaCronJobs['addAtTimestamp'] = async (name) => { + this.jobNames.add(name); + }; + + remove: AgendaCronJobs['remove'] = async (name) => { + this.jobNames.delete(name); + }; + + has: AgendaCronJobs['has'] = async (jobName) => { + return this.jobNames.has(jobName); + }; +} diff --git a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts new file mode 100644 index 0000000000000..d6fc603089ce9 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts @@ -0,0 +1,657 @@ +import { api } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { DeleteResult, UpdateResult } from 'mongodb'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { testPrivateMethod, createFreshServiceInstance } from '../utils'; +import { MockedCronJobs } from './mocks/cronJobs'; + +const settingsMock = new Map(); +const cronJobsMock = new MockedCronJobs(); + +const CalendarEventMock = { + insertOne: sinon.stub(), + findOne: sinon.stub(), + findByUserIdAndDate: sinon.stub(), + updateEvent: sinon.stub(), + deleteOne: sinon.stub(), + findNextNotificationDate: sinon.stub(), + findEventsToNotify: sinon.stub(), + flagNotificationSent: sinon.stub(), + findOneByExternalIdAndUserId: sinon.stub(), +}; + +const statusEventManagerMock = { + setupAppointmentStatusChange: sinon.stub().resolves(), + removeCronJobs: sinon.stub().resolves(), + cancelUpcomingStatusChanges: sinon.stub().resolves(), +}; + +const getUserPreferenceMock = sinon.stub(); + +const serviceMocks = { + './statusEvents/cancelUpcomingStatusChanges': { cancelUpcomingStatusChanges: statusEventManagerMock.cancelUpcomingStatusChanges }, + './statusEvents/removeCronJobs': { removeCronJobs: statusEventManagerMock.removeCronJobs }, + './statusEvents/setupAppointmentStatusChange': { setupAppointmentStatusChange: statusEventManagerMock.setupAppointmentStatusChange }, + '../../../app/settings/server': { settings: settingsMock }, + '@rocket.chat/core-services': { api, ServiceClassInternal: class {} }, + '@rocket.chat/cron': { cronJobs: cronJobsMock }, + '@rocket.chat/models': { CalendarEvent: CalendarEventMock }, + '../../../app/utils/server/lib/getUserPreference': { getUserPreference: getUserPreferenceMock }, +}; + +const { CalendarService } = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks); + +describe('CalendarService', () => { + let sandbox: sinon.SinonSandbox; + let service: InstanceType; + const fakeUserId = 'user123'; + const fakeEventId = 'event456'; + const fakeExternalId = 'external789'; + const fakeStartTime = new Date('2025-01-01T10:00:00Z'); + const fakeEndTime = new Date('2025-01-01T11:00:00Z'); + const fakeSubject = 'Test Meeting'; + const fakeDescription = 'This is a test meeting'; + const fakeMeetingUrl = 'https://meet.test/123'; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + service = new CalendarService(); + stubServiceMethods(); + setupCalendarEventMocks(); + setupStatusEventManagerMocks(); + setupOtherMocks(); + }); + + function stubServiceMethods() { + const proto = Object.getPrototypeOf(service); + sandbox.stub(proto, 'parseDescriptionForMeetingUrl').resolves(fakeMeetingUrl); + sandbox.stub(proto, 'findImportedEvent').callsFake(async (externalId, uid) => { + return CalendarEventMock.findOneByExternalIdAndUserId(externalId, uid); + }); + sandbox.stub(proto, 'sendEventNotification').resolves(); + sandbox.stub(proto, 'sendCurrentNotifications').resolves(); + sandbox.stub(proto, 'doSetupNextNotification').resolves(); + + sandbox.stub(service, 'setupNextNotification').resolves(); + } + + function setupCalendarEventMocks() { + const freshMocks = { + insertOne: sinon.stub().resolves({ insertedId: fakeEventId }), + findOne: sinon.stub().resolves(null), + findByUserIdAndDate: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), + updateEvent: sinon.stub().resolves({ modifiedCount: 1, matchedCount: 1 } as UpdateResult), + deleteOne: sinon.stub().resolves({ deletedCount: 1 } as DeleteResult), + findNextNotificationDate: sinon.stub().resolves(null), + findEventsToNotify: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), + flagNotificationSent: sinon.stub().resolves(), + findOneByExternalIdAndUserId: sinon.stub().resolves(null), + }; + + Object.assign(CalendarEventMock, freshMocks); + } + + function setupStatusEventManagerMocks() { + Object.values(statusEventManagerMock).forEach((stub) => stub.resetHistory()); + } + + function setupOtherMocks() { + sandbox.stub(api, 'broadcast').resolves(); + + settingsMock.clear(); + settingsMock.set( + 'Calendar_MeetingUrl_Regex', + '(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)', + ); + settingsMock.set('Calendar_BusyStatus_Enabled', true); + + cronJobsMock.jobNames.clear(); + + getUserPreferenceMock.reset(); + getUserPreferenceMock.resolves(true); + } + + afterEach(() => { + sandbox.restore(); + }); + + describe('#create', () => { + it('should create a new calendar event', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + description: fakeDescription, + meetingUrl: fakeMeetingUrl, + reminderMinutesBeforeStart: 5, + }; + + const result = await service.create(eventData); + + expect(result).to.equal(fakeEventId); + expect(CalendarEventMock.insertOne.callCount).to.equal(1); + expect(CalendarEventMock.insertOne.firstCall.args[0]).to.include({ + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + meetingUrl: fakeMeetingUrl, + reminderMinutesBeforeStart: 5, + notificationSent: false, + }); + sinon.assert.calledOnce(statusEventManagerMock.setupAppointmentStatusChange); + }); + + it('should create event without end time if not provided', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + }; + + await service.create(eventData); + + expect(CalendarEventMock.insertOne.firstCall.args[0]).to.not.have.property('endTime'); + }); + + it('should use default reminder minutes if not provided', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + }; + + await service.create(eventData); + + const insertedData = CalendarEventMock.insertOne.firstCall.args[0]; + expect(insertedData).to.have.property('reminderMinutesBeforeStart', 5); + }); + }); + + describe('#import', () => { + it('should create a new event if externalId is not provided', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + }; + + await service.import(eventData); + + sinon.assert.calledOnce(CalendarEventMock.insertOne); + sinon.assert.calledOnce(statusEventManagerMock.setupAppointmentStatusChange); + }); + + it('should create a new event if event with externalId not found', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + externalId: fakeExternalId, + }; + + CalendarEventMock.findOneByExternalIdAndUserId.resolves(null); + + await service.import(eventData); + + sinon.assert.calledWith(CalendarEventMock.findOneByExternalIdAndUserId, fakeExternalId, fakeUserId); + sinon.assert.calledOnce(CalendarEventMock.insertOne); + }); + + it('should update existing event if found by externalId', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + externalId: fakeExternalId, + }; + + CalendarEventMock.findOneByExternalIdAndUserId.resolves({ + _id: fakeEventId, + uid: fakeUserId, + externalId: fakeExternalId, + }); + + await service.import(eventData); + + sinon.assert.calledWith(CalendarEventMock.findOneByExternalIdAndUserId, fakeExternalId, fakeUserId); + sinon.assert.calledOnce(CalendarEventMock.updateEvent); + sinon.assert.notCalled(CalendarEventMock.insertOne); + }); + + it('should extract meeting URL from description if not provided', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: 'Description with callUrl=https://meet.test/123', + externalId: fakeExternalId, + }; + + const proto = Object.getPrototypeOf(service); + await service.import(eventData); + + sinon.assert.calledWith(proto.parseDescriptionForMeetingUrl as sinon.SinonStub, eventData.description); + }); + }); + + describe('#get', () => { + it('should retrieve a single event by ID', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const result = await service.get(fakeEventId); + + sinon.assert.calledWith(CalendarEventMock.findOne, { _id: fakeEventId }); + expect(result).to.equal(fakeEvent); + }); + }); + + describe('#list', () => { + it('should retrieve events for a user on a specific date', async () => { + const fakeEvents = [ + { _id: 'event1', uid: fakeUserId, startTime: fakeStartTime }, + { _id: 'event2', uid: fakeUserId, startTime: fakeStartTime }, + ]; + + CalendarEventMock.findByUserIdAndDate.returns({ + toArray: sinon.stub().resolves(fakeEvents), + }); + + const fakeDate = new Date('2025-01-01'); + const result = await service.list(fakeUserId, fakeDate); + + sinon.assert.calledWith(CalendarEventMock.findByUserIdAndDate, fakeUserId, fakeDate); + expect(result).to.equal(fakeEvents); + }); + }); + + describe('#update', () => { + it('should update an existing event', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const updateData = { + subject: 'Updated Subject', + description: 'Updated Description', + }; + + await service.update(fakeEventId, updateData); + + sinon.assert.calledWith(CalendarEventMock.updateEvent, fakeEventId, sinon.match.has('subject', 'Updated Subject')); + }); + + it('should do nothing if event not found', async () => { + CalendarEventMock.findOne.resolves(null); + + await service.update(fakeEventId, { subject: 'New Subject' }); + + sinon.assert.notCalled(CalendarEventMock.updateEvent); + }); + + it('should update cron jobs when start/end times change', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const newStartTime = new Date('2025-01-02T10:00:00Z'); + const newEndTime = new Date('2025-01-02T11:00:00Z'); + + await service.update(fakeEventId, { + startTime: newStartTime, + endTime: newEndTime, + }); + + sinon.assert.calledOnce(statusEventManagerMock.removeCronJobs); + sinon.assert.calledOnce(statusEventManagerMock.setupAppointmentStatusChange); + }); + + it('should extract meeting URL from description if not provided', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const proto = Object.getPrototypeOf(service); + + await service.update(fakeEventId, { + description: 'Description with callUrl=https://meet.test/123', + }); + + sinon.assert.called(proto.parseDescriptionForMeetingUrl as sinon.SinonStub); + }); + + it('should setup next notification if event was modified', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + CalendarEventMock.updateEvent.resolves({ modifiedCount: 1 } as UpdateResult); + + await service.update(fakeEventId, { subject: 'New Subject' }); + + sinon.assert.calledOnce(service.setupNextNotification as sinon.SinonStub); + }); + }); + + describe('#delete', () => { + it('should delete an event and remove cron jobs', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + await service.delete(fakeEventId); + + sinon.assert.calledOnce(statusEventManagerMock.removeCronJobs); + sinon.assert.calledWith(CalendarEventMock.deleteOne, { _id: fakeEventId }); + }); + + it('should only delete the event if not found', async () => { + CalendarEventMock.findOne.resolves(null); + + await service.delete(fakeEventId); + + sinon.assert.notCalled(statusEventManagerMock.removeCronJobs); + sinon.assert.calledOnce(CalendarEventMock.deleteOne); + }); + }); + + describe('#setupNextNotification', () => { + it('should call doSetupNextNotification internally', async () => { + const serviceExports = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks); + + const testService = createFreshServiceInstance>(serviceExports); + + const localSandbox = sinon.createSandbox(); + + try { + const doSetupStub = localSandbox.stub(Object.getPrototypeOf(testService), 'doSetupNextNotification').resolves(); + + await testService.setupNextNotification(); + + sinon.assert.calledOnceWithExactly(doSetupStub, false); + } finally { + localSandbox.restore(); + } + }); + }); + + describe('#cancelUpcomingStatusChanges', () => { + it('should delegate to statusEventManager', async () => { + await service.cancelUpcomingStatusChanges(fakeUserId); + + sinon.assert.calledWith(statusEventManagerMock.cancelUpcomingStatusChanges, fakeUserId); + }); + + it('should pass custom end time if provided', async () => { + const customDate = new Date('2025-02-01'); + + await service.cancelUpcomingStatusChanges(fakeUserId, customDate); + + sinon.assert.calledWith(statusEventManagerMock.cancelUpcomingStatusChanges, fakeUserId, customDate); + }); + }); + + describe('Private: parseDescriptionForMeetingUrl', () => { + it('should return undefined for empty description', async () => { + await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => { + const result = await method(''); + expect(result).to.be.undefined; + }); + }); + + it('should extract URL from description with default pattern', async () => { + await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => { + const testDescription = 'Join at https://meet.example.com?callUrl=https://special-meeting.com/123'; + const result = await method(testDescription); + expect(result).to.equal('https://special-meeting.com/123'); + }); + }); + + it('should return undefined if regex pattern is empty', async () => { + await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => { + settingsMock.set('Calendar_MeetingUrl_Regex', ''); + + const result = await method('Test description with no pattern match'); + expect(result).to.be.undefined; + }); + }); + + it('should handle URL decoding', async () => { + await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => { + const encodedUrl = 'Join meeting at link with callUrl%3Dhttps%3A%2F%2Fmeeting.example.com%2F123'; + const result = await method(encodedUrl); + expect(result).to.include('https://meeting.example.com/123'); + }); + }); + }); + + describe('Private: findImportedEvent', () => { + it('should call the model method with correct parameters', async () => { + await testPrivateMethod(service, 'findImportedEvent', async (method) => { + await method(fakeExternalId, fakeUserId); + sinon.assert.calledWith(CalendarEventMock.findOneByExternalIdAndUserId, fakeExternalId, fakeUserId); + }); + }); + + it('should return the event when found', async () => { + await testPrivateMethod(service, 'findImportedEvent', async (method) => { + const fakeEvent = { _id: fakeEventId, externalId: fakeExternalId, uid: fakeUserId }; + CalendarEventMock.findOneByExternalIdAndUserId.resolves(fakeEvent); + + const result = await method(fakeExternalId, fakeUserId); + expect(result).to.equal(fakeEvent); + }); + }); + + it('should return null when event not found', async () => { + await testPrivateMethod(service, 'findImportedEvent', async (method) => { + CalendarEventMock.findOneByExternalIdAndUserId.resolves(null); + + const result = await method(fakeExternalId, fakeUserId); + expect(result).to.be.null; + }); + }); + }); + + describe('Private: sendEventNotification', () => { + it('should not send notification if user preference is disabled', async () => { + await testPrivateMethod(service, 'sendEventNotification', async (method) => { + getUserPreferenceMock.resolves(false); + + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + await method(fakeEvent); + + sinon.assert.calledWith(getUserPreferenceMock, fakeUserId, 'notifyCalendarEvents'); + sinon.assert.notCalled(api.broadcast as sinon.SinonStub); + }); + }); + + it('should send notification with correct event data', async () => { + await testPrivateMethod(service, 'sendEventNotification', async (method) => { + getUserPreferenceMock.resolves(true); + + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + await method(fakeEvent); + + sinon.assert.calledWith( + api.broadcast as sinon.SinonStub, + 'notify.calendar', + fakeUserId, + sinon.match({ + title: fakeSubject, + payload: { _id: fakeEventId }, + }), + ); + }); + }); + }); + + describe('Private: sendCurrentNotifications', () => { + it('should send notification for all events and flag them as sent', async () => { + await testPrivateMethod(service, 'sendCurrentNotifications', async (method) => { + const proto = Object.getPrototypeOf(service); + (proto.sendEventNotification as sinon.SinonStub).restore(); + sandbox.stub(proto, 'sendEventNotification').resolves(); + + const fakeDate = new Date('2025-01-01T10:00:00Z'); + const fakeEvents = [ + { _id: 'event1', uid: fakeUserId, startTime: fakeStartTime }, + { _id: 'event2', uid: fakeUserId, startTime: fakeStartTime }, + ]; + + CalendarEventMock.findEventsToNotify.returns({ + toArray: sinon.stub().resolves(fakeEvents), + }); + + await method(fakeDate); + + sinon.assert.calledWith(CalendarEventMock.findEventsToNotify, fakeDate, 1); + sinon.assert.calledTwice(proto.sendEventNotification as sinon.SinonStub); + sinon.assert.calledTwice(CalendarEventMock.flagNotificationSent); + sinon.assert.calledWith(CalendarEventMock.flagNotificationSent, 'event1'); + sinon.assert.calledWith(CalendarEventMock.flagNotificationSent, 'event2'); + sinon.assert.calledOnceWithExactly(proto.doSetupNextNotification as sinon.SinonStub, true); + }); + }); + }); + + describe('Private: doSetupNextNotification', () => { + it('should remove calendar-reminders cron job if no events found', async () => { + await testPrivateMethod(service, 'doSetupNextNotification', async (method) => { + CalendarEventMock.findNextNotificationDate.resolves(null); + cronJobsMock.jobNames.add('calendar-reminders'); + + await method(false); + + expect(cronJobsMock.jobNames.has('calendar-reminders')).to.false; + }); + }); + + it('should schedule notifications at the next date', async () => { + await testPrivateMethod(service, 'doSetupNextNotification', async (method) => { + const nextDate = new Date('2025-01-01T10:00:00Z'); + CalendarEventMock.findNextNotificationDate.resolves(nextDate); + + await method(false); + + expect(cronJobsMock.jobNames.has('calendar-reminders')).to.true; + }); + }); + + it('should send current notifications if date is in the past', async () => { + await testPrivateMethod(service, 'doSetupNextNotification', async (method) => { + const proto = Object.getPrototypeOf(service); + (proto.sendCurrentNotifications as sinon.SinonStub).restore(); + sandbox.stub(proto, 'sendCurrentNotifications').resolves(); + + const pastDate = new Date(); + pastDate.setMinutes(pastDate.getMinutes() - 10); + CalendarEventMock.findNextNotificationDate.resolves(pastDate); + + await method(false); + + sinon.assert.calledWith(proto.sendCurrentNotifications as sinon.SinonStub, pastDate); + expect(cronJobsMock.jobNames.size).to.equal(0); + }); + }); + + it('should schedule future notifications even if date is in the past when recursive', async () => { + await testPrivateMethod(service, 'doSetupNextNotification', async (method) => { + const pastDate = new Date(); + pastDate.setMinutes(pastDate.getMinutes() - 10); + CalendarEventMock.findNextNotificationDate.resolves(pastDate); + + await method(true); + + sinon.assert.notCalled(service.sendCurrentNotifications as sinon.SinonStub); + expect(cronJobsMock.jobNames.size).to.equal(1); + }); + }); + }); + + describe('Overlapping events', () => { + it('should not set up status change if no endTime is provided when updating', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + await service.update(fakeEventId, { + subject: 'New Subject', + }); + + sinon.assert.notCalled(statusEventManagerMock.setupAppointmentStatusChange); + }); + + it('should cancel upcoming status changes for a user', async () => { + const customDate = new Date('2025-02-01'); + + await service.cancelUpcomingStatusChanges(fakeUserId, customDate); + + sinon.assert.calledOnce(statusEventManagerMock.cancelUpcomingStatusChanges); + sinon.assert.calledWith(statusEventManagerMock.cancelUpcomingStatusChanges, fakeUserId, customDate); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts new file mode 100644 index 0000000000000..30053e86cc1e7 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts @@ -0,0 +1,169 @@ +import { api } from '@rocket.chat/core-services'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const UsersMock = { + findOneById: sinon.stub(), + updateOne: sinon.stub(), + updateStatusAndStatusDefault: sinon.stub().resolves(), +}; + +const setupAppointmentStatusChange = sinon.stub().resolves(); + +const { applyStatusChange } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/statusEvents/applyStatusChange', { + './setupAppointmentStatusChange': { setupAppointmentStatusChange }, + '@rocket.chat/core-services': { api }, + '@rocket.chat/models': { + Users: UsersMock, + }, +}); + +describe('Calendar.StatusEvents', () => { + let sandbox: sinon.SinonSandbox; + const fakeEventId = 'eventId123'; + const fakeUserId = 'userId456'; + const fakeStartTime = new Date('2025-01-01T10:00:00Z'); + const fakeEndTime = new Date('2025-01-01T11:00:00Z'); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + setupUsersMocks(); + setupOtherMocks(); + }); + + function setupUsersMocks() { + const freshMocks = { + findOneById: sinon.stub().resolves({ + _id: fakeUserId, + status: UserStatus.ONLINE, + roles: ['user'], + username: 'testuser', + name: 'Test User', + } as any), + updateOne: sinon.stub().resolves({ modifiedCount: 1 } as any), + updateStatusAndStatusDefault: sinon.stub().resolves(), + }; + + Object.assign(UsersMock, freshMocks); + } + + function setupOtherMocks() { + sandbox.stub(api, 'broadcast').resolves(); + + setupAppointmentStatusChange.resetHistory(); + } + + afterEach(() => { + sandbox.restore(); + }); + + describe('#applyStatusChange', () => { + it('should do nothing if user is not found', async () => { + UsersMock.findOneById.resolves(null); + + await applyStatusChange({ + eventId: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + status: undefined, + shouldScheduleRemoval: false, + }); + + expect(UsersMock.updateStatusAndStatusDefault.callCount).to.equal(0); + expect((api.broadcast as sinon.SinonStub).callCount).to.equal(0); + }); + + it('should do nothing if user is offline', async () => { + UsersMock.findOneById.resolves({ + _id: fakeUserId, + status: UserStatus.OFFLINE, + }); + + await applyStatusChange({ + eventId: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + status: undefined, + shouldScheduleRemoval: false, + }); + + expect(UsersMock.updateStatusAndStatusDefault.callCount).to.equal(0); + expect((api.broadcast as sinon.SinonStub).callCount).to.equal(0); + }); + + it('should use UserStatus.BUSY as default if no status provided', async () => { + UsersMock.updateStatusAndStatusDefault.resetHistory(); + + await applyStatusChange({ + eventId: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + status: undefined, + shouldScheduleRemoval: false, + }); + + expect(UsersMock.updateStatusAndStatusDefault.callCount).to.equal(1); + expect(UsersMock.updateStatusAndStatusDefault.firstCall.args[1]).to.equal(UserStatus.BUSY); + }); + + it('should update user status and broadcast presence update', async () => { + const previousStatus = UserStatus.ONLINE; + const newStatus = UserStatus.AWAY; + + UsersMock.updateStatusAndStatusDefault.resetHistory(); + (api.broadcast as sinon.SinonStub).resetHistory(); + + UsersMock.findOneById.resolves({ + _id: fakeUserId, + status: previousStatus, + roles: ['user'], + username: 'testuser', + name: 'Test User', + }); + + await applyStatusChange({ + eventId: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + status: newStatus, + shouldScheduleRemoval: false, + }); + + expect(UsersMock.updateStatusAndStatusDefault.callCount).to.equal(1); + expect(UsersMock.updateStatusAndStatusDefault.firstCall.args).to.deep.equal([fakeUserId, newStatus, newStatus]); + + expect((api.broadcast as sinon.SinonStub).callCount).to.equal(1); + expect((api.broadcast as sinon.SinonStub).firstCall.args[0]).to.equal('presence.status'); + }); + + it('should schedule status revert when shouldScheduleRemoval=true', async () => { + const previousStatus = UserStatus.ONLINE; + + await applyStatusChange({ + eventId: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + status: UserStatus.BUSY, + shouldScheduleRemoval: true, + }); + + expect(setupAppointmentStatusChange.callCount).to.equal(1); + expect(setupAppointmentStatusChange.firstCall.args).to.deep.equal([ + fakeEventId, + fakeUserId, + fakeStartTime, + fakeEndTime, + previousStatus, + false, + ]); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.tests.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.tests.ts new file mode 100644 index 0000000000000..3b0e23b8ffd32 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/cancelUpcomingStatusChanges.tests.ts @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { MockedCronJobs } from '../mocks/cronJobs'; + +const settingsMock = new Map(); + +const fakeUserId = 'userId456'; +const CalendarEventMock = { + findEligibleEventsForCancelation: sinon.stub().returns({ + toArray: sinon.stub().resolves([ + { _id: 'event1', uid: fakeUserId }, + { _id: 'event2', uid: fakeUserId }, + ]), + }), +}; + +const cronJobsMock = new MockedCronJobs(); + +const { cancelUpcomingStatusChanges } = proxyquire + .noCallThru() + .load('../../../../../../server/services/calendar/statusEvents/cancelUpcomingStatusChanges', { + '../../../../app/settings/server': { settings: settingsMock }, + '@rocket.chat/cron': { cronJobs: cronJobsMock }, + '@rocket.chat/models': { + CalendarEvent: CalendarEventMock, + }, + }); + +describe('Calendar.StatusEvents', () => { + describe('#cancelUpcomingStatusChanges', () => { + it('should do nothing if busy status setting is disabled', async () => { + settingsMock.set('Calendar_BusyStatus_Enabled', false); + + const events = [ + { _id: 'event1', uid: fakeUserId }, + { _id: 'event2', uid: fakeUserId }, + ]; + + cronJobsMock.jobNames.clear(); + cronJobsMock.jobNames.add(`calendar-presence-status-event1-${fakeUserId}`); + cronJobsMock.jobNames.add(`calendar-presence-status-event2-${fakeUserId}`); + cronJobsMock.jobNames.add(`calendar-presence-status-event3-${fakeUserId}`); + + CalendarEventMock.findEligibleEventsForCancelation.returns({ + toArray: sinon.stub().resolves(events), + }); + + await cancelUpcomingStatusChanges(fakeUserId); + + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event1-${fakeUserId}`)).to.true; + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event2-${fakeUserId}`)).to.true; + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event3-${fakeUserId}`)).to.true; + }); + + it('should find and cancel active events', async () => { + settingsMock.set('Calendar_BusyStatus_Enabled', true); + + const events = [ + { _id: 'event1', uid: fakeUserId }, + { _id: 'event2', uid: fakeUserId }, + ]; + + cronJobsMock.jobNames.clear(); + cronJobsMock.jobNames.add(`calendar-presence-status-event1-${fakeUserId}`); + cronJobsMock.jobNames.add(`calendar-presence-status-event2-${fakeUserId}`); + cronJobsMock.jobNames.add(`calendar-presence-status-event3-${fakeUserId}`); + + CalendarEventMock.findEligibleEventsForCancelation.returns({ + toArray: sinon.stub().resolves(events), + }); + + await cancelUpcomingStatusChanges(fakeUserId); + + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event1-${fakeUserId}`)).to.false; + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event2-${fakeUserId}`)).to.false; + expect(cronJobsMock.jobNames.has(`calendar-presence-status-event3-${fakeUserId}`)).to.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/generateCronJobId.tests.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/generateCronJobId.tests.ts new file mode 100644 index 0000000000000..cbc8ab277c5b1 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/generateCronJobId.tests.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; + +const { generateCronJobId } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/statusEvents/generateCronJobId', {}); + +describe('#generateCronJobId', () => { + const fakeEventId = 'eventId123'; + const fakeUserId = 'userId456'; + + it('should generate correct ID for status events', () => { + const id = generateCronJobId(fakeEventId, fakeUserId, 'status'); + expect(id).to.equal(`calendar-presence-status-${fakeEventId}-${fakeUserId}`); + }); + + it('should generate correct ID for reminder events', () => { + const id = generateCronJobId(fakeEventId, fakeUserId, 'reminder'); + expect(id).to.equal(`calendar-reminder-${fakeEventId}-${fakeUserId}`); + }); + + it('should throw an error if some required parameters are missing', () => { + expect(() => generateCronJobId(undefined, fakeUserId, 'status')).to.throw( + 'Missing required parameters. Please provide eventId, uid and eventType (status or reminder)', + ); + expect(() => generateCronJobId(fakeEventId, undefined, 'status')).to.throw( + 'Missing required parameters. Please provide eventId, uid and eventType (status or reminder)', + ); + expect(() => generateCronJobId(fakeEventId, fakeUserId)).to.throw( + 'Missing required parameters. Please provide eventId, uid and eventType (status or reminder)', + ); + }); + + it('should throw an error if eventType is not "status" or "reminder"', () => { + expect(() => generateCronJobId(fakeEventId, fakeUserId, 'invalid' as any)).to.throw( + 'Missing required parameters. Please provide eventId, uid and eventType (status or reminder)', + ); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/handleOverlappingEvents.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/handleOverlappingEvents.ts new file mode 100644 index 0000000000000..d9997d4bac80f --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/handleOverlappingEvents.ts @@ -0,0 +1,164 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { MockedCronJobs } from '../mocks/cronJobs'; + +const CalendarEventMock = { + findOverlappingEvents: sinon.stub(), +}; + +const cronJobsMock = new MockedCronJobs(); + +const applyStatusChange = sinon.stub(); + +const { handleOverlappingEvents } = proxyquire + .noCallThru() + .load('../../../../../../server/services/calendar/statusEvents/handleOverlappingEvents', { + './applyStatusChange': { applyStatusChange }, + '@rocket.chat/cron': { cronJobs: cronJobsMock }, + '@rocket.chat/models': { + CalendarEvent: CalendarEventMock, + }, + }); + +describe('Calendar.StatusEvents', () => { + const fakeEventId = 'eventId123'; + const fakeUserId = 'userId456'; + const fakeStartTime = new Date('2025-01-01T10:00:00Z'); + const fakeEndTime = new Date('2025-01-01T11:00:00Z'); + const statusId = `calendar-presence-status-${fakeEventId}-${fakeUserId}`; + const containedStatusId = `calendar-presence-status-containedEvent-${fakeUserId}`; + + beforeEach(() => { + cronJobsMock.jobNames.clear(); + setupCalendarEventMocks(); + applyStatusChange.resetHistory(); + }); + + function setupCalendarEventMocks() { + CalendarEventMock.findOverlappingEvents.reset(); + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([]), + }); + } + + describe('#handleOverlappingEvents', () => { + it('should return shouldProceed=true when no overlapping events', async () => { + // Clear previous calls + CalendarEventMock.findOverlappingEvents.reset(); + + // Set up the mock to return no overlapping events + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: true }); + expect(cronJobsMock.jobNames.size).to.equal(0); + sinon.assert.calledWith(CalendarEventMock.findOverlappingEvents, fakeEventId, fakeUserId, fakeStartTime, fakeEndTime); + }); + + it('should handle case when current event is not the latest ending', async () => { + const laterEvent = { + _id: 'laterEvent', + startTime: fakeStartTime, + endTime: new Date('2025-01-01T12:00:00Z'), // Later than fakeEndTime + }; + + // Mock a specific response for this test + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([laterEvent]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: false }); + expect(cronJobsMock.jobNames.has(statusId)).to.equal(true); + }); + + it('should remove status jobs for events ending before the current one', async () => { + const earlierEvent = { + _id: 'earlierEvent', + startTime: new Date('2025-01-01T09:00:00Z'), + endTime: new Date('2025-01-01T10:30:00Z'), // Earlier than fakeEndTime + }; + + // Set up has to return true for the specific job ID + cronJobsMock.jobNames.add(statusId); + + // Mock a specific response for this test + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([earlierEvent]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: true }); + expect(cronJobsMock.jobNames.has(statusId)).to.equal(false); + }); + + it('should handle multiple overlapping events with different end times', async () => { + const earlierEvent = { + _id: 'earlierEvent', + startTime: new Date('2025-01-01T09:00:00Z'), + endTime: new Date('2025-01-01T10:30:00Z'), // Earlier than fakeEndTime + }; + + const laterEvent = { + _id: 'laterEvent', + startTime: new Date('2025-01-01T10:30:00Z'), + endTime: new Date('2025-01-01T12:00:00Z'), // Later than fakeEndTime + }; + + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([earlierEvent, laterEvent]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: false }); + expect(cronJobsMock.jobNames.has(statusId)).to.be.true; + }); + + it('should handle an event completely contained within the current event', async () => { + const containedEvent = { + _id: 'containedEvent', + startTime: new Date('2025-01-01T10:15:00Z'), // After fakeStartTime + endTime: new Date('2025-01-01T10:45:00Z'), // Before fakeEndTime + }; + + cronJobsMock.jobNames.add(statusId); + + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([containedEvent]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: true }); + expect(cronJobsMock.jobNames.has(statusId)).to.be.false; + expect(cronJobsMock.jobNames.has(containedStatusId)).to.be.true; + }); + + it('should handle an event that completely contains the current event', async () => { + const containingEvent = { + _id: 'containingEvent', + startTime: new Date('2025-01-01T09:00:00Z'), // Before fakeStartTime + endTime: new Date('2025-01-01T12:00:00Z'), // After fakeEndTime + }; + + CalendarEventMock.findOverlappingEvents.returns({ + toArray: sinon.stub().resolves([containingEvent]), + }); + + const result = await handleOverlappingEvents(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY); + + expect(result).to.deep.equal({ shouldProceed: false }); + expect(cronJobsMock.jobNames.has(statusId)).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/removeCronJobs.tests.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/removeCronJobs.tests.ts new file mode 100644 index 0000000000000..ec160abe51d25 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/removeCronJobs.tests.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; + +import { MockedCronJobs } from '../mocks/cronJobs'; + +const cronJobsMock = new MockedCronJobs(); + +const { removeCronJobs } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/statusEvents/removeCronJobs', { + '@rocket.chat/cron': { cronJobs: cronJobsMock }, +}); + +describe('Calendar.StatusEvents', () => { + const fakeEventId = 'eventId123'; + const fakeUserId = 'userId456'; + const fakeUserId2 = 'userId4562'; + + const statusId = `calendar-presence-status-${fakeEventId}-${fakeUserId}`; + const reminderId = `calendar-reminder-${fakeEventId}-${fakeUserId}`; + const statusId2 = `calendar-presence-status-${fakeEventId}-${fakeUserId2}`; + const reminderId2 = `calendar-reminder-${fakeEventId}-${fakeUserId2}`; + + beforeEach(() => { + cronJobsMock.jobNames.clear(); + }); + + describe('#removeCronJobs', () => { + it('should check and remove status and reminder jobs', async () => { + cronJobsMock.jobNames.clear(); + cronJobsMock.jobNames.add(statusId); + cronJobsMock.jobNames.add(reminderId); + + await removeCronJobs(fakeEventId, fakeUserId); + + expect(cronJobsMock.jobNames.has(statusId)).to.equal(false); + expect(cronJobsMock.jobNames.has(reminderId)).to.equal(false); + }); + + it('should not remove jobs from other users', async () => { + cronJobsMock.jobNames.clear(); + cronJobsMock.jobNames.add(statusId); + cronJobsMock.jobNames.add(reminderId); + cronJobsMock.jobNames.add(statusId2); + cronJobsMock.jobNames.add(reminderId2); + + await removeCronJobs(fakeEventId, fakeUserId); + + expect(cronJobsMock.jobNames.has(statusId)).to.equal(false); + expect(cronJobsMock.jobNames.has(reminderId)).to.equal(false); + expect(cronJobsMock.jobNames.has(statusId2)).to.equal(true); + expect(cronJobsMock.jobNames.has(reminderId2)).to.equal(true); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts new file mode 100644 index 0000000000000..7df232de9c700 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts @@ -0,0 +1,77 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { MockedCronJobs } from '../mocks/cronJobs'; + +const settingsMock = new Map(); +const cronJobsMock = new MockedCronJobs(); + +const applyStatusChange = sinon.stub(); +const handleOverlappingEvents = sinon.stub(); + +const { setupAppointmentStatusChange } = proxyquire + .noCallThru() + .load('../../../../../../server/services/calendar/statusEvents/setupAppointmentStatusChange', { + './applyStatusChange': { applyStatusChange }, + './handleOverlappingEvents': { handleOverlappingEvents }, + '../../../../app/settings/server': { settings: settingsMock }, + '@rocket.chat/cron': { cronJobs: cronJobsMock }, + }); + +describe('Calendar.StatusEvents', () => { + const fakeEventId = 'eventId123'; + const fakeUserId = 'userId456'; + const fakeStartTime = new Date('2025-01-01T10:00:00Z'); + const fakeEndTime = new Date('2025-01-01T11:00:00Z'); + const statusId = `calendar-presence-status-${fakeEventId}-${fakeUserId}`; + + beforeEach(() => { + cronJobsMock.jobNames.clear(); + applyStatusChange.resetHistory(); + handleOverlappingEvents.resetHistory(); + settingsMock.clear(); + settingsMock.set('Calendar_BusyStatus_Enabled', true); + }); + + describe('#setupAppointmentStatusChange', () => { + it('should do nothing if busy status setting is disabled', async () => { + settingsMock.set('Calendar_BusyStatus_Enabled', false); + + await setupAppointmentStatusChange(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, undefined, false); + + expect(cronJobsMock.jobNames.size).to.equal(0); + }); + + it('should do nothing if endTime is not provided', async () => { + await setupAppointmentStatusChange(fakeEventId, fakeUserId, fakeStartTime, undefined, undefined, false); + + expect(cronJobsMock.jobNames.size).to.equal(0); + }); + + it('should handle overlapping events when shouldScheduleRemoval=true', async () => { + handleOverlappingEvents.resolves({ shouldProceed: false }); + + await setupAppointmentStatusChange(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY, true); + + expect(handleOverlappingEvents.callCount).to.equal(1); + expect(cronJobsMock.jobNames.size).to.equal(0); + }); + + it('should schedule status change at the start time when shouldScheduleRemoval=true', async () => { + handleOverlappingEvents.resolves({ shouldProceed: true }); + + await setupAppointmentStatusChange(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY, true); + + expect(cronJobsMock.jobNames.has(statusId)).to.true; + }); + + it('should schedule status change at the end time when shouldScheduleRemoval=false', async () => { + await setupAppointmentStatusChange(fakeEventId, fakeUserId, fakeStartTime, fakeEndTime, UserStatus.BUSY, false); + + expect(cronJobsMock.jobNames.has(statusId)).to.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts b/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts new file mode 100644 index 0000000000000..8f068589a895a --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; + +const { getShiftedTime } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/utils/getShiftedTime', {}); + +describe('#getShiftedTime', () => { + it('should shift time forward by minutes', () => { + const date = new Date('2025-01-01T10:00:00Z'); + const result = getShiftedTime(date, 30); + + expect(result.getTime()).to.equal(new Date('2025-01-01T10:30:00Z').getTime()); + expect(date.getTime()).to.equal(new Date('2025-01-01T10:00:00Z').getTime()); + }); + + it('should shift time backward by negative minutes', () => { + const date = new Date('2025-01-01T10:00:00Z'); + const result = getShiftedTime(date, -15); + + expect(result.getTime()).to.equal(new Date('2025-01-01T09:45:00Z').getTime()); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/utils.ts b/apps/meteor/tests/unit/server/services/utils.ts new file mode 100644 index 0000000000000..20ab317cbd641 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/utils.ts @@ -0,0 +1,28 @@ +import sinon from 'sinon'; + +export async function testPrivateMethod any>( + service: any, + methodName: string, + testFn: (method: T) => Promise | void, +): Promise { + const proto = Object.getPrototypeOf(service); + const originalMethod = proto[methodName]; + const isStubbed = originalMethod && 'restore' in originalMethod; + + if (isStubbed) { + (originalMethod as sinon.SinonStub).restore(); + } + + const method = proto[methodName]; + void testFn(method.bind(service)); + + if (isStubbed) { + sinon.stub(proto, methodName).callsFake(originalMethod); + } +} + +export function createFreshServiceInstance(moduleExports: any, serviceName?: string): T { + const ServiceClass = serviceName ? moduleExports[serviceName] : Object.values(moduleExports)[0]; + + return new ServiceClass(); +} diff --git a/packages/core-services/src/types/ICalendarService.ts b/packages/core-services/src/types/ICalendarService.ts index 098dccadd2e86..f74b63b056b81 100644 --- a/packages/core-services/src/types/ICalendarService.ts +++ b/packages/core-services/src/types/ICalendarService.ts @@ -7,9 +7,8 @@ export interface ICalendarService { import(data: Omit, 'notificationSent'>): Promise; get(eventId: ICalendarEvent['_id']): Promise; list(uid: IUser['_id'], date: Date): Promise; - update(eventId: ICalendarEvent['_id'], data: Partial): Promise; + update(eventId: ICalendarEvent['_id'], data: Partial): Promise; delete(eventId: ICalendarEvent['_id']): Promise; - findImportedEvent(externalId: Required['externalId'], uid: ICalendarEvent['uid']): Promise; - parseDescriptionForMeetingUrl(description: string): Promise; setupNextNotification(): Promise; + cancelUpcomingStatusChanges(uid: IUser['_id'], endTime?: Date): Promise; } diff --git a/packages/core-typings/src/ICalendarEvent.ts b/packages/core-typings/src/ICalendarEvent.ts index 6bb0a7bb58e35..fb58bde124c43 100644 --- a/packages/core-typings/src/ICalendarEvent.ts +++ b/packages/core-typings/src/ICalendarEvent.ts @@ -3,6 +3,8 @@ import type { IUser } from './IUser'; export interface ICalendarEvent extends IRocketChatRecord { startTime: Date; + endTime?: Date; + uid: IUser['_id']; subject: string; description: string; @@ -13,4 +15,6 @@ export interface ICalendarEvent extends IRocketChatRecord { reminderMinutesBeforeStart?: number; reminderTime?: Date; + + busy?: boolean; } diff --git a/packages/model-typings/src/models/ICalendarEventModel.ts b/packages/model-typings/src/models/ICalendarEventModel.ts index 4d2f9035237c9..24c4bc30ac0a3 100644 --- a/packages/model-typings/src/models/ICalendarEventModel.ts +++ b/packages/model-typings/src/models/ICalendarEventModel.ts @@ -13,4 +13,6 @@ export interface ICalendarEventModel extends IBaseModel { externalId: Required['externalId'], uid: ICalendarEvent['uid'], ): Promise; + findOverlappingEvents(eventId: ICalendarEvent['_id'], uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor; + findEligibleEventsForCancelation(uid: IUser['_id'], endTime: Date): FindCursor; } diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 73bcbb5c02812..b3503b493bf2e 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -226,6 +226,8 @@ export interface IUsersModel extends IBaseModel { }: { statusDefault?: UserStatus; status: UserStatus; statusConnection: UserStatus; statusText?: string }, ): Promise; + updateStatusAndStatusDefault(userId: string, status: UserStatus, statusDefault: UserStatus): Promise; + setFederationAvatarUrlById(userId: IUser['_id'], federationAvatarUrl: string): Promise; findSearchedServerNamesByUserId(userId: IUser['_id']): Promise; diff --git a/packages/models/src/models/CalendarEvent.ts b/packages/models/src/models/CalendarEvent.ts index a446dec168b33..4de0c4a638e1a 100644 --- a/packages/models/src/models/CalendarEvent.ts +++ b/packages/models/src/models/CalendarEvent.ts @@ -121,4 +121,32 @@ export class CalendarEventRaw extends BaseRaw implements ICalend }, ); } + + public findOverlappingEvents( + eventId: ICalendarEvent['_id'], + uid: IUser['_id'], + startTime: Date, + endTime: Date, + ): FindCursor { + return this.find({ + _id: { $ne: eventId }, // Exclude current event + uid, + $or: [ + // Event starts during our event + { startTime: { $gte: startTime, $lt: endTime } }, + // Event ends during our event + { endTime: { $gt: startTime, $lte: endTime } }, + // Event completely contains our event + { startTime: { $lte: startTime }, endTime: { $gte: endTime } }, + ], + }); + } + + public findEligibleEventsForCancelation(uid: IUser['_id'], endTime: Date): FindCursor { + return this.find({ + uid, + startTime: { $exists: true, $lte: endTime }, + endTime: { $exists: true, $gte: endTime }, + }); + } } diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 9f9cb54886e32..4408d494c2404 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -990,6 +990,27 @@ export class UsersRaw extends BaseRaw> implements IU return this.updateOne({ _id }, update, { session: options?.session }); } + updateStatus(_id: IUser['_id'], status: UserStatus) { + const update = { + $set: { + status, + }, + }; + + return this.updateOne({ _id }, update); + } + + updateStatusAndStatusDefault(_id: IUser['_id'], status: UserStatus, statusDefault: UserStatus) { + const update = { + $set: { + status, + statusDefault, + }, + }; + + return this.updateOne({ _id }, update); + } + updateStatusByAppId(appId: string, status: UserStatus) { const query = { appId, diff --git a/packages/rest-typings/src/v1/calendar/CalendarEventCreateProps.ts b/packages/rest-typings/src/v1/calendar/CalendarEventCreateProps.ts index 5358510c97abe..2a2e378d1b6e7 100644 --- a/packages/rest-typings/src/v1/calendar/CalendarEventCreateProps.ts +++ b/packages/rest-typings/src/v1/calendar/CalendarEventCreateProps.ts @@ -5,11 +5,13 @@ const ajv = new Ajv(); export type CalendarEventCreateProps = { startTime: string; + endTime?: string; externalId?: string; subject: string; description: string; meetingUrl?: string; reminderMinutesBeforeStart?: number; + busy?: boolean; }; const calendarEventCreatePropsSchema: JSONSchemaType = { @@ -19,6 +21,10 @@ const calendarEventCreatePropsSchema: JSONSchemaType = type: 'string', nullable: false, }, + endTime: { + type: 'string', + nullable: true, + }, externalId: { type: 'string', nullable: true, @@ -39,6 +45,10 @@ const calendarEventCreatePropsSchema: JSONSchemaType = type: 'number', nullable: true, }, + busy: { + type: 'boolean', + nullable: true, + }, }, required: ['startTime', 'subject', 'description'], additionalProperties: false, diff --git a/packages/rest-typings/src/v1/calendar/CalendarEventImportProps.ts b/packages/rest-typings/src/v1/calendar/CalendarEventImportProps.ts index 955184a36115c..af9cbce1086a0 100644 --- a/packages/rest-typings/src/v1/calendar/CalendarEventImportProps.ts +++ b/packages/rest-typings/src/v1/calendar/CalendarEventImportProps.ts @@ -5,11 +5,13 @@ const ajv = new Ajv(); export type CalendarEventImportProps = { startTime: string; + endTime?: string; externalId: string; subject: string; description: string; meetingUrl?: string; reminderMinutesBeforeStart?: number; + busy?: boolean; }; const calendarEventImportPropsSchema: JSONSchemaType = { @@ -19,6 +21,10 @@ const calendarEventImportPropsSchema: JSONSchemaType = type: 'string', nullable: false, }, + endTime: { + type: 'string', + nullable: true, + }, externalId: { type: 'string', nullable: false, @@ -39,6 +45,10 @@ const calendarEventImportPropsSchema: JSONSchemaType = type: 'number', nullable: true, }, + busy: { + type: 'boolean', + nullable: true, + }, }, required: ['startTime', 'externalId', 'subject', 'description'], additionalProperties: false, diff --git a/packages/rest-typings/src/v1/calendar/CalendarEventUpdateProps.ts b/packages/rest-typings/src/v1/calendar/CalendarEventUpdateProps.ts index 1004cd09990e8..326a1fb20ea59 100644 --- a/packages/rest-typings/src/v1/calendar/CalendarEventUpdateProps.ts +++ b/packages/rest-typings/src/v1/calendar/CalendarEventUpdateProps.ts @@ -7,10 +7,12 @@ const ajv = new Ajv(); export type CalendarEventUpdateProps = { eventId: ICalendarEvent['_id']; startTime: string; + endTime?: string; subject: string; description: string; meetingUrl?: string; reminderMinutesBeforeStart?: number; + busy?: boolean; }; const calendarEventUpdatePropsSchema: JSONSchemaType = { @@ -24,6 +26,10 @@ const calendarEventUpdatePropsSchema: JSONSchemaType = type: 'string', nullable: false, }, + endTime: { + type: 'string', + nullable: true, + }, subject: { type: 'string', nullable: false, @@ -40,6 +46,10 @@ const calendarEventUpdatePropsSchema: JSONSchemaType = type: 'number', nullable: true, }, + busy: { + type: 'boolean', + nullable: true, + }, }, required: ['eventId', 'startTime', 'subject', 'description'], additionalProperties: false,