diff --git a/apps/meteor/ee/server/configuration/outlookCalendar.ts b/apps/meteor/ee/server/configuration/outlookCalendar.ts index 67c8d794503..b7a60e655e0 100644 --- a/apps/meteor/ee/server/configuration/outlookCalendar.ts +++ b/apps/meteor/ee/server/configuration/outlookCalendar.ts @@ -9,5 +9,6 @@ Meteor.startup(() => addSettings(); await Calendar.setupNextNotification(); + await Calendar.setupNextStatusChange(); }), ); diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index 80f6d7aac6a..e96956e4f94 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -5,12 +5,12 @@ 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 { CalendarEvent, Users } from '@rocket.chat/models'; import type { UpdateResult, DeleteResult } from 'mongodb'; +import { applyStatusChange } from './statusEvents/applyStatusChange'; 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'; @@ -43,7 +43,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe const insertResult = await CalendarEvent.insertOne(insertData); await this.setupNextNotification(); if (busy !== false) { - await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true); + await this.setupNextStatusChange(); } return insertResult.insertedId; @@ -82,8 +82,9 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe await this.setupNextNotification(); if (busy !== false) { - await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true); + await this.setupNextStatusChange(); } + return insertResult.insertedId; } @@ -91,7 +92,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe if (updateResult.modifiedCount > 0) { await this.setupNextNotification(); if (busy !== false) { - await setupAppointmentStatusChange(event._id, uid, startTime, endTime, UserStatus.BUSY, true); + await this.setupNextStatusChange(); } } @@ -135,16 +136,9 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe 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); - } + await this.setupNextStatusChange(); } } } @@ -158,15 +152,25 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe await removeCronJobs(eventId, event.uid); } - return CalendarEvent.deleteOne({ + const result = await CalendarEvent.deleteOne({ _id: eventId, }); + + if (result.deletedCount > 0) { + await this.setupNextStatusChange(); + } + + return result; } public async setupNextNotification(): Promise { return this.doSetupNextNotification(false); } + public async setupNextStatusChange(): Promise { + return this.doSetupNextStatusChange(); + } + public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise { return cancelUpcomingStatusChanges(uid, endTime); } @@ -200,6 +204,132 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date)); } + private async doSetupNextStatusChange(): Promise { + // This method is called in the following moments: + // 1. When a new busy event is created or imported + // 2. When a busy event is updated (time/busy status changes) + // 3. When a busy event is deleted + // 4. When a status change job executes and completes + // 5. When an event ends and the status is restored + // 6. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts) + + const busyStatusEnabled = settings.get('Calendar_BusyStatus_Enabled'); + if (!busyStatusEnabled) { + const schedulerJobId = 'calendar-status-scheduler'; + if (await cronJobs.has(schedulerJobId)) { + await cronJobs.remove(schedulerJobId); + } + return; + } + + const schedulerJobId = 'calendar-status-scheduler'; + if (await cronJobs.has(schedulerJobId)) { + await cronJobs.remove(schedulerJobId); + } + + const now = new Date(); + const nextStartEvent = await CalendarEvent.findNextFutureEvent(now); + const inProgressEvents = await CalendarEvent.findInProgressEvents(now).toArray(); + const eventsWithEndTime = inProgressEvents.filter((event) => event.endTime && event.busy !== false); + if (eventsWithEndTime.length === 0 && !nextStartEvent) { + return; + } + + let nextEndTime: Date | null = null; + if (eventsWithEndTime.length > 0 && eventsWithEndTime[0].endTime) { + nextEndTime = eventsWithEndTime.reduce((earliest, event) => { + if (!event.endTime) return earliest; + return event.endTime.getTime() < earliest.getTime() ? event.endTime : earliest; + }, eventsWithEndTime[0].endTime); + } + + let nextProcessTime: Date; + if (nextStartEvent && nextEndTime) { + nextProcessTime = nextStartEvent.startTime.getTime() < nextEndTime.getTime() ? nextStartEvent.startTime : nextEndTime; + } else if (nextStartEvent) { + nextProcessTime = nextStartEvent.startTime; + } else if (nextEndTime) { + nextProcessTime = nextEndTime; + } else { + // This should never happen due to the earlier check, but just in case + return; + } + + await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime()); + } + + private async processStatusChangesAtTime(): Promise { + const processTime = new Date(); + + const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray(); + for await (const event of eventsStartingNow) { + if (event.busy === false) { + continue; + } + await this.processEventStart(event); + } + + const eventsEndingNow = await CalendarEvent.findEventsEndingNow({ now: processTime, offset: 5000 }).toArray(); + for await (const event of eventsEndingNow) { + if (event.busy === false) { + continue; + } + await this.processEventEnd(event); + } + + await this.doSetupNextStatusChange(); + } + + private async processEventStart(event: ICalendarEvent): Promise { + if (!event.endTime) { + return; + } + + const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); + if (!user || user.status === UserStatus.OFFLINE) { + return; + } + + if (user.status) { + await CalendarEvent.updateEvent(event._id, { previousStatus: user.status }); + } + + await applyStatusChange({ + eventId: event._id, + uid: event.uid, + startTime: event.startTime, + endTime: event.endTime, + status: UserStatus.BUSY, + }); + } + + private async processEventEnd(event: ICalendarEvent): Promise { + if (!event.endTime) { + return; + } + + const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); + if (!user) { + return; + } + + // Only restore status if: + // 1. The current status is BUSY (meaning it was set by our system, not manually changed by user) + // 2. We have a previousStatus stored from before the event started + + if (event.previousStatus && event.previousStatus === user.status) { + await applyStatusChange({ + eventId: event._id, + uid: event.uid, + startTime: event.startTime, + endTime: event.endTime, + status: event.previousStatus, + }); + } else { + logger.debug(`Not restoring status for user ${event.uid}: current=${user.status}, stored=${event.previousStatus}`); + } + } + private async sendCurrentNotifications(date: Date): Promise { const events = await CalendarEvent.findEventsToNotify(date, 1).toArray(); for await (const event of events) { diff --git a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts index d47fe85237e..860d5df5e4d 100644 --- a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts +++ b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts @@ -1,9 +1,10 @@ import { api } from '@rocket.chat/core-services'; import { UserStatus } from '@rocket.chat/core-typings'; import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; -import { setupAppointmentStatusChange } from './setupAppointmentStatusChange'; +const logger = new Logger('Calendar'); export async function applyStatusChange({ eventId, @@ -11,7 +12,6 @@ export async function applyStatusChange({ startTime, endTime, status, - shouldScheduleRemoval, }: { eventId: ICalendarEvent['_id']; uid: IUser['_id']; @@ -20,6 +20,8 @@ export async function applyStatusChange({ status?: UserStatus; shouldScheduleRemoval?: boolean; }): Promise { + logger.debug(`Applying status change for event ${eventId} at ${startTime} ${endTime ? `to ${endTime}` : ''} to ${status}`); + const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } }); if (!user || user.status === UserStatus.OFFLINE) { return; @@ -40,8 +42,4 @@ export async function applyStatusChange({ }, previousStatus, }); - - if (shouldScheduleRemoval && endTime) { - await setupAppointmentStatusChange(eventId, uid, startTime, endTime, previousStatus, false); - } } diff --git a/apps/meteor/server/services/calendar/statusEvents/index.ts b/apps/meteor/server/services/calendar/statusEvents/index.ts index ff4133ca8bc..e6eca7f011c 100644 --- a/apps/meteor/server/services/calendar/statusEvents/index.ts +++ b/apps/meteor/server/services/calendar/statusEvents/index.ts @@ -3,7 +3,6 @@ import { cancelUpcomingStatusChanges } from './cancelUpcomingStatusChanges'; import { generateCronJobId } from './generateCronJobId'; import { handleOverlappingEvents } from './handleOverlappingEvents'; import { removeCronJobs } from './removeCronJobs'; -import { setupAppointmentStatusChange } from './setupAppointmentStatusChange'; export const statusEventManager = { applyStatusChange, @@ -11,5 +10,4 @@ export const statusEventManager = { generateCronJobId, handleOverlappingEvents, removeCronJobs, - setupAppointmentStatusChange, } as const; diff --git a/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts index 907f2a6bf2c..0b818a23ded 100644 --- a/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts +++ b/apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts @@ -33,7 +33,13 @@ export async function setupAppointmentStatusChange( await cronJobs.remove(cronJobId); } - await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => - applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval }), - ); + await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => { + await applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval }); + + if (!shouldScheduleRemoval) { + if (await cronJobs.has('calendar-next-status-change')) { + await cronJobs.remove('calendar-next-status-change'); + } + } + }); } diff --git a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts index d6fc603089c..e071b65c763 100644 --- a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts +++ b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts @@ -21,12 +21,19 @@ const CalendarEventMock = { findEventsToNotify: sinon.stub(), flagNotificationSent: sinon.stub(), findOneByExternalIdAndUserId: sinon.stub(), + findEventsToScheduleNow: sinon.stub(), + findNextFutureEvent: sinon.stub(), + findInProgressEvents: sinon.stub(), +}; + +const UsersMock = { + findOne: sinon.stub(), }; const statusEventManagerMock = { - setupAppointmentStatusChange: sinon.stub().resolves(), removeCronJobs: sinon.stub().resolves(), cancelUpcomingStatusChanges: sinon.stub().resolves(), + applyStatusChange: sinon.stub().resolves(), }; const getUserPreferenceMock = sinon.stub(); @@ -34,11 +41,11 @@ const getUserPreferenceMock = sinon.stub(); const serviceMocks = { './statusEvents/cancelUpcomingStatusChanges': { cancelUpcomingStatusChanges: statusEventManagerMock.cancelUpcomingStatusChanges }, './statusEvents/removeCronJobs': { removeCronJobs: statusEventManagerMock.removeCronJobs }, - './statusEvents/setupAppointmentStatusChange': { setupAppointmentStatusChange: statusEventManagerMock.setupAppointmentStatusChange }, + './statusEvents/applyStatusChange': { applyStatusChange: statusEventManagerMock.applyStatusChange }, '../../../app/settings/server': { settings: settingsMock }, '@rocket.chat/core-services': { api, ServiceClassInternal: class {} }, '@rocket.chat/cron': { cronJobs: cronJobsMock }, - '@rocket.chat/models': { CalendarEvent: CalendarEventMock }, + '@rocket.chat/models': { CalendarEvent: CalendarEventMock, Users: UsersMock }, '../../../app/utils/server/lib/getUserPreference': { getUserPreference: getUserPreferenceMock }, }; @@ -74,8 +81,10 @@ describe('CalendarService', () => { sandbox.stub(proto, 'sendEventNotification').resolves(); sandbox.stub(proto, 'sendCurrentNotifications').resolves(); sandbox.stub(proto, 'doSetupNextNotification').resolves(); + sandbox.stub(proto, 'doSetupNextStatusChange').resolves(); sandbox.stub(service, 'setupNextNotification').resolves(); + sandbox.stub(service, 'setupNextStatusChange').resolves(); } function setupCalendarEventMocks() { @@ -93,6 +102,13 @@ describe('CalendarService', () => { }), flagNotificationSent: sinon.stub().resolves(), findOneByExternalIdAndUserId: sinon.stub().resolves(null), + findEventsToScheduleNow: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), + findNextFutureEvent: sinon.stub().resolves(null), + findInProgressEvents: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), }; Object.assign(CalendarEventMock, freshMocks); @@ -147,34 +163,7 @@ describe('CalendarService', () => { 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); + sinon.assert.calledOnce(service.setupNextStatusChange); }); }); @@ -190,24 +179,7 @@ describe('CalendarService', () => { 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); + sinon.assert.calledOnce(service.setupNextStatusChange); }); it('should update existing event if found by externalId', async () => { @@ -231,58 +203,6 @@ describe('CalendarService', () => { 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', () => { @@ -307,14 +227,6 @@ describe('CalendarService', () => { 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, @@ -335,42 +247,7 @@ describe('CalendarService', () => { }); 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); + sinon.assert.calledOnce(service.setupNextStatusChange); }); }); @@ -390,15 +267,6 @@ describe('CalendarService', () => { 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', () => { @@ -421,30 +289,7 @@ describe('CalendarService', () => { }); }); - 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'; @@ -452,199 +297,162 @@ describe('CalendarService', () => { 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', ''); + describe('Private: doSetupNextNotification', () => { + 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); - const result = await method('Test description with no pattern match'); - expect(result).to.be.undefined; - }); - }); + await method(false); - 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'); + expect(cronJobsMock.jobNames.has('calendar-reminders')).to.true; }); }); }); - 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); + describe('Private: doSetupNextStatusChange', () => { + it('should not run when busy status setting is disabled', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', false); + + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; + + const hasStub = sinon.stub().resolves(true); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); + + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; + + try { + await method(); + sinon.assert.calledWith(hasStub, 'calendar-next-status-change'); + sinon.assert.calledWith(removeStub, 'calendar-next-status-change'); + sinon.assert.notCalled(addAtTimestampStub); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } }); }); - 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); + it('should schedule a single chain job to handle all events when busy status setting is enabled', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', true); - 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; - }); - }); - }); + const startOfNextMinute = new Date(); + startOfNextMinute.setSeconds(0, 0); + startOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1); - describe('Private: sendEventNotification', () => { - it('should not send notification if user preference is disabled', async () => { - await testPrivateMethod(service, 'sendEventNotification', async (method) => { - getUserPreferenceMock.resolves(false); + const endOfNextMinute = new Date(startOfNextMinute); + endOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1); - const fakeEvent = { - _id: fakeEventId, + const eventStartingSoon = { + _id: 'soon123', uid: fakeUserId, - startTime: fakeStartTime, - subject: fakeSubject, + startTime: startOfNextMinute, + endTime: new Date(startOfNextMinute.getTime() + 3600000), // 1 hour later }; - 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, + const futureEvent = { + _id: 'future123', uid: fakeUserId, - startTime: fakeStartTime, - subject: fakeSubject, + startTime: endOfNextMinute, + endTime: new Date(endOfNextMinute.getTime() + 3600000), // 1 hour later }; - 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), + CalendarEventMock.findEventsToScheduleNow.returns({ + toArray: sinon.stub().resolves([eventStartingSoon]), }); + CalendarEventMock.findNextFutureEvent.resolves(futureEvent); - await method(fakeDate); + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; - 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); - }); - }); - }); + const hasStub = sinon.stub().resolves(false); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); - 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'); + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; - await method(false); + try { + await method(); - expect(cronJobsMock.jobNames.has('calendar-reminders')).to.false; - }); - }); + sinon.assert.calledWith(hasStub, 'calendar-next-status-change'); + sinon.assert.notCalled(removeStub); - 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); + sinon.assert.calledOnce(addAtTimestampStub); - await method(false); + sinon.assert.calledWith(addAtTimestampStub, 'calendar-next-status-change', futureEvent.startTime, sinon.match.func); - expect(cronJobsMock.jobNames.has('calendar-reminders')).to.true; + sinon.assert.neverCalledWith(addAtTimestampStub, sinon.match(/^calendar-status-/), sinon.match.any, sinon.match.any); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } }); }); - 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(); + it('should fetch events at execution time rather than scheduling them individually', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', true); - const pastDate = new Date(); - pastDate.setMinutes(pastDate.getMinutes() - 10); - CalendarEventMock.findNextNotificationDate.resolves(pastDate); + const now = new Date(); + const startOfNextMinute = new Date(now); + startOfNextMinute.setSeconds(0, 0); + startOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1); - await method(false); + const endOfNextMinute = new Date(startOfNextMinute); + endOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1); - sinon.assert.calledWith(proto.sendCurrentNotifications as sinon.SinonStub, pastDate); - expect(cronJobsMock.jobNames.size).to.equal(0); - }); - }); + CalendarEventMock.findEventsToScheduleNow.returns({ + toArray: sinon.stub().resolves([]), + }); + CalendarEventMock.findNextFutureEvent.resolves(null); - 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); + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; - await method(true); + const hasStub = sinon.stub().resolves(false); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); - sinon.assert.notCalled(service.sendCurrentNotifications as sinon.SinonStub); - expect(cronJobsMock.jobNames.size).to.equal(1); - }); - }); - }); + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; - 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, - }; + try { + await method(); - CalendarEventMock.findOne.resolves(fakeEvent); + sinon.assert.calledWith(addAtTimestampStub, 'calendar-next-status-change', endOfNextMinute, sinon.match.func); - await service.update(fakeEventId, { - subject: 'New Subject', - }); + const callback = addAtTimestampStub.firstCall.args[2]; + const doSetupNextStatusChangeStub = sinon.stub(service, 'doSetupNextStatusChange').resolves(); + await callback(); - sinon.assert.notCalled(statusEventManagerMock.setupAppointmentStatusChange); + sinon.assert.calledOnce(doSetupNextStatusChangeStub); + doSetupNextStatusChangeStub.restore(); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } + }); }); + }); + describe('Overlapping events', () => { it('should cancel upcoming status changes for a user', async () => { const customDate = new Date('2025-02-01'); diff --git a/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts index 30053e86cc1..65818c9d1f0 100644 --- a/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts +++ b/apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts @@ -11,10 +11,7 @@ const UsersMock = { 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, @@ -52,8 +49,6 @@ describe('Calendar.StatusEvents', () => { function setupOtherMocks() { sandbox.stub(api, 'broadcast').resolves(); - - setupAppointmentStatusChange.resetHistory(); } afterEach(() => { @@ -144,26 +139,13 @@ describe('Calendar.StatusEvents', () => { }); 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/setupAppointmentStatusChange.ts b/apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts deleted file mode 100644 index 7df232de9c7..00000000000 --- a/apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts +++ /dev/null @@ -1,77 +0,0 @@ -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/packages/core-services/src/types/ICalendarService.ts b/packages/core-services/src/types/ICalendarService.ts index f74b63b056b..a7744ddd7c1 100644 --- a/packages/core-services/src/types/ICalendarService.ts +++ b/packages/core-services/src/types/ICalendarService.ts @@ -10,5 +10,6 @@ export interface ICalendarService { update(eventId: ICalendarEvent['_id'], data: Partial): Promise; delete(eventId: ICalendarEvent['_id']): Promise; setupNextNotification(): Promise; + setupNextStatusChange(): 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 fb58bde124c..5088f99cebf 100644 --- a/packages/core-typings/src/ICalendarEvent.ts +++ b/packages/core-typings/src/ICalendarEvent.ts @@ -1,5 +1,6 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; +import type { UserStatus } from './UserStatus'; export interface ICalendarEvent extends IRocketChatRecord { startTime: Date; @@ -17,4 +18,5 @@ export interface ICalendarEvent extends IRocketChatRecord { reminderTime?: Date; busy?: boolean; + previousStatus?: UserStatus; } diff --git a/packages/model-typings/src/models/ICalendarEventModel.ts b/packages/model-typings/src/models/ICalendarEventModel.ts index 24c4bc30ac0..9aae06964d2 100644 --- a/packages/model-typings/src/models/ICalendarEventModel.ts +++ b/packages/model-typings/src/models/ICalendarEventModel.ts @@ -15,4 +15,9 @@ export interface ICalendarEventModel extends IBaseModel { ): Promise; findOverlappingEvents(eventId: ICalendarEvent['_id'], uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor; findEligibleEventsForCancelation(uid: IUser['_id'], endTime: Date): FindCursor; + findEventsToScheduleNow(now: Date, endTime: Date): FindCursor; + findNextFutureEvent(startTime: Date): Promise; + findInProgressEvents(now: Date): FindCursor; + findEventsStartingNow({ now, offset }: { now: Date; offset?: number }): FindCursor; + findEventsEndingNow({ now, offset }: { now: Date; offset?: number }): FindCursor; } diff --git a/packages/models/src/models/CalendarEvent.ts b/packages/models/src/models/CalendarEvent.ts index 4de0c4a638e..afbbf83352f 100644 --- a/packages/models/src/models/CalendarEvent.ts +++ b/packages/models/src/models/CalendarEvent.ts @@ -50,7 +50,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend public async updateEvent( eventId: ICalendarEvent['_id'], - { subject, description, startTime, meetingUrl, reminderMinutesBeforeStart, reminderTime }: Partial, + { subject, description, startTime, meetingUrl, reminderMinutesBeforeStart, reminderTime, previousStatus }: Partial, ): Promise { return this.updateOne( { _id: eventId }, @@ -62,6 +62,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend ...(meetingUrl !== undefined ? { meetingUrl } : {}), ...(reminderMinutesBeforeStart ? { reminderMinutesBeforeStart } : {}), ...(reminderTime ? { reminderTime } : {}), + ...(previousStatus ? { previousStatus } : {}), }, }, ); @@ -149,4 +150,98 @@ export class CalendarEventRaw extends BaseRaw implements ICalend endTime: { $exists: true, $gte: endTime }, }); } + + public findEventsToScheduleNow(now: Date, endTime: Date): FindCursor { + return this.find( + { + startTime: { $gte: now, $lt: endTime }, + busy: { $ne: false }, + endTime: { $exists: true }, + }, + { + sort: { startTime: 1 }, + projection: { + _id: 1, + uid: 1, + startTime: 1, + endTime: 1, + }, + }, + ); + } + + public async findNextFutureEvent(startTime: Date): Promise { + return this.findOne( + { + startTime: { $gte: startTime }, + busy: { $ne: false }, + endTime: { $exists: true }, + }, + { + sort: { startTime: 1 }, + projection: { + startTime: 1, + }, + }, + ); + } + + public findEventsStartingNow({ now, offset = 1000 }: { now: Date; offset?: number }): FindCursor { + return this.find( + { + startTime: { + $gte: new Date(now.getTime() - offset), + $lt: new Date(now.getTime() + offset), + }, + busy: { $ne: false }, + }, + { + projection: { + _id: 1, + uid: 1, + startTime: 1, + endTime: 1, + }, + }, + ); + } + + public findEventsEndingNow({ now, offset = 1000 }: { now: Date; offset?: number }): FindCursor { + return this.find( + { + endTime: { + $gte: new Date(now.getTime() - offset), + $lt: new Date(now.getTime() + offset), + }, + busy: { $ne: false }, + }, + { + projection: { + _id: 1, + uid: 1, + startTime: 1, + endTime: 1, + previousStatus: 1, + }, + }, + ); + } + + public findInProgressEvents(now: Date): FindCursor { + return this.find( + { + startTime: { $lt: now }, + endTime: { $gt: now }, + busy: { $ne: false }, + }, + { + projection: { + _id: 1, + uid: 1, + startTime: 1, + endTime: 1, + }, + }, + ); + } }