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
2024-08-12 15:03:50 +02:00
committed by GitHub
parent 24936d8b7b
commit 848bcbfb8f
17 changed files with 232 additions and 8 deletions
@@ -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;
+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) {