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:
@@ -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<EventTypeSetupProps,
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
|
||||
|
||||
const isRoundRobinEventType =
|
||||
eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN;
|
||||
|
||||
useEffect(() => {
|
||||
!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<EventTypeSetupProps,
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isRoundRobinEventType && (
|
||||
<Controller
|
||||
name="rescheduleWithSameRoundRobinHost"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("reschedule_with_same_round_robin_host_title")}
|
||||
description={t("reschedule_with_same_round_robin_host_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
|
||||
@@ -330,6 +330,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
},
|
||||
})),
|
||||
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
|
||||
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
|
||||
assignAllTeamMembers: eventType.assignAllTeamMembers,
|
||||
aiPhoneCallConfig: {
|
||||
generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE,
|
||||
|
||||
@@ -2529,5 +2529,7 @@
|
||||
"outlook_connect_atom_already_connected_label": "Connected Outlook Calendar",
|
||||
"outlook_connect_atom_loading_label": "Checking Outlook Calendar",
|
||||
"booking_question_response_variables": "Booking question response variables",
|
||||
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
|
||||
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ export type InputEventType = {
|
||||
durationLimits?: IntervalLimit;
|
||||
owner?: number;
|
||||
metadata?: any;
|
||||
rescheduleWithSameRoundRobinHost?: boolean;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
@@ -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));
|
||||
|
||||
@@ -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