Skip to content

Commit 6329354

Browse files
committed
refactor: calendar service to use presence engine
1 parent cb516c7 commit 6329354

7 files changed

Lines changed: 36 additions & 550 deletions

File tree

apps/meteor/server/services/calendar/service.ts

Lines changed: 21 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import type { ICalendarService } from '@rocket.chat/core-services';
2-
import { ServiceClassInternal, api } from '@rocket.chat/core-services';
2+
import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services';
33
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
44
import { UserStatus } from '@rocket.chat/core-typings';
55
import { cronJobs } from '@rocket.chat/cron';
66
import { Logger } from '@rocket.chat/logger';
77
import type { InsertionModel } from '@rocket.chat/model-typings';
8-
import { CalendarEvent, Users } from '@rocket.chat/models';
8+
import { CalendarEvent } from '@rocket.chat/models';
99
import type { UpdateResult, DeleteResult } from 'mongodb';
1010

11-
import { applyStatusChange } from './statusEvents/applyStatusChange';
1211
import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges';
1312
import { removeCronJobs } from './statusEvents/removeCronJobs';
1413
import { getShiftedTime } from './utils/getShiftedTime';
@@ -150,6 +149,13 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
150149
const event = await this.get(eventId);
151150
if (event) {
152151
await removeCronJobs(eventId, event.uid);
152+
153+
// If the event is currently in progress, clear presence immediately
154+
// otherwise the user stays "Busy" until the engine's expiration cron runs
155+
const now = new Date();
156+
if (event.busy !== false && event.startTime <= now && (!event.endTime || event.endTime > now)) {
157+
await Presence.endActiveState(event.uid);
158+
}
153159
}
154160

155161
const result = await CalendarEvent.deleteOne({
@@ -210,52 +216,29 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
210216
// 2. When a busy event is updated (time/busy status changes)
211217
// 3. When a busy event is deleted
212218
// 4. When a status change job executes and completes
213-
// 5. When an event ends and the status is restored
214-
// 6. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
219+
// 5. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
220+
// Note: event end is handled by the presence engine's expiration cron via statusExpiresAt
221+
222+
const schedulerJobId = 'calendar-status-scheduler';
215223

216224
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
217225
if (!busyStatusEnabled) {
218-
const schedulerJobId = 'calendar-status-scheduler';
219226
if (await cronJobs.has(schedulerJobId)) {
220227
await cronJobs.remove(schedulerJobId);
221228
}
222229
return;
223230
}
224231

225-
const schedulerJobId = 'calendar-status-scheduler';
226232
if (await cronJobs.has(schedulerJobId)) {
227233
await cronJobs.remove(schedulerJobId);
228234
}
229235

230-
const now = new Date();
231-
const nextStartEvent = await CalendarEvent.findNextFutureEvent(now);
232-
const inProgressEvents = await CalendarEvent.findInProgressEvents(now).toArray();
233-
const eventsWithEndTime = inProgressEvents.filter((event) => event.endTime && event.busy !== false);
234-
if (eventsWithEndTime.length === 0 && !nextStartEvent) {
236+
const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date());
237+
if (!nextStartEvent) {
235238
return;
236239
}
237240

238-
let nextEndTime: Date | null = null;
239-
if (eventsWithEndTime.length > 0 && eventsWithEndTime[0].endTime) {
240-
nextEndTime = eventsWithEndTime.reduce((earliest, event) => {
241-
if (!event.endTime) return earliest;
242-
return event.endTime.getTime() < earliest.getTime() ? event.endTime : earliest;
243-
}, eventsWithEndTime[0].endTime);
244-
}
245-
246-
let nextProcessTime: Date;
247-
if (nextStartEvent && nextEndTime) {
248-
nextProcessTime = nextStartEvent.startTime.getTime() < nextEndTime.getTime() ? nextStartEvent.startTime : nextEndTime;
249-
} else if (nextStartEvent) {
250-
nextProcessTime = nextStartEvent.startTime;
251-
} else if (nextEndTime) {
252-
nextProcessTime = nextEndTime;
253-
} else {
254-
// This should never happen due to the earlier check, but just in case
255-
return;
256-
}
257-
258-
await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
241+
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime());
259242
}
260243

261244
private async processStatusChangesAtTime(): Promise<void> {
@@ -269,14 +252,6 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
269252
await this.processEventStart(event);
270253
}
271254

