Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4c4ae65
schedules status change on calendar item creation
ricardogarim Mar 12, 2025
f4356c5
adds Calendar_BusyStatus_Enabled setting
ricardogarim Mar 12, 2025
d0bf7e4
makes update and delete operations to verify existing crons
ricardogarim Mar 12, 2025
acabc00
removes crons when user manually changes status
ricardogarim Mar 12, 2025
1c1a386
t adds endTime on CalendarEventCreateProps
ricardogarim Mar 12, 2025
b3638bf
enables Calendar_BusyStatus_Enabled setting
ricardogarim Mar 12, 2025
67fe4cc
adds updateStatus method on Users model
ricardogarim Mar 12, 2025
f144965
splits status management into new file
ricardogarim Mar 12, 2025
56fbb14
typecheck fix
ricardogarim Mar 12, 2025
3df6595
adds unit tests
ricardogarim Mar 13, 2025
a4491d8
adds changeset
ricardogarim Mar 13, 2025
f47e039
fixes endTime initialization
ricardogarim Mar 18, 2025
aa6335f
moves db calls to models specific functions
ricardogarim Mar 18, 2025
1263ebe
fixes unit tests
ricardogarim Mar 18, 2025
93100fc
adds schedule overlapping tests
ricardogarim Mar 18, 2025
f6de7b7
moved some code to separate files
pierre-lehnen-rc Mar 18, 2025
4f46eb6
finished moving tests
pierre-lehnen-rc Mar 19, 2025
e3501f4
fixed some tests, disabled others
pierre-lehnen-rc Mar 19, 2025
6a7e275
removes unused import
ricardogarim Mar 19, 2025
642add6
add e2e test
gabriellsh Mar 19, 2025
fc37624
add missing params to endpoints
pierre-lehnen-rc Mar 20, 2025
4937f54
improved most unit tests
pierre-lehnen-rc Mar 20, 2025
bb6cd4f
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
pierre-lehnen-rc Mar 20, 2025
625e368
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
scuciatto Mar 20, 2025
263cfc6
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
Mar 20, 2025
2fad2e5
removes only flag from e2e test
ricardogarim Mar 20, 2025
2655c48
Merge from develop
ricardogarim Mar 20, 2025
483be88
fix types
ricardogarim Mar 20, 2025
3e89d20
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
a5ff930
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
fdb95fa
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
66c5e2d
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
dd4eebc
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
d00460d
Merge branch 'develop' into feat/set-busy-status-on-calendar-appointm…
kodiakhq[bot] Mar 20, 2025
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: 8 additions & 0 deletions .changeset/lovely-ways-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/models': minor
'@rocket.chat/meteor': minor
---

Adds automatic presence sync based on calendar events, updating the user’s status to “busy” when a meeting starts and reverting it afterward.
3 changes: 2 additions & 1 deletion apps/meteor/app/api/server/v1/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ API.v1.addRoute(
{
async post() {
const { userId: uid } = this;
const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;
const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;

const id = await Calendar.create({
uid,
startTime: new Date(startTime),
...(endTime ? { endTime: new Date(endTime) } : {}),
externalId,
subject,
description,
Expand Down
6 changes: 4 additions & 2 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, Team, api } from '@rocket.chat/core-services';
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions } from '@rocket.chat/models';
import {
Expand All @@ -19,7 +19,7 @@ import {
isUsersCheckUsernameAvailabilityParamsGET,
isUsersSendConfirmationEmailParamsPOST,
} from '@rocket.chat/rest-typings';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { getLoginExpirationInMs, wrapExceptions } 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 @@ -1337,6 +1337,8 @@ API.v1.addRoute(
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});

void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/ee/server/settings/outlookCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export function addSettings(): void {
invalidValue: '',
},
);

await this.add('Calendar_BusyStatus_Enabled', true, {
type: 'boolean',
public: true,
invalidValue: false,
});
},
);
});
Expand Down
95 changes: 74 additions & 21 deletions apps/meteor/server/services/calendar/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { ICalendarService } from '@rocket.chat/core-services';
import { 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';
import { Logger } from '@rocket.chat/logger';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CalendarEvent } from '@rocket.chat/models';
import type { UpdateResult, DeleteResult } from 'mongodb';

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 All @@ -18,24 +23,28 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
protected name = 'calendar';

public async create(data: Omit<InsertionModel<ICalendarEvent>, 'reminderTime' | 'notificationSent'>): Promise<ICalendarEvent['_id']> {
const { uid, startTime, subject, description, reminderMinutesBeforeStart, meetingUrl } = data;

const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, meetingUrl, busy } = data;
const minutes = reminderMinutesBeforeStart ?? defaultMinutesForNotifications;
const reminderTime = minutes ? this.getShiftedTime(startTime, -minutes) : undefined;
const reminderTime = minutes ? getShiftedTime(startTime, -minutes) : undefined;

const insertData: InsertionModel<ICalendarEvent> = {
uid,
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart: minutes,
reminderTime,
notificationSent: false,
...(busy !== undefined && { busy }),
};

const insertResult = await CalendarEvent.insertOne(insertData);
await this.setupNextNotification();
if (busy !== false) {
await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true);
}

return insertResult.insertedId;
}
Expand All @@ -46,18 +55,20 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
return this.create(data);
}

const { uid, startTime, subject, description, reminderMinutesBeforeStart } = data;
const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data;
const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description);
const reminderTime = reminderMinutesBeforeStart ? this.getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;

