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:
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;
|
||||
|
||||
@@ -104,6 +104,7 @@ const commons = {
|
||||
metadata: EventTypeMetaDataSchema.parse({}),
|
||||
bookingFields: [],
|
||||
assignAllTeamMembers: false,
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
secondaryEmailId: null,
|
||||
secondaryEmail: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "rescheduleWithSameRoundRobinHost" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user