diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts
index bb5deeb94e..c5a911554e 100644
--- a/packages/app-store/locations.ts
+++ b/packages/app-store/locations.ts
@@ -61,6 +61,8 @@ export const DailyLocationType = "integrations:daily";
export const MeetLocationType = "integrations:google:meet";
+export const MSTeamsLocationType = "integrations:office365_video";
+
/**
* This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking
*/
diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts
index 6579b0949d..04626103f4 100644
--- a/packages/app-store/office365calendar/lib/CalendarService.ts
+++ b/packages/app-store/office365calendar/lib/CalendarService.ts
@@ -1,8 +1,9 @@
import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta";
import type { DefaultBodyType } from "msw";
+import { MSTeamsLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
-import { getLocation } from "@calcom/lib/CalEventParser";
+import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser";
import {
CalendarAppDelegationCredentialInvalidGrantError,
CalendarAppDelegationCredentialConfigurationError,
@@ -254,7 +255,13 @@ export default class Office365CalendarService implements Calendar {
body: JSON.stringify(this.translateEvent(event)),
});
- const responseJson = await handleErrorsJson
(response);
+ const responseJson = await handleErrorsJson<
+ NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
+ >(response);
+
+ if (responseJson?.onlineMeeting?.joinUrl) {
+ responseJson.url = responseJson?.onlineMeeting?.joinUrl;
+ }
return { ...responseJson, iCalUID: responseJson.iCalUId };
} catch (error) {
@@ -266,12 +273,28 @@ export default class Office365CalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarServiceEvent): Promise {
try {
+ let rescheduledEvent: Event | undefined;
+ if (event.location === MSTeamsLocationType) {
+ // Extract the existing body content to preserve the meeting blob, otherwise it breaks and converts it into non-onlineMeeting
+ const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
+ method: "GET",
+ });
+
+ rescheduledEvent = await handleErrorsJson(response);
+ }
+
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
method: "PATCH",
- body: JSON.stringify(this.translateEvent(event)),
+ body: JSON.stringify(this.translateEvent(event, rescheduledEvent)),
});
- const responseJson = await handleErrorsJson(response);
+ const responseJson = await handleErrorsJson<
+ NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
+ >(response);
+
+ if (responseJson?.onlineMeeting?.joinUrl) {
+ responseJson.url = responseJson?.onlineMeeting?.joinUrl;
+ }
return { ...responseJson, iCalUID: responseJson.iCalUId };
} catch (error) {
@@ -401,12 +424,30 @@ export default class Office365CalendarService implements Calendar {
});
}
- private translateEvent = (event: CalendarServiceEvent) => {
+ private translateEvent = (event: CalendarServiceEvent, rescheduledEvent?: Event) => {
+ const isOnlineMeeting = event.location === MSTeamsLocationType;
+ const isRescheduledOnlineMeeting = rescheduledEvent ? rescheduledEvent.isOnlineMeeting : false;
+ const existingBody =
+ rescheduledEvent?.body?.contentType === "html" ? rescheduledEvent.body.content : undefined;
+
+ let content = "";
+ if (isOnlineMeeting) {
+ if (isRescheduledOnlineMeeting && existingBody) {
+ content = `
+ ${getRichDescriptionHTML(event)}
+ ${existingBody}`.trim();
+ } else {
+ content = getRichDescriptionHTML(event);
+ }
+ } else {
+ content = event.calendarDescription;
+ }
+
const office365Event: Event = {
subject: event.title,
body: {
- contentType: "text",
- content: event.calendarDescription,
+ contentType: isOnlineMeeting ? "html" : "text",
+ content,
},
start: {
dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
@@ -456,6 +497,17 @@ export default class Office365CalendarService implements Calendar {
if (event.hideCalendarEventDetails) {
office365Event.sensitivity = "private";
}
+ if (isOnlineMeeting) {
+ office365Event.isOnlineMeeting = true;
+ office365Event.allowNewTimeProposals = true;
+ office365Event.onlineMeetingProvider = "teamsForBusiness";
+ // MSTeams sets location as 'Microsoft Teams Meeting' by default, if location is undefined.
+ // For backward compatibility, setting explicitly.
+ office365Event.location =
+ rescheduledEvent && !isRescheduledOnlineMeeting
+ ? { displayName: "Microsoft Teams Meeting" }
+ : undefined;
+ }
return office365Event;
};
diff --git a/packages/app-store/office365video/_metadata.ts b/packages/app-store/office365video/_metadata.ts
index 644048c122..7eeda9685c 100644
--- a/packages/app-store/office365video/_metadata.ts
+++ b/packages/app-store/office365video/_metadata.ts
@@ -21,6 +21,7 @@ export const metadata = {
publisher: "Cal.com",
slug: "msteams",
dirName: "office365video",
+ dependencies: ["office365-calendar"],
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
email: "help@cal.com",
isOAuth: true,
diff --git a/packages/app-store/office365video/config.json b/packages/app-store/office365video/config.json
index 9b663ad63d..64da746b4c 100644
--- a/packages/app-store/office365video/config.json
+++ b/packages/app-store/office365video/config.json
@@ -22,6 +22,7 @@
}
},
"dirName": "office365video",
+ "dependencies": ["office365-calendar"],
"concurrentMeetings": true,
"isOAuth": true
}
diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts
index c51d2d7a5f..08d0a6f2e4 100644
--- a/packages/lib/CalEventParser.ts
+++ b/packages/lib/CalEventParser.ts
@@ -314,6 +314,64 @@ type RichDescriptionCalEvent = Parameters[0] &
Parameters[0] &
Pick;
+export const getRichDescriptionHTML = (
+ calEvent: RichDescriptionCalEvent,
+ t_?: TFunction,
+ includeAppStatus = false
+) => {
+ const t = t_ ?? calEvent.organizer.language.translate;
+
+ // Helper function to convert plain text with newlines to HTML paragraphs
+ const textToHtml = (text: string) => {
+ if (!text) return "";
+ const lines = text.split("\n").filter(Boolean);
+ return lines
+ .map((line, index) => {
+ if (index === 0) {
+ return `${line}
`;
+ }
+ return `${line}
`;
+ })
+ .join("");
+ };
+
+ // Convert the manage link to a clickable hyperlink
+ const manageLinkText = getManageLink(calEvent, t);
+ const manageLinkHtml = manageLinkText
+ ? (() => {
+ const words = manageLinkText.split(" ");
+ const lastWord = words.pop();
+ if (lastWord && lastWord.includes("http")) {
+ const textWithoutLink = words.join(" ").trim();
+ return `${textWithoutLink} Click here
`;
+ }
+ return `${manageLinkText}
`;
+ })()
+ : "";
+
+ // Build the HTML content for each section
+ const parts = [
+ textToHtml(getCancellationReason(calEvent, t)),
+ textToHtml(getWhat(calEvent, t)),
+ textToHtml(getWhen(calEvent, t)),
+ textToHtml(getWho(calEvent, t)),
+ textToHtml(getDescription(calEvent, t)),
+ textToHtml(getAdditionalNotes(calEvent, t)),
+ textToHtml(getUserFieldsResponses(calEvent, t)),
+ includeAppStatus ? textToHtml(getAppsStatus(calEvent, t)) : "",
+ manageLinkHtml,
+ calEvent.paymentInfo
+ ? `${t("pay_now")}: ${
+ calEvent.paymentInfo.link
+ }
`
+ : "",
+ ]
+ .filter(Boolean) // Remove empty strings
+ .join("\n"); // Single newline between sections
+
+ return parts.trim();
+};
+
export const getRichDescription = (
calEvent: RichDescriptionCalEvent,
t_?: TFunction /*, attendee?: Person*/,
diff --git a/packages/lib/EventManager.ts b/packages/lib/EventManager.ts
index fd25434b77..5fe836b1cf 100644
--- a/packages/lib/EventManager.ts
+++ b/packages/lib/EventManager.ts
@@ -7,7 +7,7 @@ import type { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod";
-import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations";
+import { getLocationFromApp, MeetLocationType, MSTeamsLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getUid } from "@calcom/lib/CalEventParser";
@@ -251,6 +251,32 @@ export default class EventManager {
return matches;
}
+ private updateMSTeamsVideoCallData(
+ evt: CalendarEvent,
+ results: Array>>
+ ) {
+ const office365CalendarWithTeams = results.find(
+ (result) => result.type === "office365_calendar" && result.success && result.createdEvent?.url
+ );
+ if (office365CalendarWithTeams) {
+ evt.videoCallData = {
+ type: "office365_video",
+ id: office365CalendarWithTeams.createdEvent?.id,
+ password: "",
+ url: office365CalendarWithTeams.createdEvent?.url,
+ };
+ if (evt.location && evt.responses) {
+ evt.responses["location"] = {
+ ...(evt.responses["location"] ?? {}),
+ value: {
+ optionValue: "",
+ value: evt.location,
+ },
+ };
+ }
+ }
+ }
+
/**
* Takes a CalendarEvent and creates all necessary integration entries for it.
* When a video integration is chosen as the event's location, a video integration
@@ -299,12 +325,15 @@ export default class EventManager {
evt["conferenceCredentialId"] = undefined;
}
}
+
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
+ const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
const results: Array>> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
- if (isDedicated) {
+ // If the event is a Microsoft Teams meeting with Outlook Calendar, do not create a MSTeams video event, create calendar event will take care.
+ if (isDedicated && !isMSTeamsWithOutlookCalendar) {
const result = await this.createVideoEvent(evt);
if (result?.createdEvent) {
@@ -331,6 +360,10 @@ export default class EventManager {
// Create the calendar event with the proper video call data
results.push(...(await this.createAllCalendarEvents(clonedCalEvent)));
+ if (evt.location === MSTeamsLocationType) {
+ this.updateMSTeamsVideoCallData(evt, results);
+ }
+
// Since the result can be a new calendar event or video event, we have to create a type guard
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
const isCalendarResult = (
@@ -405,6 +438,10 @@ export default class EventManager {
const calendarReference = booking.references.find((reference) => reference.type.includes("_calendar"));
if (calendarReference) {
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
+
+ if (evt.location === MSTeamsLocationType) {
+ this.updateMSTeamsVideoCallData(evt, results);
+ }
}
const referencesToCreate = results.map((result) => {