feat: provide option to allow rescheduling with the same round-robin host (#15132)

* feat: provide option to allow rescheduling with the same round-robin
host

* update

* fix type error

* fix and update

* fix type error

* update

* remove

* Update getEventTypesFromDB.ts

* add test

* small fix

* fix requested changes
This commit is contained in:
Anik Dhabal Babu authored and GitHub committed 2024-08-12 15:03:50 +02:00
1 parent 24936d8b7b
commit 848bcbfb8f
17 files changed
+232 -8

No files matched your search

@@ -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
}
@@ -59,6 +59,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
seatsShowAvailabilityCount: true,
bookingLimits: true,
durationLimits: true,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
parentId: true,
parent: {
@@ -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
);
});
});
});
@@ -122,6 +122,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
},
hidden: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
});
// TODO: Convert it to accept a single parameter with structured data
@@ -116,6 +116,7 @@ export type FormValues = {
multipleDurationEnabled: boolean;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
rescheduleWithSameRoundRobinHost: boolean;
useEventTypeDestinationCalendarEmail: boolean;
forwardParamsSuccessRedirect: boolean | null;
secondaryEmailId?: number;
+1
View File
@@ -104,6 +104,7 @@ const commons = {
metadata: EventTypeMetaDataSchema.parse({}),
bookingFields: [],
assignAllTeamMembers: false,
rescheduleWithSameRoundRobinHost: false,
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
secondaryEmail: null,
+1
View File
@@ -43,6 +43,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
recurringEvent: true,
locations: true,
bookingFields: true,
@@ -456,6 +456,7 @@ export class EventTypeRepository {
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
currency: true,
+1
View File
@@ -115,6 +115,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
bookingLimits: null,
durationLimits: null,
assignAllTeamMembers: false,
rescheduleWithSameRoundRobinHost: false,
price: 0,
currency: "usd",
slotInterval: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "rescheduleWithSameRoundRobinHost" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -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)
+1
View File
@@ -655,6 +655,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
lockTimeZoneToggleOnBookingPage: true,
requiresBookerEmailVerification: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
};
// All properties that are defined as unlocked based on all managed props
@@ -175,6 +175,7 @@ export async function getEventType(
periodEndDate: true,
onlyShowFirstAvailableSlot: true,
periodCountCalendarDays: true,
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
metadata: true,
schedule: {
@@ -436,7 +437,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
let teamMember: string | undefined;
const hosts =
let hosts =
eventType.hosts?.length && eventType.schedulingType
? eventType.hosts
: eventType.users.map((user) => {
@@ -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) {