Skip to content

Commit e09249b

Browse files
committed
feat: presence sync for outlook calendar
1 parent 80c7f46 commit e09249b

17 files changed

Lines changed: 347 additions & 882 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: 68 additions & 90 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,21 @@ 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 a busy event that was in progress changes "until when" the user is busy: recompute
163+
// from the events still active (lowering the expiry, or clearing if none remain).
164+
if (event && wasInProgress) {
165+
await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now });
166+
}
161167
}
162168

163169
return result;
@@ -171,10 +177,6 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
171177
return this.doSetupNextStatusChange();
172178
}
173179

174-
public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
175-
return cancelUpcomingStatusChanges(uid, endTime);
176-
}
177-
178180
private async getMeetingUrl(eventData: Partial<ICalendarEvent>): Promise<string | undefined> {
179181
if (eventData.meetingUrl !== undefined) {
180182
return eventData.meetingUrl || undefined;
@@ -210,52 +212,29 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
210212
// 2. When a busy event is updated (time/busy status changes)
211213
// 3. When a busy event is deleted
212214
// 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+
// 5. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
216+
// Note: event end is handled by the presence engine's expiration cron via statusExpiresAt
217+
218+
const schedulerJobId = 'calendar-status-scheduler';
215219

216220
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
217221
if (!busyStatusEnabled) {
218-
const schedulerJobId = 'calendar-status-scheduler';
219222
if (await cronJobs.has(schedulerJobId)) {
220223
await cronJobs.remove(schedulerJobId);
221224
}
222225
return;
223226
}
224227

225-
const schedulerJobId = 'calendar-status-scheduler';
226228
if (await cronJobs.has(schedulerJobId)) {
227229
await cronJobs.remove(schedulerJobId);
228230
}
229231

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
232+
const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date());
233+
if (!nextStartEvent) {
255234
return;
256235
}
257236

258-
await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
237+
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime());
259238
}
260239

261240
private async processStatusChangesAtTime(): Promise<void> {
@@ -269,75 +248,74 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
269248
await this.processEventStart(event);
270249
}
271250

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-
280251
await this.doSetupNextStatusChange();
281252
}
282253

283254
private async processEventStart(event: ICalendarEvent): Promise<void> {
255+
// Without an endTime there is no expiry to set, so the claim could never auto-restore.
284256
if (!event.endTime) {
285257
return;
286258
}
287259

288-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
289-
if (!user || user.statusDefault === UserStatus.OFFLINE) {
260+
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime });
261+
}
262+
263+
// The start scheduler only fires at event start times, so it never reacts to a (re)imported event
264+
// that is already in progress — whether newly imported mid-meeting or one whose end time changed on
265+
// resync. This brings the user's presence in line with the current state of such an event.
266+
private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise<void> {
267+
const event = await CalendarEvent.findOne({ _id: eventId });
268+
if (!event?.endTime || event.busy === false) {
290269
return;
291270
}
292271

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 });
272+
const now = new Date();
273+
if (event.startTime > now || event.endTime <= now) {
274+
return;
300275
}
301276

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

311-
private async processEventEnd(event: ICalendarEvent): Promise<void> {
312-
if (!event.endTime) {
280+
// Single authority for a user's calendar busy presence. Derives "busy until the latest active
281+
// meeting ends" from the events in progress right now and applies it:
282+
// - no active event -> release our claim (no-op if a higher-priority source owns it)
283+
// - already cal-busy -> refresh the expiry in place (never re-stashes previousState)
284+
// - otherwise -> apply a fresh claim (engine precedence decides if it wins)
285+
// `excludeEventId` is left out of the lookup; `seedEndTime` adds back the triggering event's own end
286+
// (used by start/reconcile, where findOverlappingEvents excludes that event).
287+
private async syncBusyPresence(
288+
uid: IUser['_id'],
289+
{ excludeEventId, seedEndTime, now = new Date() }: { excludeEventId?: ICalendarEvent['_id']; seedEndTime?: Date; now?: Date } = {},
290+
): Promise<void> {
291+
if (!settings.get<boolean>('Calendar_BusyStatus_Enabled')) {
313292
return;
314293
}
315294

316-
const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
317-
if (!user) {
295+
const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray();
296+
const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter(
297+
(date): date is Date => Boolean(date),
298+
);
299+
300+
// No busy event active anymore → end our claim. The source guard makes this a no-op if a
301+
// manual/voice status has taken over, so we never clobber a higher-priority status.
302+
if (endTimes.length === 0) {
303+
await Presence.endActiveState(uid, 'external');
318304
return;
319305
}
320306

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-
}
307+
// setActive is idempotent for the calendar's own claim: re-applying the same external source
308+
// updates the expiry in place (no re-stash), so we don't need to know whether we already own it.
309+
const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest));
310+
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(uid, { projection: { language: 1 } });
311+
const lng = user?.language || settings.get<string>('Language') || 'en';
312+
313+
await Presence.setActiveState(uid, {
314+
statusDefault: UserStatus.BUSY,
315+
statusText: i18n.t('Presence_status_in_a_meeting', { lng }),
316+
statusSource: 'external',
317+
statusExpiresAt,
318+
});
341319
}
342320

343321
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)