Skip to content

Commit a2b7b62

Browse files
authored
regression: improves CalendarEvent status change schedule (#35607)
1 parent 68dbd82 commit a2b7b62

File tree

12 files changed

+397
-448
lines changed

12 files changed

+397
-448
lines changed

apps/meteor/ee/server/configuration/outlookCalendar.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ Meteor.startup(() =>
99
addSettings();
1010

1111
await Calendar.setupNextNotification();
12+
await Calendar.setupNextStatusChange();
1213
}),
1314
);

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

+144-14
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ 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 } from '@rocket.chat/models';
8+
import { CalendarEvent, Users } from '@rocket.chat/models';
99
import type { UpdateResult, DeleteResult } from 'mongodb';
1010

11+
import { applyStatusChange } from './statusEvents/applyStatusChange';
1112
import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges';
1213
import { removeCronJobs } from './statusEvents/removeCronJobs';
13-
import { setupAppointmentStatusChange } from './statusEvents/setupAppointmentStatusChange';
1414
import { getShiftedTime } from './utils/getShiftedTime';
1515
import { settings } from '../../../app/settings/server';
1616
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
@@ -43,7 +43,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
4343
const insertResult = await CalendarEvent.insertOne(insertData);
4444
await this.setupNextNotification();
4545
if (busy !== false) {
46-
await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true);
46+
await this.setupNextStatusChange();
4747
}
4848

4949
return insertResult.insertedId;
@@ -82,16 +82,17 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
8282

8383
await this.setupNextNotification();
8484
if (busy !== false) {
85-
await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true);
85+
await this.setupNextStatusChange();
8686
}
87+
8788
return insertResult.insertedId;
8889
}
8990

9091
const updateResult = await CalendarEvent.updateEvent(event._id, updateData);
9192
if (updateResult.modifiedCount > 0) {
9293
await this.setupNextNotification();
9394
if (busy !== false) {
94-
await setupAppointmentStatusChange(event._id, uid, startTime, endTime, UserStatus.BUSY, true);
95+
await this.setupNextStatusChange();
9596
}
9697
}
9798

@@ -135,16 +136,9 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
135136

136137
if (startTime || endTime) {
137138
await removeCronJobs(eventId, event.uid);
138-
139139
const isBusy = busy !== undefined ? busy : event.busy !== false;
140140
if (isBusy) {
141-
const effectiveStartTime = startTime || event.startTime;
142-
const effectiveEndTime = endTime || event.endTime;
143-
144-
// Only proceed if we have both valid start and end times
145-
if (effectiveStartTime && effectiveEndTime) {
146-
await setupAppointmentStatusChange(eventId, event.uid, effectiveStartTime, effectiveEndTime, UserStatus.BUSY, true);
147-
}
141+
await this.setupNextStatusChange();
148142
}
149143
}
150144
}
@@ -158,15 +152,25 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
158152
await removeCronJobs(eventId, event.uid);
159153
}
160154

161-
return CalendarEvent.deleteOne({
155+
const result = await CalendarEvent.deleteOne({
162156
_id: eventId,
163157
});
158+
159+
if (result.deletedCount > 0) {
160+
await this.setupNextStatusChange();
161+
}
162+
163+
return result;
164164
}
165165

166166
public async setupNextNotification(): Promise<void> {
167167
return this.doSetupNextNotification(false);
168168
}
169169

170+
public async setupNextStatusChange(): Promise<void> {
171+
return this.doSetupNextStatusChange();
172+
}
173+
170174
public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
171175
return cancelUpcomingStatusChanges(uid, endTime);
172176
}
@@ -200,6 +204,132 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
200204
await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date));
201205
}
202206

