From 271f31730da301d0a7662f3600bc9de7ecb96935 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Tue, 6 Feb 2024 13:18:42 -0500 Subject: [PATCH] fix: recurring round robin events (#13471) * use same lucky user + check availability * add tests for recurring round robin events * only use luckyUsers for recurring bookings * don't check availability for all recurring dates * code clean up * pass schedulingType * fix type error * add availability check for fixed hosts + test * fix type error * implement feedback --------- Co-authored-by: CarinaWolli Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/web/pages/api/book/recurring-event.ts | 31 +- apps/web/test/lib/getSchedule.test.ts | 4 +- .../utils/bookingScenario/bookingScenario.ts | 29 +- .../getMockRequestDataForBooking.ts | 2 + .../booking-to-mutation-input-mapper.tsx | 1 + .../features/bookings/lib/handleNewBooking.ts | 106 +- .../test/recurring-event.test.ts | 1641 +++++++++++------ packages/features/bookings/types.ts | 3 + packages/prisma/zod-utils.ts | 1 + 9 files changed, 1271 insertions(+), 547 deletions(-) diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 6462a9c47d..4fde0af2a8 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -6,6 +6,7 @@ import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/featur import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import getIP from "@calcom/lib/getIP"; import { defaultResponder } from "@calcom/lib/server"; +import { SchedulingType } from "@calcom/prisma/client"; import type { AppsStatus } from "@calcom/types/Calendar"; // @TODO: Didn't look at the contents of this function in order to not break old booking page. @@ -30,7 +31,34 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe const numSlotsToCheckForAvailability = 2; let thirdPartyRecurringEventId = null; - for (let key = 0; key < data.length; key++) { + + // for round robin, the first slot needs to be handled first to define the lucky user + const firstBooking = data[0]; + const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN; + + let luckyUsers = undefined; + + if (isRoundRobin) { + const recurringEventReq: NextApiRequest & { userId?: number } = req; + + recurringEventReq.body = { + ...firstBooking, + appsStatus, + allRecurringDates, + isFirstRecurringSlot: true, + thirdPartyRecurringEventId, + numSlotsToCheckForAvailability, + currentRecurringIndex: 0, + noEmail: false, + }; + + const firstBookingResult = await handleNewBooking(recurringEventReq, { + isNotAnApiCall: true, + }); + luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id); + } + + for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) { const booking = data[key]; // Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses // It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email. @@ -61,6 +89,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe numSlotsToCheckForAvailability, currentRecurringIndex: key, noEmail: key !== 0, + luckyUsers, }; const promiseEachRecurringBooking: ReturnType = handleNewBooking( diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 4947d47ff6..1bab2e6438 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -798,10 +798,10 @@ describe("getSchedule", () => { schedulingType: "COLLECTIVE", hosts: [ { - id: 101, + userId: 101, }, { - id: 102, + userId: 102, }, ], }, diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 101091b72e..5b6a59db07 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -49,11 +49,15 @@ type InputWorkflow = { action: WorkflowActions; template: WorkflowTemplates; }; + +type InputHost = { + userId: number; + isFixed?: boolean; +}; /** * Data to be mocked */ export type ScenarioData = { - // hosts: { id: number; eventTypeId?: number; userId?: number; isFixed?: boolean }[]; /** * Prisma would return these eventTypes */ @@ -119,7 +123,7 @@ export type InputEventType = { * These user ids are `ScenarioData["users"]["id"]` */ users?: { id: number }[]; - hosts?: { id: number }[]; + hosts?: InputHost[]; schedulingType?: SchedulingType; beforeEventBuffer?: number; afterEventBuffer?: number; @@ -161,6 +165,20 @@ export const Timezones = { "+6:00": "Asia/Dhaka", }; +async function addHostsToDb(eventTypes: InputEventType[]) { + for (const eventType of eventTypes) { + if (eventType.hosts && eventType.hosts.length > 0) { + await prismock.host.createMany({ + data: eventType.hosts.map((host) => ({ + userId: host.userId, + eventTypeId: eventType.id, + isFixed: host.isFixed ?? false, + })), + }); + } + } +} + async function addEventTypesToDb( eventTypes: (Omit< Prisma.EventTypeCreateInput, @@ -283,6 +301,7 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser } : eventType.schedule, owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined, + schedulingType: eventType.schedulingType, }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); @@ -586,6 +605,7 @@ export async function createBookingScenario(data: ScenarioData) { ); } const eventTypes = await addEventTypes(data.eventTypes, data.users); + await addHostsToDb(data.eventTypes); data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); @@ -963,8 +983,7 @@ export function getScenarioData( webhooks, workflows, bookings, - }: // hosts = [], - { + }: { organizer: ReturnType; eventTypes: ScenarioData["eventTypes"]; apps?: ScenarioData["apps"]; @@ -972,7 +991,6 @@ export function getScenarioData( webhooks?: ScenarioData["webhooks"]; workflows?: ScenarioData["workflows"]; bookings?: ScenarioData["bookings"]; - // hosts?: ScenarioData["hosts"]; }, org?: { id: number | null } | undefined | null ) { @@ -1003,7 +1021,6 @@ export function getScenarioData( } }); return { - // hosts: [...hosts], eventTypes: eventTypes.map((eventType, index) => { return { ...eventType, diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index e6a5b31774..00bddf9fef 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -1,3 +1,4 @@ +import type { SchedulingType } from "@calcom/prisma/client"; import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; export const DEFAULT_TIMEZONE_BOOKER = "Asia/Kolkata"; @@ -24,6 +25,7 @@ export function getMockRequestDataForBooking({ bookingUid?: string; recurringEventId?: string; recurringCount?: number; + schedulingType?: SchedulingType; responses: { email: string; name: string; diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index cf400c54b0..615bec3d95 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -85,6 +85,7 @@ export const mapRecurringBookingToMutationInput = ( .add(booking.duration || booking.event.length, "minute") .format(), recurringEventId, + schedulingType: booking.event.schedulingType || undefined, recurringCount: recurringDates.length, })); }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5fe7665d0f..a8cf3082b6 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1011,6 +1011,7 @@ async function handler( notes: additionalNotes, smsReminderNumber, rescheduleReason, + luckyUsers, ...reqBody } = await getBookingData({ req, @@ -1227,6 +1228,8 @@ async function handler( } } + let luckyUserResponse; + //checks what users are available if (!eventType.seatsPerTimeSlot) { const eventTypeWithUsers: Awaited> & { @@ -1241,15 +1244,37 @@ async function handler( }, }), }; - if (req.body.allRecurringDates) { - if (req.body.isFirstRecurringSlot) { - for ( - let i = 0; - i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; - i++ - ) { - const start = req.body.allRecurringDates[i].start; - const end = req.body.allRecurringDates[i].end; + if (req.body.allRecurringDates && req.body.isFirstRecurringSlot) { + const isTeamEvent = + eventType.schedulingType === SchedulingType.COLLECTIVE || + eventType.schedulingType === SchedulingType.ROUND_ROBIN; + + const fixedUsers = isTeamEvent + ? eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed) + : []; + + for ( + let i = 0; + i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; + i++ + ) { + const start = req.body.allRecurringDates[i].start; + const end = req.body.allRecurringDates[i].end; + if (isTeamEvent) { + // each fixed user must be available + for (const key in fixedUsers) { + await ensureAvailableUsers( + { ...eventTypeWithUsers, users: [fixedUsers[key]] }, + { + dateFrom: dayjs(start).tz(reqBody.timeZone).format(), + dateTo: dayjs(end).tz(reqBody.timeZone).format(), + timeZone: reqBody.timeZone, + originalRescheduledBooking, + }, + loggerWithEventDetails + ); + } + } else { await ensureAvailableUsers( eventTypeWithUsers, { @@ -1263,6 +1288,7 @@ async function handler( } } } + if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) { const availableUsers = await ensureAvailableUsers( eventTypeWithUsers, @@ -1277,6 +1303,8 @@ async function handler( const luckyUsers: typeof users = []; const luckyUserPool = availableUsers.filter((user) => !user.isFixed); + const notAvailableLuckyUsers: typeof users = []; + loggerWithEventDetails.debug( "Computed available users", safeStringify({ @@ -1289,14 +1317,46 @@ async function handler( const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { // find a lucky user that is not already in the luckyUsers array availableUsers: luckyUserPool.filter( - (user) => !luckyUsers.find((existing) => existing.id === user.id) + (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) ), eventTypeId: eventType.id, }); if (!newLuckyUser) { break; // prevent infinite loop } - luckyUsers.push(newLuckyUser); + if (req.body.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + // for recurring round robin events check if lucky user is available for next slots + try { + for ( + let i = 0; + i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; + i++ + ) { + const start = req.body.allRecurringDates[i].start; + const end = req.body.allRecurringDates[i].end; + + await ensureAvailableUsers( + { ...eventTypeWithUsers, users: [newLuckyUser] }, + { + dateFrom: dayjs(start).tz(reqBody.timeZone).format(), + dateTo: dayjs(end).tz(reqBody.timeZone).format(), + timeZone: reqBody.timeZone, + originalRescheduledBooking, + }, + loggerWithEventDetails + ); + } + // if no error, then lucky user is available for the next slots + luckyUsers.push(newLuckyUser); + } catch { + notAvailableLuckyUsers.push(newLuckyUser); + loggerWithEventDetails.info( + `Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.` + ); + } + } else { + luckyUsers.push(newLuckyUser); + } } // ALL fixed users must be available if ( @@ -1306,9 +1366,22 @@ async function handler( } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; + luckyUserResponse = { luckyUsers }; + } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + // all recurring slots except the first one + const luckyUsersFromFirstBooking = luckyUsers + ? eventTypeWithUsers.users.filter((user) => luckyUsers.find((luckyUserId) => luckyUserId === user.id)) + : []; + const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed); + users = [...fixedHosts, ...luckyUsersFromFirstBooking]; } } + if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + loggerWithEventDetails.error(`No available users found for round robin event.`); + throw new Error(ErrorCode.NoAvailableUsersFound); + } + const [organizerUser] = users; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); @@ -1577,7 +1650,7 @@ async function handler( }); if (newBooking) { req.statusCode = 201; - return newBooking; + return { ...newBooking, ...luckyUserResponse }; } } if (isTeamEventType) { @@ -2171,7 +2244,13 @@ async function handler( }); req.statusCode = 201; - return { ...booking, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id }; + return { + ...booking, + ...luckyUserResponse, + message: "Payment required", + paymentUid: payment?.uid, + paymentId: payment?.id, + }; } loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`); @@ -2296,6 +2375,7 @@ async function handler( req.statusCode = 201; return { ...booking, + ...luckyUserResponse, references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, }; diff --git a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts index 18ea64fa58..8d19959531 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "vitest"; import { WEBAPP_URL, CAL_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import logger from "@calcom/lib/logger"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { createBookingScenario, @@ -16,6 +16,7 @@ import { mockSuccessfulVideoMeetingCreation, mockCalendarToHaveNoBusySlots, getDate, + Timezones, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { @@ -40,219 +41,776 @@ describe("handleNewBooking", () => { setupAndTeardown(); describe("Recurring EventType:", () => { - test( - `should create successful bookings for the number of slots requested - 1. Should create the same number of bookings as requested slots in the database - 2. Should send emails for the first booking only to the booker as well as organizer - 3. Should create a calendar event for every booking in the destination calendar - 3. Should trigger BOOKING_CREATED webhook for every booking - `, - async ({ emails }) => { - const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) - .handleRecurringEventBooking; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); + describe("User event type:", () => { + test( + `should create successful bookings for the number of slots requested + 1. Should create the same number of bookings as requested slots in the database + 2. Should send emails for the first booking only to the booker as well as organizer + 3. Should create a calendar event for every booking in the destination calendar + 3. Should trigger BOOKING_CREATED webhook for every booking + `, + async ({ emails }) => { + const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) + .handleRecurringEventBooking; + 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], - destinationCalendar: { - integration: "google_calendar", - externalId: "organizer@google-calendar.com", - }, - }); + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); - const recurrence = getRecurrence({ - type: "weekly", - numberOfOccurrences: 3, - }); - const plus1DateString = getDate({ dateIncrement: 1 }).dateString; - 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, - slotInterval: 30, - length: 30, - recurringEvent: recurrence, - users: [ - { - id: 101, - }, - ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@google-calendar.com", + const recurrence = getRecurrence({ + type: "weekly", + numberOfOccurrences: 3, + }); + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + 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, + slotInterval: 30, + length: 30, + recurringEvent: recurrence, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const recurringCountInRequest = 2; + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, }, - ], + }, + }); - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); + const numOfSlotsToBeBooked = 4; + const { req, res } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); - mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - videoMeetingData: { - id: "MOCK_ID", - password: "MOCK_PASS", - url: `http://mock-dailyvideo.example.com/meeting-1`, - }, - }); - - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); - - const recurringCountInRequest = 2; - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - start: `${plus1DateString}T04:00:00.000Z`, - end: `${plus1DateString}T04:30:00.000Z`, - recurringEventId: uuidv4(), - recurringCount: recurringCountInRequest, - responses: { + const createdBookings = await handleRecurringEventBooking(req, res); + expect(createdBookings.length).toBe(numOfSlotsToBeBooked); + for (const [index, createdBooking] of Object.entries(createdBookings)) { + logger.debug("Assertion for Booking with index:", index, { createdBooking }); + expect(createdBooking.responses).toContain({ email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + recurringEventId: mockBookingData.recurringEventId, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: "google_calendar", + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + //FIXME: All recurring bookings seem to have the same URL. https://github.com/calcom/cal.com/issues/11955 + videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, + }); + } + + // expectWorkflowToBeTriggered(); + + expectSuccessfulBookingCreationEmails({ + booker, + booking: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBookings[0].uid!, + urlOrigin: CAL_URL, + }, + organizer, + emails, + bookingTimeRange: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + start: createdBookings[0].startTime!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + end: createdBookings[0].endTime!, + }, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + recurrence: { + ...recurrence, + count: recurringCountInRequest, }, - }, - }); - - const numOfSlotsToBeBooked = 4; - const { req, res } = createMockNextJsRequest({ - method: "POST", - body: Array(numOfSlotsToBeBooked) - .fill(mockBookingData) - .map((mockBookingData, index) => { - return { - ...mockBookingData, - start: getPlusDayDate(mockBookingData.start, index).toISOString(), - end: getPlusDayDate(mockBookingData.end, index).toISOString(), - }; - }), - }); - - const createdBookings = await handleRecurringEventBooking(req, res); - expect(createdBookings.length).toBe(numOfSlotsToBeBooked); - for (const [index, createdBooking] of Object.entries(createdBookings)) { - logger.debug("Assertion for Booking with index:", index, { createdBooking }); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, }); - expect(createdBooking).toContain({ - location: "integrations:daily", + expect(emails.get().length).toBe(2); + + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + ]); + }, + timeout + ); + + test( + `should fail recurring booking if second slot is already booked`, + async ({}) => { + const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) + .handleRecurringEventBooking; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", }); - await expectBookingToBeInDatabase({ - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - recurringEventId: mockBookingData.recurringEventId, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const recurrence = getRecurrence({ + type: "weekly", + numberOfOccurrences: 3, + }); + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + const plus2DateString = getDate({ dateIncrement: 2 }).dateString; + 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, + slotInterval: 30, + length: 30, + recurringEvent: recurrence, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + bookings: [ + { + uid: "booking-1-uid", + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: `${plus2DateString}T04:00:00.000Z`, + endTime: `${plus2DateString}T04:30:00.000Z`, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const recurringCountInRequest = 2; + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, }, - { - type: "google_calendar", - uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - }, - ], + }, }); - expectBookingCreatedWebhookToHaveBeenFired({ + const numOfSlotsToBeBooked = 4; + const { req, res } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); + + expect(() => handleRecurringEventBooking(req, res)).rejects.toThrow( + ErrorCode.NoAvailableUsersFound + ); + // Actually the first booking goes through in this case but the status is still a failure. We should do a dry run to check if booking is possible for the 2 slots and if yes, then only go for the actual booking otherwise fail the recurring bookign + }, + timeout + ); + + test( + `should create successful bookings for the number of slots requested even if the third slot is already booked as long as first two slots are free + 1. Should create the same number of bookings as requested slots in the database + 2. Should send emails for the first booking only to the booker as well as organizer + 3. Should create a calendar event for every booking in the destination calendar + 3. Should trigger BOOKING_CREATED webhook for every booking + `, + async ({ emails }) => { + const recurringCountInRequest = 4; + + const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) + .handleRecurringEventBooking; + 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], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const recurrence = getRecurrence({ + type: "weekly", + numberOfOccurrences: 3, + }); + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + const plus3DateString = getDate({ dateIncrement: 3 }).dateString; + 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, + slotInterval: 30, + length: 30, + recurringEvent: recurrence, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + bookings: [ + { + uid: "booking-1-uid", + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: `${plus3DateString}T04:00:00.000Z`, + endTime: `${plus3DateString}T04:30:00.000Z`, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const numOfSlotsToBeBooked = 4; + const { req, res } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); + + const createdBookings = await handleRecurringEventBooking(req, res); + expect(createdBookings.length).toBe(numOfSlotsToBeBooked); + for (const [index, createdBooking] of Object.entries(createdBookings)) { + logger.debug("Assertion for Booking with index:", index, { createdBooking }); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + recurringEventId: mockBookingData.recurringEventId, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: "google_calendar", + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + //FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed. + videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, + }); + } + + // expectWorkflowToBeTriggered(); + + expectSuccessfulBookingCreationEmails({ + booker, + booking: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBookings[0].uid!, + urlOrigin: CAL_URL, + }, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + recurrence: { + ...recurrence, + count: recurringCountInRequest, + }, + }); + + expect(emails.get().length).toBe(2); + + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + ]); + }, + timeout + ); + + test( + `should create successful bookings for the number of slots requested even if the last slot is already booked as long as first two slots are free + 1. Should create the same number of bookings as requested slots in the database + 2. Should send emails for the first booking only to the booker as well as organizer + 3. Should create a calendar event for every booking in the destination calendar + 3. Should trigger BOOKING_CREATED webhook for every booking + `, + async ({ emails }) => { + const recurringCountInRequest = 4; + + const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) + .handleRecurringEventBooking; + 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], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const recurrence = getRecurrence({ + type: "weekly", + numberOfOccurrences: 3, + }); + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + const plus4DateString = getDate({ dateIncrement: 4 }).dateString; + 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, + slotInterval: 30, + length: 30, + recurringEvent: recurrence, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + bookings: [ + { + uid: "booking-1-uid", + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: `${plus4DateString}T04:00:00.000Z`, + endTime: `${plus4DateString}T04:30:00.000Z`, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const numOfSlotsToBeBooked = 4; + const { req, res } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); + + const createdBookings = await handleRecurringEventBooking(req, res); + expect(createdBookings.length).toBe(numOfSlotsToBeBooked); + for (const [index, createdBooking] of Object.entries(createdBookings)) { + logger.debug("Assertion for Booking with index:", index, { createdBooking }); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + recurringEventId: mockBookingData.recurringEventId, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: "google_calendar", + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + //FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed. + videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, + }); + } + + // expectWorkflowToBeTriggered(); + + expectSuccessfulBookingCreationEmails({ + booking: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBookings[0].uid!, + urlOrigin: CAL_URL, + }, booker, organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - //FIXME: All recurring bookings seem to have the same URL. https://github.com/calcom/cal.com/issues/11955 - videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + recurrence: { + ...recurrence, + count: recurringCountInRequest, + }, }); - } - // expectWorkflowToBeTriggered(); + expect(emails.get().length).toBe(2); - expectSuccessfulBookingCreationEmails({ - booker, - booking: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBookings[0].uid!, - urlOrigin: CAL_URL, - }, - organizer, - emails, - bookingTimeRange: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - start: createdBookings[0].startTime!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - end: createdBookings[0].endTime!, - }, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - recurrence: { - ...recurrence, - count: recurringCountInRequest, - }, - }); - - expect(emails.get().length).toBe(2); - - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - ]); - }, - timeout - ); - - test( - `should fail recurring booking if second slot is already booked`, - async ({}) => { + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + ]); + }, + timeout + ); + }); + describe("Round robin event type:", () => { + test("should when when a fixed host is not available on the second slot", async () => { const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) .handleRecurringEventBooking; const booker = getBooker({ @@ -264,21 +822,57 @@ describe("handleNewBooking", () => { name: "Organizer", email: "organizer@example.com", id: 101, - schedules: [TestData.schedules.IstWorkHours], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], destinationCalendar: { - integration: "google_calendar", + integration: TestData.apps["google-calendar"].type, externalId: "organizer@google-calendar.com", }, }); + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + const recurrence = getRecurrence({ type: "weekly", numberOfOccurrences: 3, }); const plus1DateString = getDate({ dateIncrement: 1 }).dateString; const plus2DateString = getDate({ dateIncrement: 2 }).dateString; + await createBookingScenario( getScenarioData({ webhooks: [ @@ -295,11 +889,17 @@ describe("handleNewBooking", () => { { id: 1, slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, length: 30, recurringEvent: recurrence, - users: [ + hosts: [ { - id: 101, + userId: 101, + isFixed: false, + }, + { + userId: 102, + isFixed: true, }, ], destinationCalendar: { @@ -310,147 +910,20 @@ describe("handleNewBooking", () => { ], bookings: [ { - uid: "booking-1-uid", + userId: 102, + attendees: [ + { + email: "IntegrationTestUser102@example.com", + }, + ], eventTypeId: 1, - userId: organizer.id, - status: BookingStatus.ACCEPTED, + status: "ACCEPTED", startTime: `${plus2DateString}T04:00:00.000Z`, endTime: `${plus2DateString}T04:30:00.000Z`, }, ], organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - videoMeetingData: { - id: "MOCK_ID", - password: "MOCK_PASS", - url: `http://mock-dailyvideo.example.com/meeting-1`, - }, - }); - - mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); - - const recurringCountInRequest = 2; - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - start: `${plus1DateString}T04:00:00.000Z`, - end: `${plus1DateString}T04:30:00.000Z`, - recurringEventId: uuidv4(), - recurringCount: recurringCountInRequest, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, - }, - }, - }); - - const numOfSlotsToBeBooked = 4; - const { req, res } = createMockNextJsRequest({ - method: "POST", - body: Array(numOfSlotsToBeBooked) - .fill(mockBookingData) - .map((mockBookingData, index) => { - return { - ...mockBookingData, - start: getPlusDayDate(mockBookingData.start, index).toISOString(), - end: getPlusDayDate(mockBookingData.end, index).toISOString(), - }; - }), - }); - - expect(() => handleRecurringEventBooking(req, res)).rejects.toThrow(ErrorCode.NoAvailableUsersFound); - // Actually the first booking goes through in this case but the status is still a failure. We should do a dry run to check if booking is possible for the 2 slots and if yes, then only go for the actual booking otherwise fail the recurring bookign - }, - timeout - ); - - test( - `should create successful bookings for the number of slots requested even if the third slot is already booked as long as first two slots are free - 1. Should create the same number of bookings as requested slots in the database - 2. Should send emails for the first booking only to the booker as well as organizer - 3. Should create a calendar event for every booking in the destination calendar - 3. Should trigger BOOKING_CREATED webhook for every booking - `, - async ({ emails }) => { - const recurringCountInRequest = 4; - - const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) - .handleRecurringEventBooking; - 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], - destinationCalendar: { - integration: "google_calendar", - externalId: "organizer@google-calendar.com", - }, - }); - - const recurrence = getRecurrence({ - type: "weekly", - numberOfOccurrences: 3, - }); - const plus1DateString = getDate({ dateIncrement: 1 }).dateString; - const plus3DateString = getDate({ dateIncrement: 3 }).dateString; - 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, - slotInterval: 30, - length: 30, - recurringEvent: recurrence, - users: [ - { - id: 101, - }, - ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@google-calendar.com", - }, - }, - ], - bookings: [ - { - uid: "booking-1-uid", - eventTypeId: 1, - userId: organizer.id, - status: BookingStatus.ACCEPTED, - startTime: `${plus3DateString}T04:00:00.000Z`, - endTime: `${plus3DateString}T04:30:00.000Z`, - }, - ], - organizer, + usersApartFromOrganizer: otherTeamMembers, apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); @@ -471,7 +944,8 @@ describe("handleNewBooking", () => { }, }); - const mockBookingData = getMockRequestDataForBooking({ + const recurringCountInRequest = 4; + const mockBookingData1 = getMockRequestDataForBooking({ data: { eventTypeId: 1, start: `${plus1DateString}T04:00:00.000Z`, @@ -490,116 +964,26 @@ describe("handleNewBooking", () => { const { req, res } = createMockNextJsRequest({ method: "POST", body: Array(numOfSlotsToBeBooked) - .fill(mockBookingData) + .fill(mockBookingData1) .map((mockBookingData, index) => { return { ...mockBookingData, + schedulingType: SchedulingType.ROUND_ROBIN, start: getPlusDayDate(mockBookingData.start, index).toISOString(), end: getPlusDayDate(mockBookingData.end, index).toISOString(), }; }), }); - - const createdBookings = await handleRecurringEventBooking(req, res); - expect(createdBookings.length).toBe(numOfSlotsToBeBooked); - for (const [index, createdBooking] of Object.entries(createdBookings)) { - logger.debug("Assertion for Booking with index:", index, { createdBooking }); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); - - expect(createdBooking).toContain({ - location: "integrations:daily", - }); - - await expectBookingToBeInDatabase({ - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - recurringEventId: mockBookingData.recurringEventId, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - type: "google_calendar", - uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - }, - ], - }); - - expectBookingCreatedWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - //FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed. - videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, - }); + let error = { message: "" }; + try { + await handleRecurringEventBooking(req, res); + } catch (e) { + error = e as Error; } + expect(error.message).toBe(ErrorCode.NoAvailableUsersFound); + }); - // expectWorkflowToBeTriggered(); - - expectSuccessfulBookingCreationEmails({ - booker, - booking: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBookings[0].uid!, - urlOrigin: CAL_URL, - }, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - recurrence: { - ...recurrence, - count: recurringCountInRequest, - }, - }); - - expect(emails.get().length).toBe(2); - - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - ]); - }, - timeout - ); - - test( - `should create successful bookings for the number of slots requested even if the last slot is already booked as long as first two slots are free - 1. Should create the same number of bookings as requested slots in the database - 2. Should send emails for the first booking only to the booker as well as organizer - 3. Should create a calendar event for every booking in the destination calendar - 3. Should trigger BOOKING_CREATED webhook for every booking - `, - async ({ emails }) => { - const recurringCountInRequest = 4; - + test("should create successfully bookings that are all assigned to the next available least recently booked user", async () => { const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) .handleRecurringEventBooking; const booker = getBooker({ @@ -611,21 +995,55 @@ describe("handleNewBooking", () => { name: "Organizer", email: "organizer@example.com", id: 101, - schedules: [TestData.schedules.IstWorkHours], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], destinationCalendar: { - integration: "google_calendar", + integration: TestData.apps["google-calendar"].type, externalId: "organizer@google-calendar.com", }, }); + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + const recurrence = getRecurrence({ type: "weekly", numberOfOccurrences: 3, }); const plus1DateString = getDate({ dateIncrement: 1 }).dateString; - const plus4DateString = getDate({ dateIncrement: 4 }).dateString; await createBookingScenario( getScenarioData({ webhooks: [ @@ -642,12 +1060,16 @@ describe("handleNewBooking", () => { { id: 1, slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, length: 30, recurringEvent: recurrence, users: [ { id: 101, }, + { + id: 102, + }, ], destinationCalendar: { integration: "google_calendar", @@ -655,17 +1077,8 @@ describe("handleNewBooking", () => { }, }, ], - bookings: [ - { - uid: "booking-1-uid", - eventTypeId: 1, - userId: organizer.id, - status: BookingStatus.ACCEPTED, - startTime: `${plus4DateString}T04:00:00.000Z`, - endTime: `${plus4DateString}T04:30:00.000Z`, - }, - ], organizer, + usersApartFromOrganizer: otherTeamMembers, apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); @@ -686,7 +1099,8 @@ describe("handleNewBooking", () => { }, }); - const mockBookingData = getMockRequestDataForBooking({ + const recurringCountInRequest = 4; + const mockBookingData1 = getMockRequestDataForBooking({ data: { eventTypeId: 1, start: `${plus1DateString}T04:00:00.000Z`, @@ -701,109 +1115,286 @@ describe("handleNewBooking", () => { }, }); + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T07:00:00.000Z`, + end: `${plus1DateString}T07:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); const numOfSlotsToBeBooked = 4; const { req, res } = createMockNextJsRequest({ method: "POST", body: Array(numOfSlotsToBeBooked) - .fill(mockBookingData) + .fill(mockBookingData1) .map((mockBookingData, index) => { return { ...mockBookingData, + schedulingType: SchedulingType.ROUND_ROBIN, start: getPlusDayDate(mockBookingData.start, index).toISOString(), end: getPlusDayDate(mockBookingData.end, index).toISOString(), }; }), }); - const createdBookings = await handleRecurringEventBooking(req, res); - expect(createdBookings.length).toBe(numOfSlotsToBeBooked); - for (const [index, createdBooking] of Object.entries(createdBookings)) { - logger.debug("Assertion for Booking with index:", index, { createdBooking }); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); + const { req: req1, res: res1 } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData2) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + schedulingType: SchedulingType.ROUND_ROBIN, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); - expect(createdBooking).toContain({ - location: "integrations:daily", - }); + const createdBookings1 = await handleRecurringEventBooking(req, res); - await expectBookingToBeInDatabase({ - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - recurringEventId: mockBookingData.recurringEventId, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + const assignedUserIds1 = createdBookings1.map((booking) => booking.userId); + + const createdBookings2 = await handleRecurringEventBooking(req1, res1); + + const assignedUserIds2 = createdBookings2.map((booking) => booking.userId); + + const allSameHosts = (assignedUserIds: (number | null | undefined)[]) => + assignedUserIds.every((id) => id === assignedUserIds[0]); + + expect(createdBookings1[0].userId).not.toBe(createdBookings2[0].userId); + expect(allSameHosts(assignedUserIds1)).toBe(true); + expect(allSameHosts(assignedUserIds2)).toBe(true); + }); + + test(`should create bookings that are all assigned to the least recently booked user that is available on the first two slots, + if the least recently booked user is not available on the second slot`, async () => { + const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event")) + .handleRecurringEventBooking; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + teams: [ + { + membership: { + accepted: true, }, - { - type: "google_calendar", - uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", + team: { + id: 1, + name: "Team 1", + slug: "team-1", }, - ], - }); - - expectBookingCreatedWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - //FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed. - videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`, - }); - } - - // expectWorkflowToBeTriggered(); - - expectSuccessfulBookingCreationEmails({ - booking: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBookings[0].uid!, - urlOrigin: CAL_URL, - }, - booker, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - recurrence: { - ...recurrence, - count: recurringCountInRequest, + }, + ], + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", }, }); - expect(emails.get().length).toBe(2); + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [ - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + const recurrence = getRecurrence({ + type: "weekly", + numberOfOccurrences: 3, + }); + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + const plus2DateString = getDate({ dateIncrement: 2 }).dateString; + + 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, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + recurringEvent: recurrence, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + bookings: [ + // Create a booking on our Collective Event Type + { + userId: 101, + attendees: [ + { + email: "IntegrationTestUser102@example.com", + }, + ], + eventTypeId: 1, + status: "ACCEPTED", + startTime: `${plus2DateString}T04:00:00.000Z`, + endTime: `${plus2DateString}T04:30:00.000Z`, + }, + { + userId: 101, + attendees: [ + { + email: "IntegrationTestUser102@example.com", + }, + ], + eventTypeId: 1, + status: "ACCEPTED", + startTime: `${plus2DateString}T07:00:00.000Z`, + endTime: `${plus2DateString}T07:30:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + const recurringCountInRequest = 4; + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, }, - { - calendarId: "event-type-1@google-calendar.com", - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T07:00:00.000Z`, + end: `${plus1DateString}T07:30:00.000Z`, + recurringEventId: uuidv4(), + recurringCount: recurringCountInRequest, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, }, - ]); - }, - timeout - ); + }); + const numOfSlotsToBeBooked = 4; + const { req, res } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData1) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + schedulingType: SchedulingType.ROUND_ROBIN, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); + + const { req: req1, res: res1 } = createMockNextJsRequest({ + method: "POST", + body: Array(numOfSlotsToBeBooked) + .fill(mockBookingData2) + .map((mockBookingData, index) => { + return { + ...mockBookingData, + schedulingType: SchedulingType.ROUND_ROBIN, + start: getPlusDayDate(mockBookingData.start, index).toISOString(), + end: getPlusDayDate(mockBookingData.end, index).toISOString(), + }; + }), + }); + + const createdBookings1 = await handleRecurringEventBooking(req, res); + + const assignedUserIds1 = createdBookings1.map((booking) => booking.userId); + + const createdBookings2 = await handleRecurringEventBooking(req1, res1); + + const assignedUserIds2 = createdBookings2.map((booking) => booking.userId); + + const allSameHosts = (assignedUserIds: (number | null | undefined)[]) => + assignedUserIds.every((id) => id === assignedUserIds[0]); + + expect(createdBookings1[0].userId).toBe(102); // user 101 is busy on the second recurring slot + expect(allSameHosts(assignedUserIds1)).toBe(true); + expect(allSameHosts(assignedUserIds2)).toBe(true); + }); + }); }); function getRecurrence({ diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 849ea14de7..204188b650 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -4,6 +4,8 @@ import type { BookingCreateBody } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppsStatus } from "@calcom/types/Calendar"; +import type { SchedulingType } from ".prisma/client"; + export type PublicEvent = NonNullable; export type ValidationErrors = { key: FieldPath; error: ErrorOption }[]; @@ -27,6 +29,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & { appsStatus?: AppsStatus[] | undefined; allRecurringDates?: Record[]; currentRecurringIndex?: number; + schedulingType?: SchedulingType; }; export type BookingResponse = Awaited< diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 671118e222..0c5436aff3 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -271,6 +271,7 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( }) ) .optional(), + luckyUsers: z.array(z.number()).optional(), }) );