fix: `videoCallUrl` not updating when rescheduling with a broken Calendar integration (#11923)

This commit is contained in:
Hariom Balhara 2023-10-17 16:46:24 +05:30 committed by GitHub
parent 7a014761dc
commit d12a5c5883
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 277 additions and 101 deletions

View File

@ -166,6 +166,7 @@
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"module-alias": "^2.2.2", "module-alias": "^2.2.2",
"msw": "^0.42.3", "msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"postcss": "^8.4.18", "postcss": "^8.4.18",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"tailwindcss-animate": "^1.0.6", "tailwindcss-animate": "^1.0.6",

View File

@ -2,7 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma"; import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { BookingReference, Attendee } from "@prisma/client"; import type { BookingReference, Attendee, Booking } from "@prisma/client";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe"; import type Stripe from "stripe";
@ -102,7 +102,7 @@ export type InputEventType = {
schedule?: InputUser["schedules"][number]; schedule?: InputUser["schedules"][number];
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>; } & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type InputBooking = { type WhiteListedBookingProps = {
id?: number; id?: number;
uid?: string; uid?: string;
userId?: number; userId?: number;
@ -118,6 +118,8 @@ type InputBooking = {
})[]; })[];
}; };
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
export const Timezones = { export const Timezones = {
"+5:30": "Asia/Kolkata", "+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka", "+6:00": "Asia/Dhaka",
@ -1203,3 +1205,35 @@ export const enum BookingLocations {
CalVideo = "integrations:daily", CalVideo = "integrations:daily",
ZoomVideo = "integrations:zoom", ZoomVideo = "integrations:zoom",
} }
const getMockAppStatus = ({
slug,
failures,
success,
}: {
slug: string;
failures: number;
success: number;
}) => {
const foundEntry = Object.entries(appStoreMetadata).find(([, app]) => {
return app.slug === slug;
});
if (!foundEntry) {
throw new Error("App not found for the slug");
}
const foundApp = foundEntry[1];
return {
appName: foundApp.slug,
type: foundApp.type,
failures,
success,
errors: [],
};
};
export const getMockFailingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 1, success: 0 });
};
export const getMockPassingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 0, success: 1 });
};

View File

