Skip to content

Commit 242f868

Browse files
fix: MSTeams not created as online meetings (#21377)
* fix MSTeams formatting and duplicate events * update without changing scopes, create onlineMeetings through CalendarService instead of VideoApiAdapter * get url after online meeting is created required for downstream * fallback to callvideo if only MSteams and update evt.videoCallData with url * for backward compatibility set explicitly 'Microsoft Teams Meeting' for old meetings if rescheduled * add dependency to msTeams App * nit * use interpolation * update to create MSTeams event if only MSTeams installed and not Outlook Calendar * make function MSTeams specific --------- Co-authored-by: Anik Dhabal Babu <[email protected]>
1 parent ce6969d commit 242f868

File tree

7 files changed

+166
-10
lines changed

7 files changed

+166
-10
lines changed

packages/app-store/components.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const AppDependencyComponent = ({
9797
{t("this_app_requires_connected_account", {
9898
appName,
9999
dependencyName: dependency.name,
100+
interpolation: { escapeValue: false },
100101
})}
101102
</span>
102103
</div>
@@ -112,7 +113,11 @@ export const AppDependencyComponent = ({
112113
</div>
113114
<div>
114115
<span className="font-semibold">
115-
{t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })}
116+
{t("this_app_requires_connected_account", {
117+
appName,
118+
dependencyName: dependency.name,
119+
interpolation: { escapeValue: false },
120+
})}
116121
</span>
117122

118123
<div>

packages/app-store/locations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const DailyLocationType = "integrations:daily";
6161

6262
export const MeetLocationType = "integrations:google:meet";
6363

64+
export const MSTeamsLocationType = "integrations:office365_video";
65+
6466
/**
6567
* 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
6668
*/

packages/app-store/office365calendar/lib/CalendarService.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta";
22
import type { DefaultBodyType } from "msw";
33

4+
import { MSTeamsLocationType } from "@calcom/app-store/locations";
45
import dayjs from "@calcom/dayjs";
5-
import { getLocation } from "@calcom/lib/CalEventParser";
6+
import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser";
67
import {
78
CalendarAppDelegationCredentialInvalidGrantError,
89
CalendarAppDelegationCredentialConfigurationError,
@@ -254,7 +255,13 @@ export default class Office365CalendarService implements Calendar {
254255
body: JSON.stringify(this.translateEvent(event)),
255256
});
256257

257-
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
258+
const responseJson = await handleErrorsJson<
259+
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
260+
>(response);
261+
262+
if (responseJson?.onlineMeeting?.joinUrl) {
263+
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
264+
}
258265

259266
return { ...responseJson, iCalUID: responseJson.iCalUId };
260267
} catch (error) {
@@ -266,12 +273,28 @@ export default class Office365CalendarService implements Calendar {
266273

267274
async updateEvent(uid: string, event: CalendarServiceEvent): Promise<NewCalendarEventType> {
268275
try {
276+
let rescheduledEvent: Event | undefined;
277+
if (event.location === MSTeamsLocationType) {
278+
// Extract the existing body content to preserve the meeting blob, otherwise it breaks and converts it into non-onlineMeeting
279+
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
280+
method: "GET",
281+
});
282+
283+
rescheduledEvent = await handleErrorsJson<Event>(response);
284+
}
285+
269286
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
270287
method: "PATCH",
271-
body: JSON.stringify(this.translateEvent(event)),
288+
body: JSON.stringify(this.translateEvent(event, rescheduledEvent)),
272289
});
273290

274-
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
291+
const responseJson = await handleErrorsJson<
292+
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
293+
>(response);
294+
295+
if (responseJson?.onlineMeeting?.joinUrl) {
296+
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
297+
}
275298

276299
return { ...responseJson, iCalUID: responseJson.iCalUId };
277300
} catch (error) {
@@ -401,12 +424,30 @@ export default class Office365CalendarService implements Calendar {
401424
});
402425
}
403426

