Skip to content

Commit d320d36

Browse files
committed
feat: presence sync for calendar
1 parent 3745234 commit d320d36

17 files changed

Lines changed: 337 additions & 890 deletions

File tree

apps/meteor/app/api/server/v1/users.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MeteorError, Presence, Team, Calendar } from '@rocket.chat/core-services';
1+
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
22
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
33
import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
44
import {
@@ -29,7 +29,7 @@ import {
2929
validateForbiddenErrorResponse,
3030
} from '@rocket.chat/rest-typings';
3131
import { escapeRegExp } from '@rocket.chat/string-helpers';
32-
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
32+
import { getLoginExpirationInMs } from '@rocket.chat/tools';
3333
import { Accounts } from 'meteor/accounts-base';
3434
import { Match, check } from 'meteor/check';
3535
import { Meteor } from 'meteor/meteor';
@@ -2029,10 +2029,6 @@ API.v1
20292029

20302030
await Presence.setStatus(user._id, effectiveStatus, message, statusExpiresAt);
20312031

2032-
if (status) {
2033-
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
2034-
}
2035-
20362032
return API.v1.success();
20372033
},
20382034
)

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

Lines changed: 57 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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';
@@ -8,12 +8,10 @@ import type { InsertionModel } from '@rocket.chat/model-typings';
88
import { CalendarEvent, Users } from '@rocket.chat/models';
99
import type { UpdateResult, DeleteResult } from 'mongodb';
1010

11-
import { applyStatusChange } from './statusEvents/applyStatusChange';
12-
import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges';
13-
import { removeCronJobs } from './statusEvents/removeCronJobs';
1411
import { getShiftedTime } from './utils/getShiftedTime';
1512
import { settings } from '../../../app/settings/server';
1613
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
14+
import { i18n } from '../../lib/i18n';
1715

1816
const logger = new Logger('Calendar');
1917

@@ -44,6 +42,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
4442
await this.setupNextNotification();
4543
if (busy !== false) {
4644
await this.setupNextStatusChange();
45+
await this.reconcileInProgressEvent(insertResult.insertedId);
4746
}
4847

4948
return insertResult.insertedId;
@@ -83,6 +82,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
8382
await this.setupNextNotification();
8483
if (busy !== false) {
8584
await this.setupNextStatusChange();
85+
await this.reconcileInProgressEvent(insertResult.insertedId);
8686
}
8787

8888
return insertResult.insertedId;
@@ -93,6 +93,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
9393
await this.setupNextNotification();
9494
if (busy !== false) {
9595
await this.setupNextStatusChange();
96+
await this.reconcileInProgressEvent(event._id);
9697
}
9798
}
9899

@@ -135,10 +136,10 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
135136
await this.setupNextNotification();
136137

137138
if (startTime || endTime) {
138-
await removeCronJobs(eventId, event.uid);
139139
const isBusy = busy !== undefined ? busy : event.busy !== false;
140140
if (isBusy) {
141141
await this.setupNextStatusChange();
142+
await this.reconcileInProgressEvent(eventId);
142143
}
143144
}
144145
}
@@ -148,16 +149,20 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
148149

149150
public async delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult> {
150151
const event = await this.get(eventId);
151-
if (event) {
152-
await removeCronJobs(eventId, event.uid);
153-
}
152+
const now = new Date();
153+
const wasInProgress = Boolean(event && event.busy !== false && event.startTime <= now && (!event.endTime || event.endTime > now));
154154

155155
const result = await CalendarEvent.deleteOne({
156156
_id: eventId,
157157
});
158158

159159
if (result.deletedCount > 0) {
160160
await this.setupNextStatusChange();
161+
162+
// deleting an in-progress busy event: recompute the claim from the events still active
163+
if (event && wasInProgress) {
164+
await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now });
165+
}
161166
}
162167

163168
return result;
@@ -171,10 +176,6 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
171176
return this.doSetupNextStatusChange();
172177
}
173178

