fix: Duplicate Calendar Invites on rescheduling an accepted booking that requires confirmation (#11827)

pull/11844/head^2
Hariom Balhara 2023-10-12 17:59:29 +05:30 committed by GitHub
parent e3a9e61046
commit db059d84c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1003 additions and 51 deletions

View File

@ -2,6 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { BookingReference, Attendee } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
@ -111,17 +112,10 @@ type InputBooking = {
title?: string;
status: BookingStatus;
attendees?: { email: string }[];
references?: {
type: string;
uid: string;
meetingId?: string;
meetingPassword?: string;
meetingUrl?: string;
bookingId?: number;
externalCalendarId?: string;
deleted?: boolean;
credentialId?: number;
}[];
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
credentialId?: number | null;
})[];
};
export const Timezones = {
@ -267,15 +261,17 @@ async function addBookingsToDb(
references: any[];
})[]
) {
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
await prismock.booking.createMany({
data: bookings,
});
log.silly(
"TestData: Booking as in DB",
"TestData: Bookings as in DB",
JSON.stringify({
bookings: await prismock.booking.findMany({
include: {
references: true,
attendees: true,
},
}),
})
@ -318,6 +314,15 @@ async function addBookings(bookings: InputBooking[]) {
},
};
}
if (booking.attendees) {
bookingCreate.attendees = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
createMany: {
data: booking.attendees,
},
};
}
return bookingCreate;
})
);
@ -839,6 +844,8 @@ export function mockCalendar(
const createEventCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateEventCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteEventCalls: any[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: {
@ -888,6 +895,11 @@ export function mockCalendar(
url: "https://UNUSED_URL",
});
},
deleteEvent: async (...rest: any[]) => {
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
// eslint-disable-next-line prefer-rest-params
deleteEventCalls.push(rest);
},
getAvailability: async (): Promise<EventBusyDate[]> => {
if (calendarData?.getAvailabilityCrash) {
throw new Error("MockCalendarService.getAvailability fake error");
@ -902,6 +914,7 @@ export function mockCalendar(
});
return {
createEventCalls,
deleteEventCalls,
updateEventCalls,
};
}
@ -952,11 +965,13 @@ export function mockVideoApp({
password: "MOCK_PASS",
url: `http://mock-${metadataLookupKey}.example.com`,
};
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
log.silly("mockVideoApp", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
@ -998,15 +1013,19 @@ export function mockVideoApp({
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
log.silly(
"mockSuccessfulVideoMeetingCreation.updateMeeting",
JSON.stringify({ bookingRef, calEvent })
);
log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent }));
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
deleteMeeting: async (...rest: any[]) => {
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
deleteMeetingCalls.push({
credential,
args: rest,
});
},
};
},
},
@ -1016,6 +1035,7 @@ export function mockVideoApp({
return {
createMeetingCalls,
updateMeetingCalls,
deleteMeetingCalls,
};
}
@ -1154,6 +1174,31 @@ export function getExpectedCalEventForBookingRequest({
};
}
export function getMockBookingReference(
bookingReference: Partial<BookingReference> & Pick<BookingReference, "type" | "uid" | "credentialId">
) {
let credentialId = bookingReference.credentialId;
if (bookingReference.type === appStoreMetadata.dailyvideo.type) {
// Right now we seems to be storing credentialId for `dailyvideo` in BookingReference as null. Another possible value is 0 in there.
credentialId = null;
log.debug("Ensuring null credentialId for dailyvideo");
}
return {
...bookingReference,
credentialId,
};
}
export function getMockBookingAttendee(attendee: Omit<Attendee, "bookingId">) {
return {
id: attendee.id,
timeZone: attendee.timeZone,
name: attendee.name,
email: attendee.email,
locale: attendee.locale,
};
}
export const enum BookingLocations {
CalVideo = "integrations:daily",
ZoomVideo = "integrations:zoom",

View File

@ -624,6 +624,31 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulCalendarEventDeletionInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteEventCalls: any[];
},
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
}
) {
expect(calendarMock.deleteEventCalls.length).toBe(1);
const call = calendarMock.deleteEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingCreation(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -669,6 +694,26 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
}
export function expectSuccessfulVideoMeetingDeletionInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteMeetingCalls: any[];
},
expected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingRef: any;
}
) {
expect(videoMock.deleteMeetingCalls.length).toBe(1);
const call = videoMock.deleteMeetingCalls[0];
const bookingRefUid = call.args[0];
expect(bookingRefUid).toEqual(expected.bookingRef.uid);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
// Expect previous booking to be cancelled
@ -678,10 +723,9 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro
status: BookingStatus.CANCELLED,
});
// Expect new booking to be created
// Expect new booking to be created but status would depend on whether the new booking requires confirmation or not.
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...to,
status: BookingStatus.ACCEPTED,
});
}

