regression: improves CalendarEvent status change schedule (#35607)

pull/35302/head^2
Ricardo Garim 10 months ago committed by GitHub
parent 68dbd826c5
commit a2b7b628db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      apps/meteor/ee/server/configuration/outlookCalendar.ts
  2. 158
      apps/meteor/server/services/calendar/service.ts
  3. 10
      apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts
  4. 2
      apps/meteor/server/services/calendar/statusEvents/index.ts
  5. 12
      apps/meteor/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts
  6. 462
      apps/meteor/tests/unit/server/services/calendar/service.tests.ts
  7. 18
      apps/meteor/tests/unit/server/services/calendar/statusEvents/applyStatusChange.ts
  8. 77
      apps/meteor/tests/unit/server/services/calendar/statusEvents/setupAppointmentStatusChange.ts
  9. 1
      packages/core-services/src/types/ICalendarService.ts
  10. 2
      packages/core-typings/src/ICalendarEvent.ts
  11. 5
      packages/model-typings/src/models/ICalendarEventModel.ts
  12. 97
      packages/models/src/models/CalendarEvent.ts

@ -9,5 +9,6 @@ Meteor.startup(() =>
addSettings();
await Calendar.setupNextNotification();
await Calendar.setupNextStatusChange();
}),
);

@ -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<void> {
return this.doSetupNextNotification(false);
}
public async setupNextStatusChange(): Promise<void> {
return this.doSetupNextStatusChange();
}
public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
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<void> {
// 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<boolean>('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<void> {
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<void> {
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<void> {
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<void> {
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();
for await (const event of events) {

@ -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<void> {
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);
}
}

@ -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;

@ -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');
}
}
});
}

@ -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');

@ -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,
]);
});
});
});

@ -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<string, any>();
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;
});
});
});

@ -10,5 +10,6 @@ export interface ICalendarService {
update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult | null>;
delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult>;
setupNextNotification(): Promise<void>;
setupNextStatusChange(): Promise<void>;
cancelUpcomingStatusChanges(uid: IUser['_id'], endTime?: Date): Promise<void>;
}

@ -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;
}

@ -15,4 +15,9 @@ export interface ICalendarEventModel extends IBaseModel<ICalendarEvent> {
): Promise<ICalendarEvent | null>;
findOverlappingEvents(eventId: ICalendarEvent['_id'], uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor<ICalendarEvent>;
findEligibleEventsForCancelation(uid: IUser['_id'], endTime: Date): FindCursor<ICalendarEvent>;
findEventsToScheduleNow(now: Date, endTime: Date): FindCursor<ICalendarEvent>;
findNextFutureEvent(startTime: Date): Promise<ICalendarEvent | null>;
findInProgressEvents(now: Date): FindCursor<ICalendarEvent>;
findEventsStartingNow({ now, offset }: { now: Date; offset?: number }): FindCursor<ICalendarEvent>;
findEventsEndingNow({ now, offset }: { now: Date; offset?: number }): FindCursor<ICalendarEvent>;
}

@ -50,7 +50,7 @@ export class CalendarEventRaw extends BaseRaw<ICalendarEvent> implements ICalend
public async updateEvent(
eventId: ICalendarEvent['_id'],
{ subject, description, startTime, meetingUrl, reminderMinutesBeforeStart, reminderTime }: Partial<ICalendarEvent>,
{ subject, description, startTime, meetingUrl, reminderMinutesBeforeStart, reminderTime, previousStatus }: Partial<ICalendarEvent>,
): Promise<UpdateResult> {
return this.updateOne(
{ _id: eventId },
@ -62,6 +62,7 @@ export class CalendarEventRaw extends BaseRaw<ICalendarEvent> implements ICalend
...(meetingUrl !== undefined ? { meetingUrl } : {}),
...(reminderMinutesBeforeStart ? { reminderMinutesBeforeStart } : {}),
...(reminderTime ? { reminderTime } : {}),
...(previousStatus ? { previousStatus } : {}),
},
},
);
@ -149,4 +150,98 @@ export class CalendarEventRaw extends BaseRaw<ICalendarEvent> implements ICalend
endTime: { $exists: true, $gte: endTime },
});
}
public findEventsToScheduleNow(now: Date, endTime: Date): FindCursor<ICalendarEvent> {
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<ICalendarEvent | null> {
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<ICalendarEvent> {
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<ICalendarEvent> {
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<ICalendarEvent> {
return this.find(
{
startTime: { $lt: now },
endTime: { $gt: now },
busy: { $ne: false },
},
{
projection: {
_id: 1,
uid: 1,
startTime: 1,
endTime: 1,
},
},
);
}
}

Loading…
Cancel
Save