diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 17b4855be7..39245a06f4 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -23,6 +23,7 @@ import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import { Alert, @@ -88,6 +89,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { !hashedUrl && setHashedUrl(generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id)); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -533,6 +537,22 @@ export const EventAdvancedTab = ({ eventType, team }: Pick )} /> + {isRoundRobinEventType && ( + ( + onChange(e)} + /> + )} + /> + )} {allowDisablingAttendeeConfirmationEmails(workflows) && ( >; type AttendeeBookingSeatInput = Pick; @@ -353,6 +354,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In : eventType.schedule, owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined, schedulingType: eventType.schedulingType, + rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 933fd5d4fc..adb8fa91d5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -598,13 +598,23 @@ async function handler( // loop through all non-fixed hosts and get the lucky users while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { - const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { - // find a lucky user that is not already in the luckyUsers array - availableUsers: luckyUserPool.filter( - (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) - ), - eventTypeId: eventType.id, - }); + const freeUsers = luckyUserPool.filter( + (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) + ); + const originalRescheduledBookingUserId = + originalRescheduledBooking && originalRescheduledBooking.userId; + const isSameRoundRobinHost = + !!originalRescheduledBookingUserId && + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + eventType.rescheduleWithSameRoundRobinHost; + + const newLuckyUser = isSameRoundRobinHost + ? freeUsers.find((user) => user.id === originalRescheduledBookingUserId) + : await getLuckyUser("MAXIMIZE_AVAILABILITY", { + // find a lucky user that is not already in the luckyUsers array + availableUsers: freeUsers, + eventTypeId: eventType.id, + }); if (!newLuckyUser) { break; // prevent infinite loop } diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 3d7c09bce1..12b9a723ae 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -59,6 +59,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { seatsShowAvailabilityCount: true, bookingLimits: true, durationLimits: true, + rescheduleWithSameRoundRobinHost: true, assignAllTeamMembers: true, parentId: true, parent: { diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 6575e11f87..d6ef4cf726 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -2167,6 +2167,164 @@ describe("handleNewBooking", () => { }, timeout ); + test( + "should reschedule event with same round robin host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + hosts: [ + { + userId: 101, + isFixed: false, + }, + { + userId: 102, + isFixed: false, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + rescheduleWithSameRoundRobinHost: true, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + user: roundRobinHost1.name, + 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); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * 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`); + + // Expect both hosts for the event types to be the same + expect(createdBooking.userId).toBe(previousBooking.userId); + + 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, + }), + }, + }); + + expectSuccessfulRoundRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost1, + emails, + }); + }, + timeout + ); }); }); }); diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 754d10c85b..570bd5e14f 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -122,6 +122,7 @@ const publicEventSelect = Prisma.validator()({ }, hidden: true, assignAllTeamMembers: true, + rescheduleWithSameRoundRobinHost: true, }); // TODO: Convert it to accept a single parameter with structured data diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 393dbbeb47..6188b509c0 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -116,6 +116,7 @@ export type FormValues = { multipleDurationEnabled: boolean; users: EventTypeSetup["users"]; assignAllTeamMembers: boolean; + rescheduleWithSameRoundRobinHost: boolean; useEventTypeDestinationCalendarEmail: boolean; forwardParamsSuccessRedirect: boolean | null; secondaryEmailId?: number; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index ddf0799035..6c6cb244f5 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -104,6 +104,7 @@ const commons = { metadata: EventTypeMetaDataSchema.parse({}), bookingFields: [], assignAllTeamMembers: false, + rescheduleWithSameRoundRobinHost: false, useEventTypeDestinationCalendarEmail: false, secondaryEmailId: null, secondaryEmail: null, diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index a0174cab0d..f2d539f0a8 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -43,6 +43,7 @@ export const eventTypeSelect = Prisma.validator()({ instantMeetingExpiryTimeOffsetInSeconds: true, aiPhoneCallConfig: true, assignAllTeamMembers: true, + rescheduleWithSameRoundRobinHost: true, recurringEvent: true, locations: true, bookingFields: true, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index e455779104..f6c27e6f1e 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -456,6 +456,7 @@ export class EventTypeRepository { onlyShowFirstAvailableSlot: true, durationLimits: true, assignAllTeamMembers: true, + rescheduleWithSameRoundRobinHost: true, successRedirectUrl: true, forwardParamsSuccessRedirect: true, currency: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 96c245213e..d75f352429 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -115,6 +115,7 @@ export const buildEventType = (eventType?: Partial): EventType => { bookingLimits: null, durationLimits: null, assignAllTeamMembers: false, + rescheduleWithSameRoundRobinHost: false, price: 0, currency: "usd", slotInterval: null, diff --git a/packages/prisma/migrations/20240802053512_allow_rescheduling_with_same_round_robin_host/migration.sql b/packages/prisma/migrations/20240802053512_allow_rescheduling_with_same_round_robin_host/migration.sql new file mode 100644 index 0000000000..e3b42baf5b --- /dev/null +++ b/packages/prisma/migrations/20240802053512_allow_rescheduling_with_same_round_robin_host/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "rescheduleWithSameRoundRobinHost" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d745b5078a..85f420791c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -137,6 +137,7 @@ model EventType { assignAllTeamMembers Boolean @default(false) useEventTypeDestinationCalendarEmail Boolean @default(false) aiPhoneCallConfig AIPhoneCallConfiguration? + rescheduleWithSameRoundRobinHost Boolean @default(false) secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index c356897b51..80b594e412 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -655,6 +655,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit { @@ -446,6 +447,25 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro }; }); + if ( + input.rescheduleUid && + eventType.rescheduleWithSameRoundRobinHost && + eventType.schedulingType === SchedulingType.ROUND_ROBIN + ) { + const originalRescheduledBooking = await prisma.booking.findFirst({ + where: { + uid: input.rescheduleUid, + status: { + in: [BookingStatus.ACCEPTED], + }, + }, + select: { + userId: true, + }, + }); + hosts = hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0); + } + let usersWithCredentials = hosts.map(({ isFixed, user }) => ({ isFixed, ...user })); if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && input.bookerEmail) {