View File

@ -7,7 +7,7 @@ import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type {
@ -366,14 +366,34 @@ export const updateEvent = async (
};
};
export const deleteEvent = async (
credential: CredentialPayload,
uid: string,
event: CalendarEvent
): Promise<unknown> => {
export const deleteEvent = async ({
credential,
bookingRefUid,
event,
externalCalendarId,
}: {
credential: CredentialPayload;
bookingRefUid: string;
event: CalendarEvent;
externalCalendarId?: string | null;
}): Promise<unknown> => {
const calendar = await getCalendar(credential);
log.debug(
"Deleting calendar event",
safeStringify({
bookingRefUid,
event: getPiiFreeCalendarEvent(event),
})
);
if (calendar) {
return calendar.deleteEvent(uid, event);
return calendar.deleteEvent(bookingRefUid, event, externalCalendarId);
} else {
log.warn(
"Could not do deleteEvent - No calendar adapter found",
safeStringify({
credential: getPiiFreeCredential(credential),
})
);
}
return Promise.resolve({});

View File

@ -10,7 +10,12 @@ import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvide
import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import logger from "@calcom/lib/logger";
import { getPiiFreeDestinationCalendar, getPiiFreeUser, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import {
getPiiFreeDestinationCalendar,
getPiiFreeUser,
getPiiFreeCredential,
getPiiFreeCalendarEvent,
} from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@ -25,8 +30,8 @@ import type {
PartialReference,
} from "@calcom/types/EventManager";
import { createEvent, updateEvent } from "./CalendarManager";
import { createMeeting, updateMeeting } from "./videoClient";
import { createEvent, updateEvent, deleteEvent } from "./CalendarManager";
import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient";
const log = logger.getChildLogger({ prefix: ["EventManager"] });
export const isDedicatedIntegration = (location: string): boolean => {
@ -91,7 +96,14 @@ export default class EventManager {
// (type google_calendar) and non-traditional calendars such as CRMs like Close.com
// (type closecom_other_calendar)
this.calendarCredentials = appCredentials.filter((cred) => cred.type.endsWith("_calendar"));
this.videoCredentials = appCredentials.filter((cred) => cred.type.endsWith("_video"));
this.videoCredentials = appCredentials
.filter((cred) => cred.type.endsWith("_video"))
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
// We also don't have updatedAt or createdAt dates on credentials so this is the best we can do
.sort((a, b) => {
return b.id - a.id;
});
}
/**
@ -226,6 +238,82 @@ export default class EventManager {
};
}
private async deleteCalendarEventForBookingReference({
bookingCalendarReference,
event,
}: {
bookingCalendarReference: PartialReference;
event: CalendarEvent;
}) {
log.debug(
"deleteCalendarEventForBookingReference",
safeStringify({ bookingCalendarReference, event: getPiiFreeCalendarEvent(event) })
);
const {
uid: bookingRefUid,
externalCalendarId: bookingExternalCalendarId,
credentialId,
type: credentialType,
} = bookingCalendarReference;
const calendarCredential = await this.getCredentialAndWarnIfNotFound(credentialId, credentialType);
if (calendarCredential) {
await deleteEvent({
credential: calendarCredential,
bookingRefUid,
event,
externalCalendarId: bookingExternalCalendarId,
});
}
}
private async deleteVideoEventForBookingReference({
bookingVideoReference,
}: {
bookingVideoReference: PartialReference;
}) {
log.debug("deleteVideoEventForBookingReference", safeStringify({ bookingVideoReference }));
const { uid: bookingRefUid, credentialId } = bookingVideoReference;
const videoCredential = await this.getCredentialAndWarnIfNotFound(
credentialId,
bookingVideoReference.type
);
if (videoCredential) {
await deleteMeeting(videoCredential, bookingRefUid);
}
}
private async getCredentialAndWarnIfNotFound(credentialId: number | null | undefined, type: string) {
const credential =
typeof credentialId === "number" && credentialId > 0
? await prisma.credential.findUnique({
where: {
id: credentialId,
},
select: credentialForCalendarServiceSelect,
})
: // Fallback for zero or nullish credentialId which could be the case of Global App e.g. dailyVideo
this.videoCredentials.find((cred) => cred.type === type) ||
this.calendarCredentials.find((cred) => cred.type === type) ||
null;
if (!credential) {
log.error(
"getCredentialAndWarnIfNotFound: Could not find credential",
safeStringify({
credentialId,
type,
videoCredentials: this.videoCredentials,
})
);
}
return credential;
}
/**
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
* given uid using the data delivered in the given CalendarEvent.
@ -281,24 +369,37 @@ export default class EventManager {
throw new Error("booking not found");
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult<Event>> = [];
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent];
if (updatedEvent) {
evt.videoCallData = updatedEvent;
evt.location = updatedEvent.url;
if (evt.requiresConfirmation) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({ booking, event });
} else {
// If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time.
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
const [updatedEvent] = Array.isArray(result.updatedEvent)
? result.updatedEvent
: [result.updatedEvent];
if (updatedEvent) {
evt.videoCallData = updatedEvent;
evt.location = updatedEvent.url;
}
results.push(result);
}
results.push(result);
}
// There was a case that booking didn't had any reference and we don't want to throw error on function
if (booking.references.find((reference) => reference.type.includes("_calendar"))) {
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId)));
const bookingCalendarReference = booking.references.find((reference) =>
reference.type.includes("_calendar")
);
// There was a case that booking didn't had any reference and we don't want to throw error on function
if (bookingCalendarReference) {
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId)));
}
}
const bookingPayment = booking?.payment;
@ -317,12 +418,54 @@ export default class EventManager {
},
});
}
return {
results,
referencesToCreate: [...booking.references],
};
}
private async deleteEventsAndMeetings({
event,
booking,
}: {
event: CalendarEvent;
booking: PartialBooking;
}) {
const calendarReferences = booking.references.filter((reference) => reference.type.includes("_calendar"));
const videoReferences = booking.references.filter((reference) => reference.type.includes("_video"));
log.debug("deleteEventsAndMeetings", safeStringify({ calendarReferences, videoReferences }));
const calendarPromises = calendarReferences.map(async (bookingCalendarReference) => {
return await this.deleteCalendarEventForBookingReference({
bookingCalendarReference,
event,
});
});
const videoPromises = videoReferences.map(async (bookingVideoReference) => {
return await this.deleteVideoEventForBookingReference({
bookingVideoReference,
});
});
const allPromises = [...calendarPromises, ...videoPromises];
// Using allSettled to ensure that if one of the promises rejects, the others will still be executed.
// Because we are just cleaning up the events and meetings, we don't want to throw an error if one of them fails.
(await Promise.allSettled(allPromises)).some((result) => {
if (result.status === "rejected") {
log.error(
"Error deleting calendar event or video meeting for booking",
safeStringify({ error: result.reason })
);
}
});
if (!allPromises.length) {
log.warn("No calendar or video references found for booking - Couldn't delete events or meetings");
}
}
public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) {
if (booking.references.length === 0) {
console.error("Tried to update references but there wasn't any.");
@ -466,13 +609,9 @@ export default class EventManager {
(credential) => credential.id === event.conferenceCredentialId
);
} else {
videoCredential = this.videoCredentials
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
.sort((a, b) => {
return b.id - a.id;
})
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
videoCredential = this.videoCredentials.find((credential: CredentialPayload) =>
credential.type.includes(integrationName)
);
log.warn(
`Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential`
);

View File

@ -19,6 +19,8 @@ import {
mockCalendarToHaveNoBusySlots,
mockCalendarToCrashOnUpdateEvent,
BookingLocations,
getMockBookingReference,
getMockBookingAttendee,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectWorkflowToBeTriggered,
@ -28,6 +30,10 @@ import {
expectSuccessfulCalendarEventUpdationInCalendar,
expectSuccessfulVideoMeetingUpdationInCalendar,
expectBookingInDBToBeRescheduledFromTo,
expectBookingRequestedEmails,
expectBookingRequestedWebhookToHaveBeenFired,
expectSuccessfulCalendarEventDeletionInCalendar,
expectSuccessfulVideoMeetingDeletionInCalendar,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { createMockNextJsRequest } from "./lib/createMockNextJsRequest";
@ -104,6 +110,7 @@ describe("handleNewBooking", () => {
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
{
type: appStoreMetadata.googlecalendar.type,
@ -248,6 +255,7 @@ describe("handleNewBooking", () => {
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
@ -258,6 +266,7 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier.
1. Should cancel the existing booking
@ -604,5 +613,700 @@ describe("handleNewBooking", () => {
},
timeout
);
describe("Event Type that requires confirmation", () => {
test(
`should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the schedule
1. Should cancel the existing booking
2. Should delete existing calendar invite and Video meeting
2. Should create a new booking in the database in PENDING state
3. Should send BOOKING Requested scenario emails to the booker as well as organizer
4. Should trigger BOOKING_REQUESTED webhook instead of BOOKING_RESCHEDULED
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const subscriberUrl = "http://my-webhook.example.com";
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const scenarioData = getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl,
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 45,
requiresConfirmation: true,
length: 45,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
getMockBookingReference({
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: 0,
}),
getMockBookingReference({
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: 1,
}),
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
});
await createBookingScenario(scenarioData);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
uid: "UPDATED_MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
expect(createdBooking.responses).toContain({
email: booker.email,
name: booker.name,
});
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
// Rescheduled booking sill stays in pending state
status: BookingStatus.PENDING,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
},
],
},
});
expectWorkflowToBeTriggered();
expectBookingRequestedEmails({
booker,
organizer,
emails,
});
expectBookingRequestedWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl,
eventType: scenarioData.eventTypes[0],
});
expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, {
bookingRef: {
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
});
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
calEvent: {
videoCallData: expect.objectContaining({
url: "http://mock-dailyvideo.example.com",
}),
},
uid: "MOCK_ID",
});
},
timeout
);
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
`should rechedule a booking, that requires confirmation, without confirmation - When Organizer is doing the reschedule
1. Should cancel the existing booking
2. Should delete existing calendar invite and Video meeting
2. Should create a new booking in the database in ACCEPTED state
3. Should send rescheduled emails to the booker as well as organizer
4. Should trigger BOOKING_RESCHEDULED webhook
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
requiresConfirmation: true,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@example.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
credentialId: undefined,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: organizer.name,
email: organizer.email,
locale: "en",
timeZone: "Europe/London",
}),
getMockBookingAttendee({
id: 2,
name: booker.name,
email: booker.email,
// Booker's locale when the fresh booking happened earlier
locale: "hi",
// Booker's timezone when the fresh booking happened earlier
timeZone: "Asia/Kolkata",
}),
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
uid: "UPDATED_MOCK_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
// Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled
timeZone: "Europe/London",
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
// Fake the request to be from organizer
req.userId = organizer.id;
const createdBooking = await handleNewBooking(req);
/**
* Booking Time should be new time
*/
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
},
],
},
});
expectWorkflowToBeTriggered();
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
calEvent: {
location: "http://mock-dailyvideo.example.com",
},
bookingRef: {
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
});
// updateEvent uses existing booking's externalCalendarId to update the event in calendar.
// and not the event-type's organizer's which is event-type-1@example.com
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
externalCalendarId: "existing-event-type@example.com",
calEvent: {
location: "http://mock-dailyvideo.example.com",
attendees: expect.arrayContaining([
expect.objectContaining({
email: booker.email,
name: booker.name,
// Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone
timeZone: "Asia/Kolkata",
language: expect.objectContaining({
// Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale
locale: "hi",
}),
}),
]),
},
uid: "MOCK_ID",
});
expectSuccessfulBookingRescheduledEmails({
booker,
organizer,
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
});
},
timeout
);
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
`should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule
1. Should cancel the existing booking
2. Should delete existing calendar invite and Video meeting
2. Should create a new booking in the database in ACCEPTED state
3. Should send rescheduled emails to the booker as well as organizer
4. Should trigger BOOKING_RESCHEDULED webhook
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const previousOrganizerIdForTheBooking = 1001;
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
requiresConfirmation: true,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@example.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
// Make sure that the earlier booking owner is some user with ID 10001
userId: previousOrganizerIdForTheBooking,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
credentialId: undefined,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: organizer.name,
email: organizer.email,
locale: "en",
timeZone: "Europe/London",
}),
getMockBookingAttendee({
id: 2,
name: booker.name,
email: booker.email,
// Booker's locale when the fresh booking happened earlier
locale: "hi",
// Booker's timezone when the fresh booking happened earlier
timeZone: "Asia/Kolkata",
}),
],
},
],
organizer,
usersApartFromOrganizer: [
{
id: previousOrganizerIdForTheBooking,
name: "Previous Organizer",
username: "prev-organizer",
email: "",
schedules: [TestData.schedules.IstWorkHours],
timeZone: "Europe/London",
},
],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
uid: "UPDATED_MOCK_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
// Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled
timeZone: "Europe/London",
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
// Fake the request to be from organizer
req.userId = previousOrganizerIdForTheBooking;
const createdBooking = await handleNewBooking(req);
/**
* Booking Time should be new time
*/
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
},
],
},
});
expectWorkflowToBeTriggered();
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
calEvent: {
location: "http://mock-dailyvideo.example.com",
},
bookingRef: {
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
});
// updateEvent uses existing booking's externalCalendarId to update the event in calendar.
// and not the event-type's organizer's which is event-type-1@example.com
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
externalCalendarId: "existing-event-type@example.com",
calEvent: {
location: "http://mock-dailyvideo.example.com",
attendees: expect.arrayContaining([
expect.objectContaining({
email: booker.email,
name: booker.name,
// Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone
timeZone: "Asia/Kolkata",
language: expect.objectContaining({
// Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale
locale: "hi",
}),
}),
]),
},
uid: "MOCK_ID",
});
expectSuccessfulBookingRescheduledEmails({
booker,
organizer,
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
});
},
timeout
);
});
});
});