404-
private translateEvent = (event: CalendarServiceEvent) => {
427+
private translateEvent = (event: CalendarServiceEvent, rescheduledEvent?: Event) => {
428+
const isOnlineMeeting = event.location === MSTeamsLocationType;
429+
const isRescheduledOnlineMeeting = rescheduledEvent ? rescheduledEvent.isOnlineMeeting : false;
430+
const existingBody =
431+
rescheduledEvent?.body?.contentType === "html" ? rescheduledEvent.body.content : undefined;
432+
433+
let content = "";
434+
if (isOnlineMeeting) {
435+
if (isRescheduledOnlineMeeting && existingBody) {
436+
content = `
437+
${getRichDescriptionHTML(event)}<hr>
438+
${existingBody}`.trim();
439+
} else {
440+
content = getRichDescriptionHTML(event);
441+
}
442+
} else {
443+
content = event.calendarDescription;
444+
}
445+
405446
const office365Event: Event = {
406447
subject: event.title,
407448
body: {
408-
contentType: "text",
409-
content: event.calendarDescription,
449+
contentType: isOnlineMeeting ? "html" : "text",
450+
content,
410451
},
411452
start: {
412453
dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
@@ -456,6 +497,17 @@ export default class Office365CalendarService implements Calendar {
456497
if (event.hideCalendarEventDetails) {
457498
office365Event.sensitivity = "private";
458499
}
500+
if (isOnlineMeeting) {
501+
office365Event.isOnlineMeeting = true;
502+
office365Event.allowNewTimeProposals = true;
503+
office365Event.onlineMeetingProvider = "teamsForBusiness";
504+
// MSTeams sets location as 'Microsoft Teams Meeting' by default, if location is undefined.
505+
// For backward compatibility, setting explicitly.
506+
office365Event.location =
507+
rescheduledEvent && !isRescheduledOnlineMeeting
508+
? { displayName: "Microsoft Teams Meeting" }
509+
: undefined;
510+
}
459511
return office365Event;
460512
};
461513

packages/app-store/office365video/_metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const metadata = {
2121
publisher: "Cal.com",
2222
slug: "msteams",
2323
dirName: "office365video",
24+
dependencies: ["office365-calendar"],
2425
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
2526
2627
isOAuth: true,

packages/app-store/office365video/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
}
2323
},
2424
"dirName": "office365video",
25+
"dependencies": ["office365-calendar"],
2526
"concurrentMeetings": true,
2627
"isOAuth": true
2728
}

packages/lib/CalEventParser.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,64 @@ type RichDescriptionCalEvent = Parameters<typeof getCancellationReason>[0] &
314314
Parameters<typeof getManageLink>[0] &
315315
Pick<CalendarEvent, "organizer" | "paymentInfo">;
316316

317+
export const getRichDescriptionHTML = (
318+
calEvent: RichDescriptionCalEvent,
319+
t_?: TFunction,
320+
includeAppStatus = false
321+
) => {
322+
const t = t_ ?? calEvent.organizer.language.translate;
323+
324+
// Helper function to convert plain text with newlines to HTML paragraphs
325+
const textToHtml = (text: string) => {
326+
if (!text) return "";
327+
const lines = text.split("\n").filter(Boolean);
328+
return lines
329+
.map((line, index) => {
330+
if (index === 0) {
331+
return `<p><strong>${line}</strong></p>`;
332+
}
333+
return `<p>${line}</p>`;
334+
})
335+
.join("");
336+
};
337+
338+
// Convert the manage link to a clickable hyperlink
339+
const manageLinkText = getManageLink(calEvent, t);
340+
const manageLinkHtml = manageLinkText
341+
? (() => {
342+
const words = manageLinkText.split(" ");
343+
const lastWord = words.pop();
344+
if (lastWord && lastWord.includes("http")) {
345+
const textWithoutLink = words.join(" ").trim();
346+
return `<p><strong>${textWithoutLink}</strong> <a href="${lastWord}">Click here</a></p>`;
347+
}
348+
return `<p>${manageLinkText}</p>`;
349+
})()
350+
: "";
351+
352+
// Build the HTML content for each section
353+
const parts = [
354+
textToHtml(getCancellationReason(calEvent, t)),
355+
textToHtml(getWhat(calEvent, t)),
356+
textToHtml(getWhen(calEvent, t)),
357+
textToHtml(getWho(calEvent, t)),
358+
textToHtml(getDescription(calEvent, t)),
359+
textToHtml(getAdditionalNotes(calEvent, t)),
360+
textToHtml(getUserFieldsResponses(calEvent, t)),
361+
includeAppStatus ? textToHtml(getAppsStatus(calEvent, t)) : "",
362+
manageLinkHtml,
363+
calEvent.paymentInfo
364+
? `<p><strong>${t("pay_now")}:</strong> <a href="${calEvent.paymentInfo.link}">${
365+
calEvent.paymentInfo.link
366+
}</a></p>`
367+
: "",
368+
]
369+
.filter(Boolean) // Remove empty strings
370+
.join("\n"); // Single newline between sections
371+
372+
return parts.trim();
373+
};
374+
317375
export const getRichDescription = (
318376
calEvent: RichDescriptionCalEvent,
319377
t_?: TFunction /*, attendee?: Person*/,

packages/lib/EventManager.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { z } from "zod";
77
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
88
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
99
import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod";
10-
import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations";
10+
import { getLocationFromApp, MeetLocationType, MSTeamsLocationType } from "@calcom/app-store/locations";
1111
import getApps from "@calcom/app-store/utils";
1212
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
1313
import { getUid } from "@calcom/lib/CalEventParser";
@@ -251,6 +251,32 @@ export default class EventManager {
251251
return matches;
252252
}
253253

254+
private updateMSTeamsVideoCallData(
255+
evt: CalendarEvent,
256+
results: Array<EventResult<Exclude<Event, AdditionalInformation>>>
257+
) {
258+
const office365CalendarWithTeams = results.find(
259+
(result) => result.type === "office365_calendar" && result.success && result.createdEvent?.url
260+
);
261+
if (office365CalendarWithTeams) {
262+
evt.videoCallData = {
263+
type: "office365_video",
264+
id: office365CalendarWithTeams.createdEvent?.id,
265+
password: "",
266+
url: office365CalendarWithTeams.createdEvent?.url,
267+
};
268+
if (evt.location && evt.responses) {
269+
evt.responses["location"] = {
270+
...(evt.responses["location"] ?? {}),
271+
value: {
272+
optionValue: "",
273+
value: evt.location,
274+
},
275+
};
276+
}
277+
}
278+
}
279+
254280
/**
255281
* Takes a CalendarEvent and creates all necessary integration entries for it.
256282
* When a video integration is chosen as the event's location, a video integration
@@ -299,12 +325,15 @@ export default class EventManager {
299325
evt["conferenceCredentialId"] = undefined;
300326
}
301327
}
328+
302329
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
330+
const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
303331

304332
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
305333

306334
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
307-
if (isDedicated) {
335+
// If the event is a Microsoft Teams meeting with Outlook Calendar, do not create a MSTeams video event, create calendar event will take care.
336+
if (isDedicated && !isMSTeamsWithOutlookCalendar) {
308337
const result = await this.createVideoEvent(evt);
309338

310339
if (result?.createdEvent) {
@@ -331,6 +360,10 @@ export default class EventManager {
331360
// Create the calendar event with the proper video call data
332361
results.push(...(await this.createAllCalendarEvents(clonedCalEvent)));
333362

363+
if (evt.location === MSTeamsLocationType) {
364+
this.updateMSTeamsVideoCallData(evt, results);
365+
}
366+
334367
// Since the result can be a new calendar event or video event, we have to create a type guard
335368
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
336369
const isCalendarResult = (
@@ -405,6 +438,10 @@ export default class EventManager {
405438
const calendarReference = booking.references.find((reference) => reference.type.includes("_calendar"));
406439
if (calendarReference) {
407440
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
441+
442+
if (evt.location === MSTeamsLocationType) {
443+
this.updateMSTeamsVideoCallData(evt, results);
444+
}
408445
}
409446

410447
const referencesToCreate = results.map((result) => {

0 commit comments

Comments
 (0)