174-
public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
175-
return cancelUpcomingStatusChanges(uid, endTime);
176-
}
177-
178179
private async getMeetingUrl(eventData: Partial<ICalendarEvent>): Promise<string | undefined> {
179180
if (eventData.meetingUrl !== undefined) {
180181
return eventData.meetingUrl || undefined;
@@ -205,57 +206,28 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
205206
}
206207

207208
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)
209+
// Schedules only event starts; event end is handled by the presence engine's expiration cron.
210+
211+
const schedulerJobId = 'calendar-status-scheduler';
215212

216213
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
217214
if (!busyStatusEnabled) {
218-
const schedulerJobId = 'calendar-status-scheduler';
219215
if (await cronJobs.has(schedulerJobId)) {
220216
await cronJobs.remove(schedulerJobId);
221217
}
222218
return;
223219
}
224220

225-
const schedulerJobId = 'calendar-status-scheduler';
226221
if (await cronJobs.has(schedulerJobId)) {
227222
await cronJobs.remove(schedulerJobId);
228223
}
229224

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
225+
const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date());
226+
if (!nextStartEvent) {
255227
return;
256228
}
257229

258-
await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
230+
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime());
259231
}
260232

261233
private async processStatusChangesAtTime(): Promise<void> {
@@ -269,75 +241,65 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
269241
await this.processEventStart(event);
270242
}
271243

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-
280244
await this.doSetupNextStatusChange();
281245
}
282246

283247
private async processEventStart(event: ICalendarEvent): Promise<void> {
248+
// no endTime → no expiry to set, so the claim could never auto-clear
284249
if (!event.endTime) {
285250
return;
286251
}
287252

288-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
289-
if (!user || user.statusDefault === UserStatus.OFFLINE) {
253+
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime });
254+
}
255+
256+
// The start scheduler only fires at start times, so it misses an event imported already in progress.
257+
private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise<void> {
258+
const event = await CalendarEvent.findOne({ _id: eventId });
259+
if (!event?.endTime || event.busy === false) {
290260
return;
291261
}
292262

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 });
263+
const now = new Date();
264+
if (event.startTime > now || event.endTime <= now) {
265+
return;
300266
}
301267

302-
await applyStatusChange({
303-
eventId: event._id,
304-
uid: event.uid,
305-
startTime: event.startTime,
306-
endTime: event.endTime,
307-
status: UserStatus.BUSY,
308-
});
268+
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime, now });
309269
}
310270

311-
private async processEventEnd(event: ICalendarEvent): Promise<void> {
312-
if (!event.endTime) {
271+
// Derives "busy until the latest active meeting ends" from the events in progress now and applies
272+
// it as one calendar claim. `excludeEventId`/`seedEndTime` re-add the triggering event's own end.
273+
private async syncBusyPresence(
274+
uid: IUser['_id'],
275+
{ excludeEventId, seedEndTime, now = new Date() }: { excludeEventId?: ICalendarEvent['_id']; seedEndTime?: Date; now?: Date } = {},
276+
): Promise<void> {
277+
if (!settings.get<boolean>('Calendar_BusyStatus_Enabled')) {
313278
return;
314279
}
315280

316-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
317-
if (!user) {
281+
const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray();
282+
const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter(
283+
(date): date is Date => Boolean(date),
284+
);
285+
286+
// No busy event left → end the calendar claim (no-op if a higher-priority status took over).
287+
if (endTimes.length === 0) {
288+
await Presence.endActiveState(uid, this.name);
318289
return;
319290
}
320291

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-
}
292+
const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest));
293+
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(uid, { projection: { language: 1 } });
294+
const lng = user?.language || settings.get<string>('Language') || 'en';
295+
296+
await Presence.setActiveState(uid, {
297+
statusDefault: UserStatus.BUSY,
298+
statusText: i18n.t('Presence_status_in_a_meeting', { lng }),
299+
statusSource: 'external',
300+
statusExpiresAt,
301+
statusId: this.name,
302+
});
341303
}
342304

343305
private async sendCurrentNotifications(date: Date): Promise<void> {

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

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

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

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

0 commit comments

Comments
 (0)