207+
private async doSetupNextStatusChange(): Promise<void> {
208+
// This method is called in the following moments:
209+
// 1. When a new busy event is created or imported
210+
// 2. When a busy event is updated (time/busy status changes)
211+
// 3. When a busy event is deleted
212+
// 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)
215+
216+
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
217+
if (!busyStatusEnabled) {
218+
const schedulerJobId = 'calendar-status-scheduler';
219+
if (await cronJobs.has(schedulerJobId)) {
220+
await cronJobs.remove(schedulerJobId);
221+
}
222+
return;
223+
}
224+
225+
const schedulerJobId = 'calendar-status-scheduler';
226+
if (await cronJobs.has(schedulerJobId)) {
227+
await cronJobs.remove(schedulerJobId);
228+
}
229+
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) {
235+
return;
236+
}
237+
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());
259+
}
260+
261+
private async processStatusChangesAtTime(): Promise<void> {
262+
const processTime = new Date();
263+
264+
const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray();
265+
for await (const event of eventsStartingNow) {
266+
if (event.busy === false) {
267+
continue;
268+
}
269+
await this.processEventStart(event);
270+
}
271+
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+
280+
await this.doSetupNextStatusChange();
281+
}
282+
283+
private async processEventStart(event: ICalendarEvent): Promise<void> {
284+
if (!event.endTime) {
285+
return;
286+
}
287+
288+
const user = await Users.findOneById(event.uid, { projection: { status: 1 } });
289+
if (!user || user.status === UserStatus.OFFLINE) {
290+
return;
291+
}
292+
293+
if (user.status) {
294+
await CalendarEvent.updateEvent(event._id, { previousStatus: user.status });
295+
}
296+
297+
await applyStatusChange({
298+
eventId: event._id,
299+
uid: event.uid,
300+
startTime: event.startTime,
301+
endTime: event.endTime,
302+
status: UserStatus.BUSY,
303+
});
304+
}
305+
306+
private async processEventEnd(event: ICalendarEvent): Promise<void> {
307+
if (!event.endTime) {
308+
return;
309+
}
310+
311+
const user = await Users.findOneById(event.uid, { projection: { status: 1 } });
312+
if (!user) {
313+
return;
314+
}
315+
316+
// Only restore status if:
317+
// 1. The current status is BUSY (meaning it was set by our system, not manually changed by user)
318+
// 2. We have a previousStatus stored from before the event started
319+
320+
if (event.previousStatus && event.previousStatus === user.status) {
321+
await applyStatusChange({
322+
eventId: event._id,
323+
uid: event.uid,
324+
startTime: event.startTime,
325+
endTime: event.endTime,
326+
status: event.previousStatus,
327+
});
328+
} else {
329+
logger.debug(`Not restoring status for user ${event.uid}: current=${user.status}, stored=${event.previousStatus}`);
330+
}
331+
}
332+
203333
private async sendCurrentNotifications(date: Date): Promise<void> {
204334
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();
205335
for await (const event of events) {

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

+4-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { api } from '@rocket.chat/core-services';
22
import { UserStatus } from '@rocket.chat/core-typings';
33
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';
4+
import { Logger } from '@rocket.chat/logger';
45
import { Users } from '@rocket.chat/models';
56

6-
import { setupAppointmentStatusChange } from './setupAppointmentStatusChange';
7+
const logger = new Logger('Calendar');
78

89
export async function applyStatusChange({
910
eventId,
1011
uid,
1112
startTime,
1213
endTime,
1314
status,
14-
shouldScheduleRemoval,
1515
}: {
1616
eventId: ICalendarEvent['_id'];
1717
uid: IUser['_id'];
@@ -20,6 +20,8 @@ export async function applyStatusChange({
2020
status?: UserStatus;
2121
shouldScheduleRemoval?: boolean;
2222
}): Promise<void> {
23+
logger.debug(`Applying status change for event ${eventId} at ${startTime} ${endTime ? `to ${endTime}` : ''} to ${status}`);
24+
2325
const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } });
2426
if (!user || user.status === UserStatus.OFFLINE) {
2527
return;
@@ -40,8 +42,4 @@ export async function applyStatusChange({
4042
},
4143
previousStatus,
4244
});
43-
44-
if (shouldScheduleRemoval && endTime) {
45-
await setupAppointmentStatusChange(eventId, uid, startTime, endTime, previousStatus, false);
46-
}
4745
}

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

-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ import { cancelUpcomingStatusChanges } from './cancelUpcomingStatusChanges';
33
import { generateCronJobId } from './generateCronJobId';
44
import { handleOverlappingEvents } from './handleOverlappingEvents';
55
import { removeCronJobs } from './removeCronJobs';
6-
import { setupAppointmentStatusChange } from './setupAppointmentStatusChange';
76

87
export const statusEventManager = {
98
applyStatusChange,
109
cancelUpcomingStatusChanges,
1110
generateCronJobId,
1211
handleOverlappingEvents,
1312
removeCronJobs,
14-
setupAppointmentStatusChange,
1513
} as const;

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

+9-3
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ export async function setupAppointmentStatusChange(
3333
await cronJobs.remove(cronJobId);
3434
}
3535

36-
await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () =>
37-
applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval }),
38-
);
36+
await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => {
37+
await applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval });
38+
39+
if (!shouldScheduleRemoval) {
40+
if (await cronJobs.has('calendar-next-status-change')) {
41+
await cronJobs.remove('calendar-next-status-change');
42+
}
43+
}
44+
});
3945
}

0 commit comments

Comments
 (0)