272-
const eventsEndingNow = await CalendarEvent.findEventsEndingNow({ now: processTime, offset: 5000 }).toArray();
273-
for await (const event of eventsEndingNow) {
274-
if (event.busy === false) {
275-
continue;
276-
}
277-
await this.processEventEnd(event);
278-
}
279-
280255
await this.doSetupNextStatusChange();
281256
}
282257

@@ -285,61 +260,14 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
285260
return;
286261
}
287262

288-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
289-
if (!user || user.statusDefault === UserStatus.OFFLINE) {
290-
return;
291-
}
292-
293-
const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime)
294-
.sort({ startTime: -1 })
295-
.toArray();
296-
const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault;
297-
298-
if (previousStatus) {
299-
await CalendarEvent.updateEvent(event._id, { previousStatus });
300-
}
301-
302-
await applyStatusChange({
303-
eventId: event._id,
304-
uid: event.uid,
305-
startTime: event.startTime,
306-
endTime: event.endTime,
307-
status: UserStatus.BUSY,
263+
await Presence.setActiveState(event.uid, {
264+
statusDefault: UserStatus.BUSY,
265+
statusText: event.subject,
266+
statusSource: 'external',
267+
statusExpiresAt: event.endTime,
308268
});
309269
}
310270

311-
private async processEventEnd(event: ICalendarEvent): Promise<void> {
312-
if (!event.endTime) {
313-
return;
314-
}
315-
316-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
317-
if (!user) {
318-
return;
319-
}
320-
321-
// Only restore status if:
322-
// 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user)
323-
// 2. We have a previousStatus stored from before the event started
324-
325-
if (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) {
326-
await applyStatusChange({
327-
eventId: event._id,
328-
uid: event.uid,
329-
startTime: event.startTime,
330-
endTime: event.endTime,
331-
status: event.previousStatus,
332-
});
333-
} else {
334-
logger.debug({
335-
msg: 'Not restoring status for user',
336-
userId: event.uid,
337-
currentStatusDefault: user.statusDefault,
338-
previousStatus: event.previousStatus,
339-
});
340-
}
341-
}
342-
343271
private async sendCurrentNotifications(date: Date): Promise<void> {
344272
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();
345273
for await (const event of events) {

apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts

Lines changed: 0 additions & 70 deletions
This file was deleted.

apps/meteor/tests/unit/server/services/calendar/service.tests.ts

Lines changed: 14 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,24 @@ const CalendarEventMock = {
2626
findInProgressEvents: sinon.stub(),
2727
};
2828

29-
const UsersMock = {
30-
findOne: sinon.stub(),
31-
};
32-
3329
const statusEventManagerMock = {
3430
removeCronJobs: sinon.stub().resolves(),
3531
cancelUpcomingStatusChanges: sinon.stub().resolves(),
36-
applyStatusChange: sinon.stub().resolves(),
3732
};
3833

3934
const getUserPreferenceMock = sinon.stub();
4035

4136
const serviceMocks = {
4237
'./statusEvents/cancelUpcomingStatusChanges': { cancelUpcomingStatusChanges: statusEventManagerMock.cancelUpcomingStatusChanges },
4338
'./statusEvents/removeCronJobs': { removeCronJobs: statusEventManagerMock.removeCronJobs },
44-
'./statusEvents/applyStatusChange': { applyStatusChange: statusEventManagerMock.applyStatusChange },
4539
'../../../app/settings/server': { settings: settingsMock },
46-
'@rocket.chat/core-services': { api, ServiceClassInternal: class {} },
40+
'@rocket.chat/core-services': {
41+
api,
42+
ServiceClassInternal: class {},
43+
Presence: { setActiveState: sinon.stub().resolves(), endActiveState: sinon.stub().resolves() },
44+
},
4745
'@rocket.chat/cron': { cronJobs: cronJobsMock },
48-
'@rocket.chat/models': { CalendarEvent: CalendarEventMock, Users: UsersMock },
46+
'@rocket.chat/models': { CalendarEvent: CalendarEventMock },
4947
'../../../app/utils/server/lib/getUserPreference': { getUserPreference: getUserPreferenceMock },
5048
};
5149

@@ -331,8 +329,8 @@ describe('CalendarService', () => {
331329

332330
try {
333331
await method();
334-
sinon.assert.calledWith(hasStub, 'calendar-next-status-change');
335-
sinon.assert.calledWith(removeStub, 'calendar-next-status-change');
332+
sinon.assert.calledWith(hasStub, 'calendar-status-scheduler');
333+
sinon.assert.calledWith(removeStub, 'calendar-status-scheduler');
336334
sinon.assert.notCalled(addAtTimestampStub);
337335
} finally {
338336
cronJobsMock.has = originalHas;
@@ -342,34 +340,17 @@ describe('CalendarService', () => {
342340
});
343341
});
344342

345-
it('should schedule a single chain job to handle all events when busy status setting is enabled', async () => {
343+
it('should schedule a job at the next event start time', async () => {
346344
await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => {
347345
settingsMock.set('Calendar_BusyStatus_Enabled', true);
348346

349-
const startOfNextMinute = new Date();
350-
startOfNextMinute.setSeconds(0, 0);
351-
startOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1);
352-
353-
const endOfNextMinute = new Date(startOfNextMinute);
354-
endOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1);
355-
356-
const eventStartingSoon = {
357-
_id: 'soon123',
358-
uid: fakeUserId,
359-
startTime: startOfNextMinute,
360-
endTime: new Date(startOfNextMinute.getTime() + 3600000), // 1 hour later
361-
};
362-
363347
const futureEvent = {
364348
_id: 'future123',
365349
uid: fakeUserId,
366-
startTime: endOfNextMinute,
367-
endTime: new Date(endOfNextMinute.getTime() + 3600000), // 1 hour later
350+
startTime: new Date(Date.now() + 60000),
351+
endTime: new Date(Date.now() + 3660000),
368352
};
369353

370-
CalendarEventMock.findEventsToScheduleNow.returns({
371-
toArray: sinon.stub().resolves([eventStartingSoon]),
372-
});
373354
CalendarEventMock.findNextFutureEvent.resolves(futureEvent);
374355

375356
const originalHas = cronJobsMock.has;
@@ -387,14 +368,8 @@ describe('CalendarService', () => {
387368
try {
388369
await method();
389370

390-
sinon.assert.calledWith(hasStub, 'calendar-next-status-change');
391-
sinon.assert.notCalled(removeStub);
392-
393371
sinon.assert.calledOnce(addAtTimestampStub);
394-
395-
sinon.assert.calledWith(addAtTimestampStub, 'calendar-next-status-change', futureEvent.startTime, sinon.match.func);
396-
397-
sinon.assert.neverCalledWith(addAtTimestampStub, sinon.match(/^calendar-status-/), sinon.match.any, sinon.match.any);
372+
sinon.assert.calledWith(addAtTimestampStub, 'calendar-status-scheduler', futureEvent.startTime, sinon.match.func);
398373
} finally {
399374
cronJobsMock.has = originalHas;
400375
cronJobsMock.remove = originalRemove;
@@ -403,21 +378,10 @@ describe('CalendarService', () => {
403378
});
404379
});
405380

406-
it('should fetch events at execution time rather than scheduling them individually', async () => {
381+
it('should not schedule a job when there are no future events', async () => {
407382
await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => {
408383
settingsMock.set('Calendar_BusyStatus_Enabled', true);
409384

410-
const now = new Date();
411-
const startOfNextMinute = new Date(now);
412-
startOfNextMinute.setSeconds(0, 0);
413-
startOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1);
414-
415-
const endOfNextMinute = new Date(startOfNextMinute);
416-
endOfNextMinute.setMinutes(startOfNextMinute.getMinutes() + 1);
417-
418-
CalendarEventMock.findEventsToScheduleNow.returns({
419-
toArray: sinon.stub().resolves([]),
420-
});
421385
CalendarEventMock.findNextFutureEvent.resolves(null);
422386

423387
const originalHas = cronJobsMock.has;
@@ -435,14 +399,7 @@ describe('CalendarService', () => {
435399
try {
436400
await method();
437401

438-
sinon.assert.calledWith(addAtTimestampStub, 'calendar-next-status-change', endOfNextMinute, sinon.match.func);
439-
440-
const callback = addAtTimestampStub.firstCall.args[2];
441-
const doSetupNextStatusChangeStub = sinon.stub(service, 'doSetupNextStatusChange').resolves();
442-
await callback();
443-
444-
sinon.assert.calledOnce(doSetupNextStatusChangeStub);
445-
doSetupNextStatusChangeStub.restore();
402+
sinon.assert.notCalled(addAtTimestampStub);
446403
} finally {
447404
cronJobsMock.has = originalHas;
448405
cronJobsMock.remove = originalRemove;

0 commit comments

Comments
 (0)