Skip to content
1 change: 1 addition & 0 deletions apps/meteor/ee/server/configuration/outlookCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ Meteor.startup(() =>
addSettings();

await Calendar.setupNextNotification();
await Calendar.setupNextStatusChange();
}),
);
158 changes: 144 additions & 14 deletions apps/meteor/server/services/calendar/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -82,16 +82,17 @@ 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;
}

const updateResult = await CalendarEvent.updateEvent(event._id, updateData);
if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();
if (busy !== false) {
await setupAppointmentStatusChange(event._id, uid, startTime, endTime, UserStatus.BUSY, true);
await this.setupNextStatusChange();
}
}

Expand Down Expand Up @@ -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();
}
}
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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,
uid,
startTime,
endTime,
status,
shouldScheduleRemoval,
}: {
eventId: ICalendarEvent['_id'];
uid: IUser['_id'];
Expand All @@ -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;
Expand All @@ -40,8 +42,4 @@ export async function applyStatusChange({
},
previousStatus,
});

if (shouldScheduleRemoval && endTime) {
await setupAppointmentStatusChange(eventId, uid, startTime, endTime, previousStatus, false);
}
}
2 changes: 0 additions & 2 deletions apps/meteor/server/services/calendar/statusEvents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import { cancelUpcomingStatusChanges } from './cancelUpcomingStatusChanges';
import { generateCronJobId } from './generateCronJobId';
import { handleOverlappingEvents } from './handleOverlappingEvents';
import { removeCronJobs } from './removeCronJobs';
import { setupAppointmentStatusChange } from './setupAppointmentStatusChange';

export const statusEventManager = {
applyStatusChange,
cancelUpcomingStatusChanges,
generateCronJobId,
handleOverlappingEvents,
removeCronJobs,
setupAppointmentStatusChange,
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
});
}
Loading
Loading