const updateData: Omit<InsertionModel<ICalendarEvent>, 'uid' | 'notificationSent'> = {
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
externalId,
...(busy !== undefined && { busy }),
};

const event = await this.findImportedEvent(externalId, uid);
Expand All @@ -70,12 +81,18 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
});

await this.setupNextNotification();
if (busy !== false) {
await setupAppointmentStatusChange(insertResult.insertedId, uid, startTime, endTime, UserStatus.BUSY, true);
}
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);
}
}

return event._id;
Expand All @@ -89,30 +106,58 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
return CalendarEvent.findByUserIdAndDate(uid, date).toArray();
}

public async update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult> {
const { startTime, subject, description, reminderMinutesBeforeStart } = data;
const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description || '');
const reminderTime = reminderMinutesBeforeStart && startTime ? this.getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
public async update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult | null> {
const event = await this.get(eventId);
if (!event) {
return null;
}

const { startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data;

const meetingUrl = await this.getMeetingUrl(data);
const reminderTime = reminderMinutesBeforeStart && startTime ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;

const updateData: Partial<ICalendarEvent> = {
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
...(busy !== undefined && { busy }),
};

const updateResult = await CalendarEvent.updateEvent(eventId, updateData);

if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();

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);
}
}
}
}

return updateResult;
}

public async delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult> {
const event = await this.get(eventId);
if (event) {
await removeCronJobs(eventId, event.uid);
}

return CalendarEvent.deleteOne({
_id: eventId,
});
Expand All @@ -122,6 +167,22 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
return this.doSetupNextNotification(false);
}

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;
}

if (eventData.description !== undefined) {
return this.parseDescriptionForMeetingUrl(eventData.description);
}

return undefined;
}

private async doSetupNextNotification(isRecursive: boolean): Promise<void> {
const date = await CalendarEvent.findNextNotificationDate();
if (!date) {
Expand All @@ -139,19 +200,17 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date));
}

public async sendCurrentNotifications(date: Date): Promise<void> {
private async sendCurrentNotifications(date: Date): Promise<void> {
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();

for await (const event of events) {
await this.sendEventNotification(event);

await CalendarEvent.flagNotificationSent(event._id);
}

await this.doSetupNextNotification(true);
}

public async sendEventNotification(event: ICalendarEvent): Promise<void> {
private async sendEventNotification(event: ICalendarEvent): Promise<void> {
if (!(await getUserPreference(event.uid, 'notifyCalendarEvents'))) {
return;
}
Expand All @@ -165,14 +224,14 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
});
}

public async findImportedEvent(
private async findImportedEvent(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null> {
return CalendarEvent.findOneByExternalIdAndUserId(externalId, uid);
}

public async parseDescriptionForMeetingUrl(description: string): Promise<string | undefined> {
private async parseDescriptionForMeetingUrl(description: string): Promise<string | undefined> {
if (!description) {
return;
}
Expand Down Expand Up @@ -224,10 +283,4 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe

return undefined;
}

private getShiftedTime(time: Date, minutes: number): Date {
const newTime = new Date(time.valueOf());
newTime.setMinutes(newTime.getMinutes() + minutes);
return newTime;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { api } from '@rocket.chat/core-services';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';

import { setupAppointmentStatusChange } from './setupAppointmentStatusChange';

export async function applyStatusChange({
eventId,
uid,
startTime,
endTime,
status,
shouldScheduleRemoval,
}: {
eventId: ICalendarEvent['_id'];
uid: IUser['_id'];
startTime: Date;
endTime?: Date;
status?: UserStatus;
shouldScheduleRemoval?: boolean;
}): Promise<void> {
const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } });
if (!user || user.status === UserStatus.OFFLINE) {
return;
}

const newStatus = status ?? UserStatus.BUSY;
const previousStatus = user.status;

await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus);

await api.broadcast('presence.status', {
user: {
status: newStatus,
_id: uid,
roles: user.roles,
username: user.username,
name: user.name,
},
previousStatus,
});

if (shouldScheduleRemoval && endTime) {
await setupAppointmentStatusChange(eventId, uid, startTime, endTime, previousStatus, false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { IUser } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { CalendarEvent } from '@rocket.chat/models';

import { generateCronJobId } from './generateCronJobId';
import { settings } from '../../../../app/settings/server';

export async function cancelUpcomingStatusChanges(uid: IUser['_id'], endTime = new Date()): Promise<void> {
const hasBusyStatusSetting = settings.get<boolean>('Calendar_BusyStatus_Enabled');
if (!hasBusyStatusSetting) {
return;
}

const events = await CalendarEvent.findEligibleEventsForCancelation(uid, endTime).toArray();

for await (const event of events) {
const statusChangeJobId = generateCronJobId(event._id, event.uid, 'status');
if (await cronJobs.has(statusChangeJobId)) {
await cronJobs.remove(statusChangeJobId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';

export function generateCronJobId(eventId: ICalendarEvent['_id'], uid: IUser['_id'], eventType: 'status' | 'reminder'): string {
if (!eventId || !uid || !eventType || (eventType !== 'status' && eventType !== 'reminder')) {
throw new Error('Missing required parameters. Please provide eventId, uid and eventType (status or reminder)');
}

if (eventType === 'status') {
return `calendar-presence-status-${eventId}-${uid}`;
}

return `calendar-reminder-${eventId}-${uid}`;
}
Loading
Loading