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 } },