diff --git a/.changeset/sweet-paws-doubt.md b/.changeset/sweet-paws-doubt.md new file mode 100644 index 0000000000000..ae8845d6d18de --- /dev/null +++ b/.changeset/sweet-paws-doubt.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/models": patch +--- + +Fixes an issue where overlapping calendar events could cause the user status to stay busy indefinitely diff --git a/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx b/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx index 92ea5eef4e196..021364fef11ca 100644 --- a/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx +++ b/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx @@ -18,6 +18,8 @@ const UserAvatarWithStatus = () => { const { status = !user ? 'online' : 'offline', username, avatarETag } = user || anon; + const effectiveStatus = presenceDisabled ? 'disabled' : status; + return ( { mie='neg-x2' mbe='neg-x2' > - + ); diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index b37b2adc45b09..642f13f114c8f 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -290,8 +290,13 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return; } - if (user.status) { - await CalendarEvent.updateEvent(event._id, { previousStatus: user.status }); + const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime) + .sort({ startTime: -1 }) + .toArray(); + const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.status; + + if (previousStatus) { + await CalendarEvent.updateEvent(event._id, { previousStatus }); } await applyStatusChange({ diff --git a/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts b/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts deleted file mode 100644 index 96ca535b98c15..0000000000000 --- a/apps/meteor/server/services/calendar/statusEvents/handleOverlappingEvents.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index e6eca7f011c7e..0000000000000 --- a/apps/meteor/server/services/calendar/statusEvents/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { applyStatusChange } from './applyStatusChange'; -import { cancelUpcomingStatusChanges } from './cancelUpcomingStatusChanges'; -import { generateCronJobId } from './generateCronJobId'; -import { handleOverlappingEvents } from './handleOverlappingEvents'; -import { removeCronJobs } from './removeCronJobs'; - -export const statusEventManager = { - applyStatusChange, - cancelUpcomingStatusChanges, - generateCronJobId, - handleOverlappingEvents, - removeCronJobs, -} as const; diff --git a/apps/meteor/tests/e2e/calendar.spec.ts b/apps/meteor/tests/e2e/calendar.spec.ts new file mode 100644 index 0000000000000..b081bdd14c07b --- /dev/null +++ b/apps/meteor/tests/e2e/calendar.spec.ts @@ -0,0 +1,69 @@ +import type { CalendarEventImportProps } from '@rocket.chat/rest-typings'; + +import { Users } from './fixtures/userStates'; +import { test, expect, type BaseTest } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +// TODO: Make this test run on CI - it's slow and requires an open browser, run manually for now. +test.describe.skip('Calendar', () => { + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: true })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: false })).status()).toBe(200); + }); + + test.describe('Status changes', () => { + test.beforeEach(async ({ page, api }) => { + expect((await api.post('/users.setStatus', { status: 'away', message: '' })).status()).toBe(200); + await page.goto('/home'); + }); + + test('Should change user status back to away when there are overlapping imported events', async ({ page, api }) => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away'); + + const event1 = await importCalendarEvent(api, { start: 1, end: 2 }); + await importCalendarEvent(api, { start: 1.5, end: 2.5 }); + const event3 = await importCalendarEvent(api, { start: 2.5, end: 3 }); + + await test.step('Should change status to busy', async () => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('busy', { + timeout: event1.endTime.getTime() - Date.now(), + }); + }); + + await test.step('Should change status to away again', async () => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away', { + timeout: event3.endTime.getTime() - Date.now() + 1000, + }); + }); + }); + }); +}); + +async function importCalendarEvent(api: BaseTest['api'], { now = Date.now(), start = 2, end = 4 } = {}) { + const startTime = new Date(now + 1000 * 60 * start); + const endTime = new Date(now + 1000 * 60 * end); + + const apiResponse = await api.post('/calendar-events.import', { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + subject: 'Test appointment', + description: 'Test appointment description', + meetingUrl: 'https://rocket.chat/', + busy: true, + externalId: `test-${start}-${end}-${now}`, + } satisfies CalendarEventImportProps); + + expect(apiResponse.status()).toBe(200); + + const { id } = await apiResponse.json(); + + if (typeof id !== 'string') { + throw new Error(`Expected id to be a string, but got ${typeof id}`); + } + + return { startTime, endTime, now, id }; +} diff --git a/packages/models/src/models/CalendarEvent.ts b/packages/models/src/models/CalendarEvent.ts index dcba0fe24f83c..6726aa26cef50 100644 --- a/packages/models/src/models/CalendarEvent.ts +++ b/packages/models/src/models/CalendarEvent.ts @@ -144,6 +144,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend return this.find({ _id: { $ne: eventId }, // Exclude current event uid, + busy: { $ne: false }, $or: [ // Event starts during our event { startTime: { $gte: startTime, $lt: endTime } },