Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MeteorError, Presence, Team, Calendar } from '@rocket.chat/core-services';
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
import {
Expand Down Expand Up @@ -29,7 +29,7 @@ import {
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -2029,10 +2029,6 @@ API.v1

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

if (status) {
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
}

return API.v1.success();
},
)
Expand Down
7 changes: 4 additions & 3 deletions apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class AppUserBridge extends UserBridge {

protected async setActiveState(
userId: IUser['id'],
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt'>,
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
appId: string,
): Promise<void> {
this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`);
Expand All @@ -191,13 +191,14 @@ export class AppUserBridge extends UserBridge {
statusText: state.statusText,
statusSource: state.statusSource as PresenceSource,
...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }),
...(state.statusId && { statusId: state.statusId }),
});
}

protected async endActiveState(userId: IUser['id'], appId: string): Promise<void> {
protected async endActiveState(userId: IUser['id'], appId: string, statusId?: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`);

await Presence.endActiveState(userId);
await Presence.endActiveState(userId, statusId);
}

protected async getActiveUserCount(): Promise<number> {
Expand Down
166 changes: 66 additions & 100 deletions apps/meteor/server/services/calendar/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ICalendarService } from '@rocket.chat/core-services';
import { ServiceClassInternal, api } from '@rocket.chat/core-services';
import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services';
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
Expand All @@ -8,12 +8,10 @@ import type { InsertionModel } from '@rocket.chat/model-typings';
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 { getShiftedTime } from './utils/getShiftedTime';
import { settings } from '../../../app/settings/server';
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
import { i18n } from '../../lib/i18n';

const logger = new Logger('Calendar');

Expand Down Expand Up @@ -44,6 +42,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(insertResult.insertedId);
}

return insertResult.insertedId;
Expand Down Expand Up @@ -83,6 +82,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(insertResult.insertedId);
}

return insertResult.insertedId;
Expand All @@ -93,6 +93,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(event._id);
}
}

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

if (startTime || endTime) {
await removeCronJobs(eventId, event.uid);
const isBusy = busy !== undefined ? busy : event.busy !== false;
if (isBusy) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(eventId);
}
}
}
Expand All @@ -148,16 +149,20 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe

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

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

if (result.deletedCount > 0) {
await this.setupNextStatusChange();

// deleting an in-progress busy event: recompute the claim from the events still active
if (event && wasInProgress) {
await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now });
}
}

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

public async cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
return cancelUpcomingStatusChanges(uid, endTime);
}

private async getMeetingUrl(eventData: Partial<ICalendarEvent>): Promise<string | undefined> {
if (eventData.meetingUrl !== undefined) {
return eventData.meetingUrl || undefined;
Expand Down Expand Up @@ -205,139 +206,104 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
}

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)
// Schedules only event starts; event end is handled by the presence engine's expiration cron.

const schedulerJobId = 'calendar-status-scheduler';

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

await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, 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;
try {
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);
}
await this.processEventEnd(event);
} catch (err) {
logger.error({ msg: 'Failed to process calendar status changes', err });
}

await this.doSetupNextStatusChange();
}

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

const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
if (!user || user.statusDefault === UserStatus.OFFLINE) {
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime });
Comment thread
sampaiodiego marked this conversation as resolved.
}

// The start scheduler only fires at start times, so it misses an event imported already in progress.
private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise<void> {
const event = await CalendarEvent.findOne({ _id: eventId });
if (!event?.endTime || event.busy === false) {
return;
}

const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime)
.sort({ startTime: -1 })
.toArray();
const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault;

if (previousStatus) {
await CalendarEvent.updateEvent(event._id, { previousStatus });
const now = new Date();
if (event.startTime > now || event.endTime <= now) {
return;
}

await applyStatusChange({
eventId: event._id,
uid: event.uid,
startTime: event.startTime,
endTime: event.endTime,
status: UserStatus.BUSY,
});
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime, now });
}

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

const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } });
if (!user) {
const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray();
const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter(
(date): date is Date => Boolean(date),
);

// No busy event left → end the calendar claim (no-op if a higher-priority status took over).
if (endTimes.length === 0) {
await Presence.endActiveState(uid, this.name);
return;
}

// Only restore status if:
// 1. The current statusDefault 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 (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) {
await applyStatusChange({
eventId: event._id,
uid: event.uid,
startTime: event.startTime,
endTime: event.endTime,
status: event.previousStatus,
});
} else {
logger.debug({
msg: 'Not restoring status for user',
userId: event.uid,
currentStatusDefault: user.statusDefault,
previousStatus: event.previousStatus,
});
}
const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest));
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(uid, { projection: { language: 1 } });
const lng = user?.language || settings.get<string>('Language') || 'en';

await Presence.setActiveState(uid, {
statusDefault: UserStatus.BUSY,
statusText: i18n.t('Presence_status_in_a_meeting', { lng }),
statusSource: 'external',
statusExpiresAt,
statusId: this.name,
});
}

private async sendCurrentNotifications(date: Date): Promise<void> {
Expand Down
Loading
Loading