@ -1,6 +1,7 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client"; import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
import { parse } from "node-html-parser";
import ical from "node-ical"; import ical from "node-ical";
import { expect } from "vitest"; import { expect } from "vitest";
import "vitest-fetch-mock"; import "vitest-fetch-mock";
@ -8,6 +9,7 @@ import "vitest-fetch-mock";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures"; import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
@ -19,14 +21,14 @@ declare global {
interface Matchers<R> { interface Matchers<R> {
toHaveEmail( toHaveEmail(
expectedEmail: { expectedEmail: {
//TODO: Support email HTML parsing to target specific elements title?: string;
htmlToContain?: string;
to: string; to: string;
noIcs?: true; noIcs?: true;
ics?: { ics?: {
filename: string; filename: string;
iCalUID: string; iCalUID: string;
}; };
appsStatus?: AppsStatus[];
}, },
to: string to: string
): R; ): R;
@ -38,21 +40,23 @@ expect.extend({
toHaveEmail( toHaveEmail(
emails: Fixtures["emails"], emails: Fixtures["emails"],
expectedEmail: { expectedEmail: {
//TODO: Support email HTML parsing to target specific elements title?: string;
htmlToContain?: string;
to: string; to: string;
ics: { ics: {
filename: string; filename: string;
iCalUID: string; iCalUID: string;
}; };
noIcs: true; noIcs: true;
appsStatus: AppsStatus[];
}, },
to: string to: string
) { ) {
const { isNot } = this;
const testEmail = emails.get().find((email) => email.to.includes(to)); const testEmail = emails.get().find((email) => email.to.includes(to));
const emailsToLog = emails const emailsToLog = emails
.get() .get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent })); .map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
if (!testEmail) { if (!testEmail) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return { return {
@ -63,48 +67,93 @@ expect.extend({
const ics = testEmail.icalEvent; const ics = testEmail.icalEvent;
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null; const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
let isHtmlContained = true;
let isToAddressExpected = true; let isToAddressExpected = true;
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true; const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
const isIcsUIDExpected = expectedEmail.ics const isIcsUIDExpected = expectedEmail.ics
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null) ? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
: true; : true;
const emailDom = parse(testEmail.html);
if (expectedEmail.htmlToContain) { const actualEmailContent = {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain); title: emailDom.querySelector("title")?.innerText,
subject: emailDom.querySelector("subject")?.innerText,
};
const expectedEmailContent = {
title: expectedEmail.title,
};
const isEmailContentMatched = this.equals(
actualEmailContent,
expect.objectContaining(expectedEmailContent)
);
if (!isEmailContentMatched) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `Email content ${isNot ? "is" : "is not"} matching`,
actual: actualEmailContent,
expected: expectedEmailContent,
};
} }
isToAddressExpected = expectedEmail.to === testEmail.to; isToAddressExpected = expectedEmail.to === testEmail.to;
if (!isToAddressExpected) {
if (!isHtmlContained || !isToAddressExpected) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `To address ${isNot ? "is" : "is not"} matching`,
actual: testEmail.to,
expected: expectedEmail.to,
};
}
if (!expectedEmail.noIcs && !isIcsFilenameExpected) {
return {
pass: false,
actual: ics?.filename,
expected: expectedEmail.ics.filename,
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
};
}
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
return {
pass: false,
actual: JSON.stringify(icsObject),
expected: expectedEmail.ics.iCalUID,
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
};
}
if (expectedEmail.appsStatus) {
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
return li.innerText.trim();
});
const expectedAppStatus = expectedEmail.appsStatus.map((appStatus) => {
if (appStatus.success && !appStatus.failures) {
return `${appStatus.appName}`;
}
return `${appStatus.appName}`;
});
const isAppsStatusCorrect = this.equals(actualAppsStatus, expectedAppStatus);
if (!isAppsStatusCorrect) {
return {
pass: false,
actual: actualAppsStatus,
expected: expectedAppStatus,
message: () => `AppsStatus ${isNot ? "is" : "isn't"} matching`,
};
}
} }
return { return {
pass: pass: true,
isHtmlContained && message: () => `Email ${isNot ? "is" : "isn't"} correct`,
isToAddressExpected &&
(expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected),
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
if (!isToAddressExpected) {
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
}
if (!isIcsFilenameExpected) {
return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`;
}
if (!isIcsUIDExpected) {
return `ICS UID is not as expected. Expected:${
expectedEmail.ics.iCalUID
} isn't present in ${JSON.stringify(icsObject)}`;
}
throw new Error("Unknown error");
},
}; };
}, },
}); });
@ -139,9 +188,10 @@ export function expectWebhookToHaveBeenCalledWith(
const parsedBody = JSON.parse((body as string) || "{}"); const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent); expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) { if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID") ? parsedBody.payload.metadata.videoCallUrl
: parsedBody.payload.metadata.videoCallUrl; : parsedBody.payload.metadata.videoCallUrl;
} }
if (data.payload) { if (data.payload) {
@ -195,7 +245,7 @@ export function expectSuccessfulBookingCreationEmails({
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>confirmed_event_type_subject</title>", title: "confirmed_event_type_subject",
to: `${organizer.email}`, to: `${organizer.email}`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -207,7 +257,7 @@ export function expectSuccessfulBookingCreationEmails({
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>confirmed_event_type_subject</title>", title: "confirmed_event_type_subject",
to: `${booker.name} <${booker.email}>`, to: `${booker.name} <${booker.email}>`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -221,7 +271,7 @@ export function expectSuccessfulBookingCreationEmails({
otherTeamMembers.forEach((otherTeamMember) => { otherTeamMembers.forEach((otherTeamMember) => {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>confirmed_event_type_subject</title>", title: "confirmed_event_type_subject",
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker // Don't know why but organizer and team members of the eventType don'thave their name here like Booker
to: `${otherTeamMember.email}`, to: `${otherTeamMember.email}`,
ics: { ics: {
@ -238,7 +288,7 @@ export function expectSuccessfulBookingCreationEmails({
guests.forEach((guest) => { guests.forEach((guest) => {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>confirmed_event_type_subject</title>", title: "confirmed_event_type_subject",
to: `${guest.email}`, to: `${guest.email}`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -261,7 +311,7 @@ export function expectBrokenIntegrationEmails({
// Broken Integration email is only sent to the Organizer // Broken Integration email is only sent to the Organizer
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>broken_integration</title>", title: "broken_integration",
to: `${organizer.email}`, to: `${organizer.email}`,
// No ics goes in case of broken integration email it seems // No ics goes in case of broken integration email it seems
// ics: { // ics: {
@ -274,7 +324,7 @@ export function expectBrokenIntegrationEmails({
// expect(emails).toHaveEmail( // expect(emails).toHaveEmail(
// { // {
// htmlToContain: "<title>confirmed_event_type_subject</title>", // title: "confirmed_event_type_subject",
// to: `${booker.name} <${booker.email}>`, // to: `${booker.name} <${booker.email}>`,
// }, // },
// `${booker.name} <${booker.email}>` // `${booker.name} <${booker.email}>`
@ -294,7 +344,7 @@ export function expectCalendarEventCreationFailureEmails({
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>broken_integration</title>", title: "broken_integration",
to: `${organizer.email}`, to: `${organizer.email}`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -306,7 +356,7 @@ export function expectCalendarEventCreationFailureEmails({
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>calendar_event_creation_failure_subject</title>", title: "calendar_event_creation_failure_subject",
to: `${booker.name} <${booker.email}>`, to: `${booker.name} <${booker.email}>`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -322,27 +372,30 @@ export function expectSuccessfulBookingRescheduledEmails({
organizer, organizer,
booker, booker,
iCalUID, iCalUID,
appsStatus,
}: { }: {
emails: Fixtures["emails"]; emails: Fixtures["emails"];
organizer: { email: string; name: string }; organizer: { email: string; name: string };
booker: { email: string; name: string }; booker: { email: string; name: string };
iCalUID: string; iCalUID: string;
appsStatus: AppsStatus[];
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>", title: "event_type_has_been_rescheduled_on_time_date",
to: `${organizer.email}`, to: `${organizer.email}`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
iCalUID, iCalUID,
}, },
appsStatus,
}, },
`${organizer.email}` `${organizer.email}`
); );
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>", title: "event_type_has_been_rescheduled_on_time_date",
to: `${booker.name} <${booker.email}>`, to: `${booker.name} <${booker.email}>`,
ics: { ics: {
filename: "event.ics", filename: "event.ics",
@ -362,7 +415,7 @@ export function expectAwaitingPaymentEmails({
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>awaiting_payment_subject</title>", title: "awaiting_payment_subject",
to: `${booker.name} <${booker.email}>`, to: `${booker.name} <${booker.email}>`,
noIcs: true, noIcs: true,
}, },
@ -381,7 +434,7 @@ export function expectBookingRequestedEmails({
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>event_awaiting_approval_subject</title>", title: "event_awaiting_approval_subject",
to: `${organizer.email}`, to: `${organizer.email}`,
noIcs: true, noIcs: true,
}, },
@ -390,7 +443,7 @@ export function expectBookingRequestedEmails({
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
{ {
htmlToContain: "<title>booking_submitted_subject</title>", title: "booking_submitted_subject",
to: `${booker.email}`, to: `${booker.email}`,
noIcs: true, noIcs: true,
}, },
@ -509,6 +562,7 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location, location,
subscriberUrl, subscriberUrl,
videoCallUrl, videoCallUrl,
payload,
}: { }: {
organizer: { email: string; name: string }; organizer: { email: string; name: string };
booker: { email: string; name: string }; booker: { email: string; name: string };
@ -516,10 +570,12 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location: string; location: string;
paidEvent?: boolean; paidEvent?: boolean;
videoCallUrl?: string; videoCallUrl?: string;
payload?: Record<string, unknown>;
}) { }) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, { expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED", triggerEvent: "BOOKING_RESCHEDULED",
payload: { payload: {
...payload,
metadata: { metadata: {
...(videoCallUrl ? { videoCallUrl } : null), ...(videoCallUrl ? { videoCallUrl } : null),
}, },

View File

@ -1,4 +1,4 @@
import { TFunction } from "next-i18next"; import type { TFunction } from "next-i18next";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
@ -11,7 +11,7 @@ export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) =>
<Info <Info
label={t("apps_status")} label={t("apps_status")}
description={ description={
<ul style={{ lineHeight: "24px" }}> <ul style={{ lineHeight: "24px" }} data-testid="appsStatus">
{props.calEvent.appsStatus.map((status) => ( {props.calEvent.appsStatus.map((status) => (
<li key={status.type} style={{ fontWeight: 400 }}> <li key={status.type} style={{ fontWeight: 400 }}>
{status.appName}{" "} {status.appName}{" "}

View File

@ -1431,7 +1431,7 @@ async function handler(
metadata.hangoutLink = updatedEvent.hangoutLink; metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData; metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints; metadata.entryPoints = updatedEvent.entryPoints;
handleAppsStatus(results, newBooking); evt.appsStatus = handleAppsStatus(results, newBooking);
} }
} }
} }
@ -2105,7 +2105,7 @@ async function handler(
booking: (Booking & { appsStatus?: AppsStatus[] }) | null booking: (Booking & { appsStatus?: AppsStatus[] }) | null
) { ) {
// Taking care of apps status // Taking care of apps status
const resultStatus: AppsStatus[] = results.map((app) => ({ let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName, appName: app.appName,
type: app.type, type: app.type,
success: app.success ? 1 : 0, success: app.success ? 1 : 0,
@ -2118,8 +2118,7 @@ async function handler(
if (booking !== null) { if (booking !== null) {
booking.appsStatus = resultStatus; booking.appsStatus = resultStatus;
} }
evt.appsStatus = resultStatus; return resultStatus;
return;
} }
// From down here we can assume reqAppsStatus is not undefined anymore // From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series, // Other status exist, so this is the last booking of a series,
@ -2134,7 +2133,8 @@ async function handler(
} }
return prev; return prev;
}, {} as { [key: string]: AppsStatus }); }, {} as { [key: string]: AppsStatus });
evt.appsStatus = Object.values(calcAppsStatus); resultStatus = Object.values(calcAppsStatus);
return resultStatus;
} }
let videoCallUrl; let videoCallUrl;
@ -2174,44 +2174,42 @@ async function handler(
results = updateManager.results; results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate; referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.some((res) => !res.success)) { const isThereAnIntegrationError = results && results.some((res) => !res.success);
if (isThereAnIntegrationError) {
const error = { const error = {
errorCode: "BookingReschedulingMeetingFailed", errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed", message: "Booking Rescheduling failed",
}; };
loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, safeStringify({ error, results })); loggerWithEventDetails.error(
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
safeStringify({ error, results })
);
} else { } else {
const metadata: AdditionalInformation = {};
const calendarResult = results.find((result) => result.type.includes("_calendar")); const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID ? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined; : calendarResult?.updatedEvent?.iCalUID || undefined;
}
if (results.length) { const { metadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
// TODO: Handle created event metadata more elegantly results,
const [updatedEvent] = Array.isArray(results[0].updatedEvent) });
? results[0].updatedEvent
: [results[0].updatedEvent]; videoCallUrl = _videoCallUrl;
if (updatedEvent) { evt.appsStatus = handleAppsStatus(results, booking);
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData; // If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient
metadata.entryPoints = updatedEvent.entryPoints; if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) {
handleAppsStatus(results, booking); const copyEvent = cloneDeep(evt);
videoCallUrl = metadata.hangoutLink || videoCallUrl || updatedEvent?.url; loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
} await sendRescheduledEmails({
} ...copyEvent,
if (noEmail !== true && isConfirmedByDefault) { additionalInformation: metadata,
const copyEvent = cloneDeep(evt); additionalNotes, // Resets back to the additionalNote input and not the override value
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
await sendRescheduledEmails({ });
...copyEvent,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
} }
// If it's not a reschedule, doesn't require confirmation and there's no price, // If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking // Create a booking
@ -2234,7 +2232,7 @@ async function handler(
}; };
loggerWithEventDetails.error( loggerWithEventDetails.error(
`Failure in creating events in some of the integrations ${organizerUser.username} failed`, `EventManager.create failure in some of the integrations ${organizerUser.username}`,
safeStringify({ error, results }) safeStringify({ error, results })
); );
} else { } else {
@ -2296,7 +2294,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
handleAppsStatus(results, booking); evt.appsStatus = handleAppsStatus(results, booking);
videoCallUrl = videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
} }
@ -2374,6 +2372,7 @@ async function handler(
videoCallUrl: getVideoCallUrlFromCalEvent(evt), videoCallUrl: getVideoCallUrlFromCalEvent(evt),
} }
: undefined; : undefined;
const webhookData = { const webhookData = {
...evt, ...evt,
...eventTypeInfo, ...eventTypeInfo,
@ -2558,6 +2557,31 @@ async function handler(
export default handler; export default handler;
function getVideoCallDetails({
results,
}: {
results: EventResult<AdditionalInformation & { url?: string | undefined; iCalUID?: string | undefined }>[];
}) {
const firstVideoResult = results.find((result) => result.type.includes("_video"));
const metadata: AdditionalInformation = {};
let updatedVideoEvent = null;
if (firstVideoResult && firstVideoResult.success) {
updatedVideoEvent = Array.isArray(firstVideoResult.updatedEvent)
? firstVideoResult.updatedEvent[0]
: firstVideoResult.updatedEvent;
if (updatedVideoEvent) {
metadata.hangoutLink = updatedVideoEvent.hangoutLink;
metadata.conferenceData = updatedVideoEvent.conferenceData;
metadata.entryPoints = updatedVideoEvent.entryPoints;
}
}
const videoCallUrl = metadata.hangoutLink || updatedVideoEvent?.url;
return { videoCallUrl, metadata, updatedVideoEvent };
}
function getRequiresConfirmationFlags({ function getRequiresConfirmationFlags({
eventType, eventType,
bookingStartTime, bookingStartTime,

View File

@ -206,7 +206,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -353,7 +353,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -499,7 +499,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -760,7 +760,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -1447,7 +1447,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl, subscriberUrl,
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -1883,7 +1883,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
paidEvent: true, paidEvent: true,
}); });
}, },

View File

@ -21,6 +21,8 @@ import {
BookingLocations, BookingLocations,
getMockBookingReference, getMockBookingReference,
getMockBookingAttendee, getMockBookingAttendee,
getMockFailingAppStatus,
getMockPassingAppStatus,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { import {
expectWorkflowToBeTriggered, expectWorkflowToBeTriggered,
@ -103,6 +105,9 @@ describe("handleNewBooking", () => {
status: BookingStatus.ACCEPTED, status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`, startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [ references: [
{ {
type: appStoreMetadata.dailyvideo.type, type: appStoreMetadata.dailyvideo.type,
@ -254,6 +259,10 @@ describe("handleNewBooking", () => {
organizer, organizer,
emails, emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
appsStatus: [
getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }),
getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }),
],
}); });
expectBookingRescheduledWebhookToHaveBeenFired({ expectBookingRescheduledWebhookToHaveBeenFired({
@ -261,7 +270,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -464,7 +473,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -525,6 +534,9 @@ describe("handleNewBooking", () => {
status: BookingStatus.ACCEPTED, status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`, startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [ references: [
{ {
type: appStoreMetadata.dailyvideo.type, type: appStoreMetadata.dailyvideo.type,
@ -551,6 +563,9 @@ describe("handleNewBooking", () => {
); );
const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar");
const _videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const mockBookingData = getMockRequestDataForBooking({ const mockBookingData = getMockRequestDataForBooking({
data: { data: {
@ -559,7 +574,7 @@ describe("handleNewBooking", () => {
responses: { responses: {
email: booker.email, email: booker.email,
name: booker.name, name: booker.name,
location: { optionValue: "", value: "New York" }, location: { optionValue: "", value: BookingLocations.CalVideo },
}, },
}, },
}); });
@ -577,16 +592,27 @@ describe("handleNewBooking", () => {
}, },
to: { to: {
description: "", description: "",
location: "New York", location: "integrations:daily",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!, uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId, eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED, status: BookingStatus.ACCEPTED,
metadata: {
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`,
},
responses: expect.objectContaining({ responses: expect.objectContaining({
email: booker.email, email: booker.email,
name: booker.name, name: booker.name,
}), }),
// Booking References still use the original booking's references - Not sure how intentional it is.
references: [ references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{ {
type: appStoreMetadata.googlecalendar.type, type: appStoreMetadata.googlecalendar.type,
// A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this.
@ -607,8 +633,18 @@ describe("handleNewBooking", () => {
expectBookingRescheduledWebhookToHaveBeenFired({ expectBookingRescheduledWebhookToHaveBeenFired({
booker, booker,
organizer, organizer,
location: "New York", location: "integrations:daily",
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
payload: {
uid: createdBooking.uid,
appsStatus: [
expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })),
expect.objectContaining(
getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug })
),
],
},
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`,
}); });
}, },
timeout timeout
@ -1048,7 +1084,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -1497,12 +1533,13 @@ describe("handleNewBooking", () => {
emails, emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
}); });
expectBookingRescheduledWebhookToHaveBeenFired({ expectBookingRescheduledWebhookToHaveBeenFired({
booker, booker,
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout

View File

@ -225,7 +225,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -537,7 +537,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout
@ -854,7 +854,7 @@ describe("handleNewBooking", () => {
organizer, organizer,
location: BookingLocations.CalVideo, location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com", subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
}); });
}, },
timeout timeout

View File

@ -4576,6 +4576,7 @@ __metadata:
next-i18next: ^13.2.2 next-i18next: ^13.2.2
next-seo: ^6.0.0 next-seo: ^6.0.0
next-themes: ^0.2.0 next-themes: ^0.2.0
node-html-parser: ^6.1.10
nodemailer: ^6.7.8 nodemailer: ^6.7.8
otplib: ^12.0.1 otplib: ^12.0.1
postcss: ^8.4.18 postcss: ^8.4.18
@ -18514,6 +18515,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"css-select@npm:^5.1.0":
version: 5.1.0
resolution: "css-select@npm:5.1.0"
dependencies:
boolbase: ^1.0.0
css-what: ^6.1.0
domhandler: ^5.0.2
domutils: ^3.0.1
nth-check: ^2.0.1
checksum: 2772c049b188d3b8a8159907192e926e11824aea525b8282981f72ba3f349cf9ecd523fdf7734875ee2cb772246c22117fc062da105b6d59afe8dcd5c99c9bda
languageName: node
linkType: hard
"css-to-react-native@npm:^3.0.0": "css-to-react-native@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "css-to-react-native@npm:3.0.0" resolution: "css-to-react-native@npm:3.0.0"
@ -18532,7 +18546,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"css-what@npm:^6.0.1": "css-what@npm:^6.0.1, css-what@npm:^6.1.0":
version: 6.1.0 version: 6.1.0
resolution: "css-what@npm:6.1.0" resolution: "css-what@npm:6.1.0"
checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe
@ -29541,6 +29555,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-html-parser@npm:^6.1.10":
version: 6.1.10
resolution: "node-html-parser@npm:6.1.10"
dependencies:
css-select: ^5.1.0
he: 1.2.0
checksum: 927f6a38b3b1cbc042bce609e24fb594d3b1e0f1067ffb416a925fa5a699e907be31980f349e094d55bab706dc16a71958b08f8dcdab62faf7b12013f29442bc
languageName: node
linkType: hard
"node-ical@npm:^0.16.1": "node-ical@npm:^0.16.1":
version: 0.16.1 version: 0.16.1
resolution: "node-ical@npm:0.16.1" resolution: "node-ical@npm:0.16.1"