diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts
index 5b7ac79e95..9b5ba327c0 100644
--- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts
@@ -40,7 +40,6 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
-
import { PrismaClient } from "@calcom/prisma";
@Controller({
diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx
index a1b80a01d5..b6dc5f00f7 100644
--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -19,7 +19,7 @@ import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
-import { BookingStatus } from "@calcom/prisma/enums";
+import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -44,6 +44,7 @@ import {
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
+import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
@@ -155,6 +156,45 @@ function BookingListItem(booking: BookingItemProps) {
: []),
];
+ const editBookingActions: ActionType[] = [
+ {
+ id: "reschedule",
+ icon: "clock" as const,
+ label: t("reschedule_booking"),
+ href: `${bookerUrl}/reschedule/${booking.uid}${
+ booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
+ }`,
+ },
+ {
+ id: "reschedule_request",
+ icon: "send" as const,
+ iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
+ label: t("send_reschedule_request"),
+ onClick: () => {
+ setIsOpenRescheduleDialog(true);
+ },
+ },
+ {
+ id: "change_location",
+ label: t("edit_location"),
+ onClick: () => {
+ setIsOpenLocationDialog(true);
+ },
+ icon: "map-pin" as const,
+ },
+ ];
+
+ if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
+ editBookingActions.push({
+ id: "reassign ",
+ label: t("reassign"),
+ onClick: () => {
+ setIsOpenReassignDialog(true);
+ },
+ icon: "users" as const,
+ });
+ }
+
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -170,33 +210,7 @@ function BookingListItem(booking: BookingItemProps) {
{
id: "edit_booking",
label: t("edit"),
- actions: [
- {
- id: "reschedule",
- icon: "clock" as const,
- label: t("reschedule_booking"),
- href: `${bookerUrl}/reschedule/${booking.uid}${
- booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
- }`,
- },
- {
- id: "reschedule_request",
- icon: "send" as const,
- iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
- label: t("send_reschedule_request"),
- onClick: () => {
- setIsOpenRescheduleDialog(true);
- },
- },
- {
- id: "change_location",
- label: t("edit_location"),
- onClick: () => {
- setIsOpenLocationDialog(true);
- },
- icon: "map-pin" as const,
- },
- ],
+ actions: editBookingActions,
},
];
@@ -233,6 +247,7 @@ function BookingListItem(booking: BookingItemProps) {
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
+ const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
@@ -309,6 +324,12 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
+
>;
+ teamId: number;
+ bookingId: number;
+};
+
+export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
+ onSuccess: async () => {
+ await utils.viewer.bookings.get.invalidate();
+ setIsOpenDialog(false);
+ showToast(t("booking_reassigned"), "success");
+ },
+ onError: async (error) => {
+ if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
+ showToast(t("no_available_hosts"), "error");
+ } else {
+ showToast(t("unexpected_error_try_again"), "error");
+ }
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts
index f01cee0fc3..c9033fa5aa 100644
--- a/apps/web/lib/booking.ts
+++ b/apps/web/lib/booking.ts
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
bookingFields: true,
disableGuests: true,
timeZone: true,
+ teamId: true,
owner: {
select: userSelect,
},
diff --git a/apps/web/modules/bookings/views/bookings-single-view.test.tsx b/apps/web/modules/bookings/views/bookings-single-view.test.tsx
index ae19e6f7e6..9a2705626f 100644
--- a/apps/web/modules/bookings/views/bookings-single-view.test.tsx
+++ b/apps/web/modules/bookings/views/bookings-single-view.test.tsx
@@ -33,6 +33,7 @@ function mockedSuccessComponentProps(props: Partial {
return usersStore.find((user) => user.id === userWithJustId.id);
}) || [];
+ const hosts =
+ eventType.users?.map((host) => {
+ const user = usersStore.find((user) => user.id === host.id);
+ return { ...host, user };
+ }) || [];
return {
...baseEventType,
...eventType,
workflows: [],
users,
+ hosts,
destinationCalendar: eventType.destinationCalendar
? {
create: eventType.destinationCalendar,
@@ -377,7 +401,7 @@ async function addBookingsToDb(
);
}
-async function addBookings(bookings: InputBooking[]) {
+export async function addBookings(bookings: InputBooking[]) {
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
const allBookings = [...bookings].map((booking) => {
if (booking.references) {
@@ -471,6 +495,7 @@ async function addWorkflowsToDb(workflows: InputWorkflow[]) {
// Create the workflow first
const createdWorkflow = await prismock.workflow.create({
data: {
+ ...(workflow.id && { id: workflow.id }),
userId: workflow.userId,
teamId: workflow.teamId,
trigger: workflow.trigger,
@@ -530,7 +555,15 @@ async function addWorkflowsToDb(workflows: InputWorkflow[]) {
async function addWorkflows(workflows: InputWorkflow[]) {
log.silly("TestData: Creating Workflows", safeStringify(workflows));
- await addWorkflowsToDb(workflows);
+ return await addWorkflowsToDb(workflows);
+}
+
+export async function addWorkflowReminders(workflowReminders: InputWorkflowReminder[]) {
+ log.silly("TestData: Creating Workflow Reminders", safeStringify(workflowReminders));
+
+ return await prismock.workflowReminder.createMany({
+ data: workflowReminders,
+ });
}
export async function addUsersToDb(
@@ -541,24 +574,28 @@ export async function addUsersToDb(
data: users,
});
+ const allUsers = await prismock.user.findMany({
+ include: {
+ credentials: true,
+ teams: true,
+ profiles: true,
+ schedules: {
+ include: {
+ availability: true,
+ },
+ },
+ destinationCalendar: true,
+ },
+ });
+
log.silly(
"Added users to Db",
safeStringify({
- allUsers: await prismock.user.findMany({
- include: {
- credentials: true,
- teams: true,
- profiles: true,
- schedules: {
- include: {
- availability: true,
- },
- },
- destinationCalendar: true,
- },
- }),
+ allUsers,
})
);
+
+ return allUsers;
}
export async function addTeamsToDb(teams: NonNullable[number]["team"][]) {
@@ -655,6 +692,13 @@ export async function addUsers(users: InputUser[]) {
},
};
}
+ if (user.destinationCalendar) {
+ newUser.destinationCalendar = {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ create: user.destinationCalendar,
+ };
+ }
if (user.profiles) {
newUser.profiles = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -669,7 +713,7 @@ export async function addUsers(users: InputUser[]) {
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
- await addUsersToDb(prismaUsersCreate);
+ return await addUsersToDb(prismaUsersCreate);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -701,10 +745,11 @@ export async function createBookingScenario(data: ScenarioData) {
// mockBusyCalendarTimes([]);
await addWebhooks(data.webhooks || []);
// addPaymentMock();
- await addWorkflows(data.workflows || []);
+ const workflows = await addWorkflows(data.workflows || []);
return {
eventTypes,
+ workflows,
};
}
@@ -1224,13 +1269,6 @@ export function getScenarioData(
...user,
organizationId: user.organizationId ?? null,
};
- if (user.destinationCalendar) {
- newUser.destinationCalendar = {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- create: user.destinationCalendar,
- };
- }
return newUser;
}),
apps: [...apps],
diff --git a/packages/app-store/skype/config.json b/packages/app-store/skype/config.json
index bcd75fbe2b..cf0429d315 100644
--- a/packages/app-store/skype/config.json
+++ b/packages/app-store/skype/config.json
@@ -6,9 +6,7 @@
"logo": "icon.svg",
"url": "https://github.com/anikdhabal/",
"variant": "conferencing",
- "categories": [
- "conferencing"
- ],
+ "categories": ["conferencing"],
"publisher": "Anik Dhabal Babu",
"email": "adhabl2002@gmail.com",
"appData": {
@@ -17,11 +15,11 @@
"label": "{TITLE}",
"linkType": "static",
"organizerInputPlaceholder": "https://join.skype.com/",
- "urlRegExp": "https:\/\/join\\.skype\\.com\/[a-zA-Z0-9]*"
+ "urlRegExp": "https://join\\.skype\\.com/[a-zA-Z0-9]*"
}
},
"description": "Skype is for connecting with the people that matter most in your life and work. It's built for both one-on-one and group conversations and works wherever you are – via mobile, PC and Alexa. Skype messaging and HD voice and video calling will help you share experiences and get things done with others.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
-}
\ No newline at end of file
+}
diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts
index 1b4b587972..e5fc5a5aa5 100644
--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -411,6 +411,9 @@ export default class EventManager {
userId: true,
attendees: true,
references: {
+ where: {
+ deleted: null,
+ },
// NOTE: id field removed from select as we don't require for deletingMany
// but was giving error on recreate for reschedule, probably because promise.all() didn't finished
select: {
diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
index 38576d80ae..3d7c09bce1 100644
--- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
+++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
@@ -24,6 +24,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
},
slug: true,
+ teamId: true,
team: {
select: {
id: true,
diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts
index 385816a633..cb4e7fb2d6 100644
--- a/packages/features/bookings/lib/handleNewBooking/types.ts
+++ b/packages/features/bookings/lib/handleNewBooking/types.ts
@@ -55,7 +55,7 @@ export interface IEventTypePaymentCredentialType {
export type IsFixedAwareUser = User & {
isFixed: boolean;
credentials: CredentialPayload[];
- organization: { slug: string };
+ organization?: { slug: string };
priority?: number;
};
diff --git a/packages/features/ee/round-robin/roundRobinReassignment.test.ts b/packages/features/ee/round-robin/roundRobinReassignment.test.ts
new file mode 100644
index 0000000000..4c3ee84d8e
--- /dev/null
+++ b/packages/features/ee/round-robin/roundRobinReassignment.test.ts
@@ -0,0 +1,295 @@
+import {
+ getDate,
+ createBookingScenario,
+ getScenarioData,
+ getMockBookingAttendee,
+ TestData,
+ addWorkflowReminders,
+} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
+import {
+ expectBookingToBeInDatabase,
+ expectSuccessfulRoundRobinReschedulingEmails,
+ expectWorkflowToBeTriggered,
+} from "@calcom/web/test/utils/bookingScenario/expects";
+import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
+
+import { describe, vi, expect } from "vitest";
+
+import { BookingRepository } from "@calcom/lib/server/repository/booking";
+import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
+import { test } from "@calcom/web/test/fixtures/fixtures";
+
+vi.mock("@calcom/core/EventManager");
+
+const testDestinationCalendar = {
+ integration: "test-calendar",
+ externalId: "test-calendar",
+};
+
+const testUsers = [
+ {
+ id: 1,
+ name: "user-1",
+ timeZone: "Asia/Kolkata",
+ username: "host-1",
+ email: "host1@test.com",
+ schedules: [TestData.schedules.IstWorkHours],
+ destinationCalendar: testDestinationCalendar,
+ },
+ {
+ id: 2,
+ name: "user-2",
+ timeZone: "Asia/Kolkata",
+ username: "host-2",
+ email: "host2@test.com",
+ schedules: [TestData.schedules.IstWorkHours],
+ },
+ {
+ id: 3,
+ name: "user-3",
+ timeZone: "Asia/Kolkata",
+ username: "host-3",
+ email: "host3@test.com",
+ schedules: [TestData.schedules.IstWorkHours],
+ },
+];
+
+describe("roundRobinReassignment test", () => {
+ setupAndTeardown();
+
+ test("reassign new round robin organizer", async ({ emails }) => {
+ const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
+ const EventManager = (await import("@calcom/core/EventManager")).default;
+
+ const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
+ eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
+
+ const users = testUsers;
+ const originalHost = users[0];
+ const newHost = users[1];
+ // Assume we are using the RR fairness algorithm. Add an extra booking for user[2] to ensure user[1] is the new host
+
+ const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
+ const { dateString: dateStringMinusOne } = getDate({ dateIncrement: -1 });
+ const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
+
+ const bookingToReassignUid = "booking-to-reassign";
+
+ const bookingData = await createBookingScenario(
+ getScenarioData({
+ workflows: [
+ {
+ userId: originalHost.id,
+ trigger: "NEW_EVENT",
+ action: "EMAIL_HOST",
+ template: "REMINDER",
+ activeEventTypeId: 1,
+ },
+ ],
+ eventTypes: [
+ {
+ id: 1,
+ slug: "round-robin-event",
+ schedulingType: SchedulingType.ROUND_ROBIN,
+ length: 45,
+ users: users.map((user) => {
+ return {
+ id: user.id,
+ };
+ }),
+ hosts: users.map((user) => {
+ return {
+ userId: user.id,
+ isFixed: false,
+ };
+ }),
+ },
+ ],
+ bookings: [
+ {
+ id: 123,
+ eventTypeId: 1,
+ userId: originalHost.id,
+ uid: bookingToReassignUid,
+ status: BookingStatus.ACCEPTED,
+ startTime: `${dateStringPlusOne}T05:00:00.000Z`,
+ endTime: `${dateStringPlusOne}T05:15:00.000Z`,
+ attendees: [
+ getMockBookingAttendee({
+ id: 2,
+ name: "attendee",
+ email: "attendee@test.com",
+ locale: "en",
+ timeZone: "Asia/Kolkata",
+ }),
+ ],
+ },
+ {
+ id: 456,
+ eventTypeId: 1,
+ userId: users[2].id,
+ uid: bookingToReassignUid,
+ status: BookingStatus.ACCEPTED,
+ startTime: `${dateStringMinusOne}T05:00:00.000Z`,
+ endTime: `${dateStringMinusOne}T05:15:00.000Z`,
+ attendees: [
+ getMockBookingAttendee({
+ id: 2,
+ name: "attendee",
+ email: "attendee@test.com",
+ locale: "en",
+ timeZone: "Asia/Kolkata",
+ }),
+ ],
+ },
+ ],
+ organizer: originalHost,
+ usersApartFromOrganizer: users.slice(1),
+ })
+ );
+ await addWorkflowReminders([
+ {
+ bookingUid: bookingToReassignUid,
+ method: WorkflowMethods.EMAIL,
+ scheduledDate: dateStringPlusTwo,
+ scheduled: true,
+ workflowStepId: 1,
+ workflowId: 1,
+ },
+ ]);
+
+ await roundRobinReassignment({
+ bookingId: 123,
+ });
+
+ expect(eventManagerSpy).toBeCalledTimes(1);
+ // Triggers moving to new host within event manager
+ expect(eventManagerSpy).toHaveBeenCalledWith(
+ expect.any(Object),
+ bookingToReassignUid,
+ undefined,
+ true,
+ expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
+ );
+
+ // Use equal fairness rr algorithm
+ expectBookingToBeInDatabase({
+ uid: bookingToReassignUid,
+ userId: newHost.id,
+ });
+
+ expectSuccessfulRoundRobinReschedulingEmails({
+ prevOrganizer: originalHost,
+ newOrganizer: newHost,
+ emails,
+ });
+
+ expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
+ });
+
+ // TODO: add fixed hosts test
+ test("Reassign round robin host with fixed host as organizer", async () => {
+ const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
+ const EventManager = (await import("@calcom/core/EventManager")).default;
+
+ const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
+
+ const users = testUsers;
+
+ const bookingToReassignUid = "booking-to-reassign";
+
+ const fixedHost = users[0];
+ const currentRRHost = users[1];
+ const newHost = users[2];
+ // Assume we are using the RR fairness algorithm. Add an extra booking for user[2] to ensure user[1] is the new host
+ const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
+
+ await createBookingScenario(
+ getScenarioData({
+ workflows: [
+ {
+ userId: fixedHost.id,
+ trigger: "NEW_EVENT",
+ action: "EMAIL_HOST",
+ template: "REMINDER",
+ activeEventTypeId: 1,
+ },
+ ],
+ eventTypes: [
+ {
+ id: 1,
+ slug: "round-robin-event",
+ schedulingType: SchedulingType.ROUND_ROBIN,
+ length: 45,
+ users: users.map((user) => {
+ return {
+ id: user.id,
+ };
+ }),
+ hosts: users.map((user) => {
+ return {
+ userId: user.id,
+ isFixed: !!(user.id === fixedHost.id),
+ };
+ }),
+ },
+ ],
+ bookings: [
+ {
+ id: 123,
+ eventTypeId: 1,
+ userId: fixedHost.id,
+ uid: bookingToReassignUid,
+ status: BookingStatus.ACCEPTED,
+ startTime: `${dateStringPlusOne}T05:00:00.000Z`,
+ endTime: `${dateStringPlusOne}T05:15:00.000Z`,
+ attendees: [
+ getMockBookingAttendee({
+ id: 1,
+ name: "attendee",
+ email: "attendee@test.com",
+ locale: "en",
+ timeZone: "Asia/Kolkata",
+ }),
+ getMockBookingAttendee({
+ id: currentRRHost.id,
+ name: currentRRHost.name,
+ email: currentRRHost.email,
+ locale: "en",
+ timeZone: currentRRHost.timeZone,
+ }),
+ ],
+ },
+ ],
+ organizer: fixedHost,
+ usersApartFromOrganizer: users.slice(1),
+ })
+ );
+
+ await roundRobinReassignment({
+ bookingId: 123,
+ });
+
+ expect(eventManagerSpy).toBeCalledTimes(1);
+ // Triggers moving to new host within event manager
+ expect(eventManagerSpy).toHaveBeenCalledWith(
+ expect.any(Object),
+ bookingToReassignUid,
+ undefined,
+ false,
+ []
+ );
+
+ // Ensure organizer stays the same
+ expectBookingToBeInDatabase({
+ uid: bookingToReassignUid,
+ userId: 1,
+ });
+
+ const attendees = await BookingRepository.getBookingAttendees(123);
+
+ expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
+
+ expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
+ });
+});
diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts
new file mode 100644
index 0000000000..09264fd69a
--- /dev/null
+++ b/packages/features/ee/round-robin/roundRobinReassignment.ts
@@ -0,0 +1,528 @@
+// eslint-disable-next-line no-restricted-imports
+import { cloneDeep } from "lodash";
+
+import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
+import EventManager from "@calcom/core/EventManager";
+import { getEventName } from "@calcom/core/event";
+import dayjs from "@calcom/dayjs";
+import { sendRoundRobinCancelledEmails, sendRoundRobinScheduledEmails } from "@calcom/emails";
+import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
+import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
+import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
+import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
+import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
+import {
+ scheduleEmailReminder,
+ deleteScheduledEmailReminder,
+} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
+import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
+import { isPrismaObjOrUndefined } from "@calcom/lib";
+import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
+import logger from "@calcom/lib/logger";
+import { getLuckyUser } from "@calcom/lib/server";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference";
+import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
+import { prisma } from "@calcom/prisma";
+import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
+import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+const bookingSelect = {
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ userId: true,
+ customInputs: true,
+ responses: true,
+ description: true,
+ location: true,
+ eventTypeId: true,
+ destinationCalendar: true,
+ user: {
+ include: {
+ destinationCalendar: true,
+ },
+ },
+ attendees: true,
+ references: true,
+};
+
+export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }) => {
+ const roundRobinReassignLogger = logger.getSubLogger({
+ prefix: ["roundRobinReassign", `${bookingId}`],
+ });
+
+ let booking = await prisma.booking.findUnique({
+ where: {
+ id: bookingId,
+ },
+ select: bookingSelect,
+ });
+
+ if (!booking) {
+ roundRobinReassignLogger.error(`Booking ${bookingId} not found`);
+ throw new Error("Booking not found");
+ }
+
+ if (!booking?.user) {
+ roundRobinReassignLogger.error(`No user associated with booking ${bookingId}`);
+ throw new Error("Booking not found");
+ }
+
+ const eventTypeId = booking.eventTypeId;
+
+ if (!eventTypeId) {
+ roundRobinReassignLogger.error(`Booking ${bookingId} does not have an event type id`);
+ throw new Error("Event type not found");
+ }
+
+ const eventType = await getEventTypesFromDB(eventTypeId);
+
+ if (!eventType) {
+ roundRobinReassignLogger.error(`Event type ${eventTypeId} not found`);
+ throw new Error("Event type not found");
+ }
+
+ eventType.hosts = eventType.hosts.length
+ ? eventType.hosts
+ : eventType.users.map((user) => ({ user, isFixed: false, priority: 2 }));
+
+ const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
+
+ const originalOrganizer = booking.user;
+
+ const attendeeEmailsSet = new Set(booking.attendees.map((attendee) => attendee.email));
+
+ // Find the current round robin host assigned
+ const previousRRHost = (() => {
+ for (const host of roundRobinHosts) {
+ if (host.user.id === booking.userId) {
+ return host.user;
+ }
+ if (attendeeEmailsSet.has(host.user.email)) {
+ return host.user;
+ }
+ }
+ })();
+
+ const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
+
+ // Filter out the current attendees of the booking from the event type
+ const availableEventTypeUsers = eventType.hosts.reduce((availableUsers, host) => {
+ if (!attendeeEmailsSet.has(host.user.email) && host.user.email !== originalOrganizer.email) {
+ availableUsers.push({ ...host.user, isFixed: host.isFixed, priority: host?.priority ?? 2 });
+ }
+ return availableUsers;
+ }, [] as IsFixedAwareUser[]);
+
+ const availableUsers = await ensureAvailableUsers(
+ { ...eventType, users: availableEventTypeUsers },
+ {
+ dateFrom: dayjs(booking.startTime).format(),
+ dateTo: dayjs(booking.endTime).format(),
+ timeZone: eventType.timeZone || originalOrganizer.timeZone,
+ },
+ roundRobinReassignLogger
+ );
+
+ const reassignedRRHost = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
+ availableUsers,
+ eventTypeId: eventTypeId,
+ });
+ const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id;
+ const organizer = hasOrganizerChanged ? reassignedRRHost : booking.user;
+ const organizerT = await getTranslation(organizer?.locale || "en", "common");
+
+ const currentBookingTitle = booking.title;
+ let newBookingTitle = currentBookingTitle;
+
+ const reassignedRRHostT = await getTranslation(reassignedRRHost.locale || "en", "common");
+
+ const teamMemberPromises = [];
+ for (const teamMember of eventType.hosts) {
+ const user = teamMember.user;
+ // Need to skip over the reassigned user and the organizer user
+ if (user.email === previousRRHost?.email || user.email === organizer.email) {
+ continue;
+ }
+
+ // Check that the team member is a part of the booking
+ if (booking.attendees.some((attendee) => attendee.email === user.email)) {
+ teamMemberPromises.push(
+ getTranslation(user.locale ?? "en", "common").then((tTeamMember) => ({
+ id: user.id,
+ email: user.email,
+ name: user.name || "",
+ timeZone: user.timeZone,
+ language: { translate: tTeamMember, locale: user.locale ?? "en" },
+ }))
+ );
+ }
+ }
+
+ const teamMembers = await Promise.all(teamMemberPromises);
+ // Assume the RR host was labelled as a team member
+ if (reassignedRRHost.email !== organizer.email) {
+ teamMembers.push({
+ id: reassignedRRHost.id,
+ email: reassignedRRHost.email,
+ name: reassignedRRHost.name || "",
+ timeZone: reassignedRRHost.timeZone,
+ language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale ?? "en" },
+ });
+ }
+
+ const attendeePromises = [];
+ for (const attendee of booking.attendees) {
+ if (
+ attendee.email === reassignedRRHost.email ||
+ attendee.email === previousRRHost?.email ||
+ teamMembers.some((member) => member.email === attendee.email)
+ ) {
+ continue;
+ }
+
+ attendeePromises.push(
+ getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({
+ email: attendee.email,
+ name: attendee.name,
+ timeZone: attendee.timeZone,
+ language: { translate: tAttendee, locale: attendee.locale ?? "en" },
+ }))
+ );
+ }
+
+ const attendeeList = await Promise.all(attendeePromises);
+
+ if (hasOrganizerChanged) {
+ const bookingResponses = booking.responses;
+
+ const responseSchema = getBookingResponsesSchema({
+ bookingFields: eventType.bookingFields,
+ view: "reschedule",
+ });
+
+ const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
+
+ const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
+
+ let bookingLocation = booking.location;
+
+ if (eventType.locations.includes({ type: OrganizerDefaultConferencingAppType })) {
+ const organizerMetadataSafeParse = userMetadataSchema.safeParse(reassignedRRHost.metadata);
+
+ const defaultLocationUrl = organizerMetadataSafeParse.success
+ ? organizerMetadataSafeParse?.data?.defaultConferencingApp?.appLink
+ : undefined;
+
+ const currentBookingLocation = booking.location || "integrations:daily";
+
+ bookingLocation =
+ defaultLocationUrl ||
+ getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
+ }
+
+ const eventNameObject = {
+ attendeeName: responses?.name || "Nameless",
+ eventType: eventType.title,
+ eventName: eventType.eventName,
+ // we send on behalf of team if >1 round robin attendee | collective
+ teamName: teamMembers.length > 1 ? eventType.team?.name : null,
+ // TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here.
+ host: organizer.name || "Nameless",
+ location: bookingLocation || "integrations:daily",
+ bookingFields: { ...responses },
+ t: organizerT,
+ };
+
+ newBookingTitle = getEventName(eventNameObject);
+
+ booking = await prisma.booking.update({
+ where: {
+ id: bookingId,
+ },
+ data: {
+ userId: reassignedRRHost.id,
+ title: newBookingTitle,
+ },
+ select: bookingSelect,
+ });
+ } else {
+ const previousRRHostAttendee = booking.attendees.find(
+ (attendee) => attendee.email === previousRRHost.email
+ );
+ await prisma.attendee.update({
+ where: {
+ id: previousRRHostAttendee!.id,
+ },
+ data: {
+ name: reassignedRRHost.name || "",
+ email: reassignedRRHost.email,
+ timeZone: reassignedRRHost.timeZone,
+ locale: reassignedRRHost.locale,
+ },
+ });
+ }
+
+ const destinationCalendar = await (async () => {
+ if (eventType?.destinationCalendar) {
+ return [eventType.destinationCalendar];
+ }
+
+ if (hasOrganizerChanged) {
+ const organizerDestinationCalendar = await prisma.destinationCalendar.findFirst({
+ where: {
+ userId: reassignedRRHost.id,
+ },
+ });
+ if (organizerDestinationCalendar) {
+ return [organizerDestinationCalendar];
+ }
+ } else {
+ if (booking.user?.destinationCalendar) return [booking.user?.destinationCalendar];
+ }
+ })();
+
+ // If changed owner, also change destination calendar
+ const previousHostDestinationCalendar = hasOrganizerChanged
+ ? await prisma.destinationCalendar.findFirst({
+ where: {
+ userId: originalOrganizer.id,
+ },
+ })
+ : null;
+
+ const evt: CalendarEvent = {
+ organizer: {
+ name: organizer.name || "",
+ email: organizer.email,
+ language: {
+ locale: organizer.locale || "en",
+ translate: organizerT,
+ },
+ timeZone: organizer.timeZone,
+ timeFormat: getTimeFormatStringFromUserTimeFormat(organizer.timeFormat),
+ },
+ startTime: dayjs(booking.startTime).utc().format(),
+ endTime: dayjs(booking.endTime).utc().format(),
+ type: eventType.slug,
+ title: newBookingTitle,
+ description: eventType.description,
+ attendees: attendeeList,
+ uid: booking.uid,
+ destinationCalendar,
+ team: {
+ members: teamMembers,
+ name: eventType.team?.name || "",
+ id: eventType.team?.id || 0,
+ },
+ customInputs: isPrismaObjOrUndefined(booking.customInputs),
+ ...getCalEventResponses({
+ bookingFields: eventType?.bookingFields ?? null,
+ booking,
+ }),
+ };
+
+ const credentials = await prisma.credential.findMany({
+ where: {
+ userId: organizer.id,
+ },
+ include: {
+ user: {
+ select: {
+ email: true,
+ },
+ },
+ },
+ });
+ const eventManager = new EventManager({ ...organizer, credentials: [...credentials] });
+
+ const results = await eventManager.reschedule(
+ evt,
+ booking.uid,
+ undefined,
+ hasOrganizerChanged,
+ previousHostDestinationCalendar ? [previousHostDestinationCalendar] : []
+ );
+
+ let newReferencesToCreate = [];
+ newReferencesToCreate = structuredClone(results.referencesToCreate);
+
+ await BookingReferenceRepository.replaceBookingReferences({
+ bookingId,
+ newReferencesToCreate,
+ });
+
+ // Send to new RR host
+ await sendRoundRobinScheduledEmails(evt, [
+ {
+ ...reassignedRRHost,
+ name: reassignedRRHost.name || "",
+ username: reassignedRRHost.username || "",
+ timeFormat: getTimeFormatStringFromUserTimeFormat(reassignedRRHost.timeFormat),
+ language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale || "en" },
+ },
+ ]);
+
+ if (previousRRHost) {
+ // Send to cancelled RR host
+ // First we need to replace the new RR host with the old RR host in the evt object
+ const cancelledRRHostEvt = cloneDeep(evt);
+ cancelledRRHostEvt.title = currentBookingTitle;
+ if (hasOrganizerChanged) {
+ cancelledRRHostEvt.organizer = {
+ name: previousRRHost.name || "",
+ email: previousRRHost.email,
+ language: {
+ locale: previousRRHost.locale || "en",
+ translate: previousRRHostT,
+ },
+ timeZone: previousRRHost.timeZone,
+ timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat),
+ };
+ } else if (cancelledRRHostEvt.team) {
+ // Filter out the new RR host from attendees and add the old RR host
+ const newMembersArray = cancelledRRHostEvt.team?.members || [];
+ cancelledRRHostEvt.team.members = newMembersArray.filter(
+ (member) => member.email !== reassignedRRHost.email
+ );
+ cancelledRRHostEvt.team.members.unshift({
+ id: previousRRHost.id,
+ email: previousRRHost.email,
+ name: previousRRHost.name || "",
+ timeZone: previousRRHost.timeZone,
+ language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
+ });
+ }
+
+ await sendRoundRobinCancelledEmails(cancelledRRHostEvt, [
+ {
+ ...previousRRHost,
+ name: previousRRHost.name || "",
+ username: previousRRHost.username || "",
+ timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat),
+ language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
+ },
+ ]);
+ }
+
+ // Handle changing workflows with organizer
+ if (hasOrganizerChanged) {
+ const workflowReminders = await prisma.workflowReminder.findMany({
+ where: {
+ bookingUid: booking.uid,
+ method: WorkflowMethods.EMAIL,
+ workflowStep: {
+ action: WorkflowActions.EMAIL_HOST,
+ workflow: {
+ trigger: {
+ in: [
+ WorkflowTriggerEvents.BEFORE_EVENT,
+ WorkflowTriggerEvents.NEW_EVENT,
+ WorkflowTriggerEvents.AFTER_EVENT,
+ ],
+ },
+ },
+ },
+ },
+ select: {
+ id: true,
+ referenceId: true,
+ workflowStep: {
+ select: {
+ template: true,
+ workflow: {
+ select: {
+ trigger: true,
+ time: true,
+ timeUnit: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ for (const workflowReminder of workflowReminders) {
+ const workflowStep = workflowReminder?.workflowStep;
+ const workflow = workflowStep?.workflow;
+
+ if (workflowStep && workflow) {
+ await scheduleEmailReminder({
+ evt: {
+ ...evt,
+ eventType,
+ },
+ action: WorkflowActions.EMAIL_HOST,
+ triggerEvent: workflow.trigger,
+ timeSpan: {
+ time: workflow.time,
+ timeUnit: workflow.timeUnit,
+ },
+ sendTo: reassignedRRHost.email,
+ template: workflowStep.template,
+ });
+ }
+
+ await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId);
+ }
+ // Send new event workflows to new organizer
+ const newEventWorkflows = await prisma.workflow.findMany({
+ where: {
+ trigger: WorkflowTriggerEvents.NEW_EVENT,
+ OR: [
+ {
+ isActiveOnAll: true,
+ teamId: eventType?.teamId,
+ },
+ {
+ activeOn: {
+ some: {
+ eventTypeId: eventTypeId,
+ },
+ },
+ },
+ ...(eventType?.teamId
+ ? [
+ {
+ activeOnTeams: {
+ some: {
+ teamId: eventType.teamId,
+ },
+ },
+ },
+ ]
+ : []),
+ ...(eventType?.team?.parentId
+ ? [
+ {
+ isActiveOnAll: true,
+ teamId: eventType.team.parentId,
+ },
+ ]
+ : []),
+ ],
+ },
+ include: {
+ steps: {
+ where: {
+ action: WorkflowActions.EMAIL_HOST,
+ },
+ },
+ },
+ });
+
+ const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evt) };
+
+ await scheduleWorkflowReminders({
+ workflows: newEventWorkflows,
+ smsReminderNumber: null,
+ calendarEvent: { ...evt, metadata: workflowEventMetadata, eventType: { slug: eventType.slug } },
+ hideBranding: !!eventType?.owner?.hideBranding,
+ });
+ }
+};
+
+export default roundRobinReassignment;
diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts
index 1a34c7c18c..c881d23dfb 100644
--- a/packages/lib/server/repository/booking.ts
+++ b/packages/lib/server/repository/booking.ts
@@ -1,6 +1,48 @@
-import prisma from "@calcom/prisma";
+import { prisma } from "@calcom/prisma";
+
+import { UserRepository } from "./user";
export class BookingRepository {
+ static async getBookingAttendees(bookingId: number) {
+ return await prisma.attendee.findMany({
+ where: {
+ bookingId,
+ },
+ });
+ }
+
+ /** Determines if the user is the organizer, team admin, or org admin that the booking was created under */
+ static async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) {
+ const booking = await prisma.booking.findFirst({
+ where: {
+ id: bookingId,
+ },
+ select: {
+ userId: true,
+ eventType: {
+ select: {
+ teamId: true,
+ },
+ },
+ },
+ });
+
+ if (!booking) return false;
+
+ if (userId === booking.userId) return true;
+
+ // If the booking doesn't belong to the user and there's no team then return early
+ if (!booking.eventType || !booking.eventType.teamId) return false;
+
+ // TODO add checks for team and org
+ const isAdminOrUser = await UserRepository.isAdminOfTeamOrParentOrg({
+ userId,
+ teamId: booking.eventType.teamId,
+ });
+
+ return isAdminOrUser;
+ }
+
static async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) {
return await prisma.booking.findFirst({
where: {
diff --git a/packages/lib/server/repository/bookingReference.ts b/packages/lib/server/repository/bookingReference.ts
index 1d12548a96..905bdec75f 100644
--- a/packages/lib/server/repository/bookingReference.ts
+++ b/packages/lib/server/repository/bookingReference.ts
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
+import type { PartialReference } from "@calcom/types/EventManager";
const bookingReferenceSelect = Prisma.validator()({
id: true,
@@ -20,4 +21,32 @@ export class BookingReferenceRepository {
select: bookingReferenceSelect,
});
}
+
+ /**
+ * If rescheduling a booking with new references from the EventManager. Delete the previous references and replace them with new ones
+ */
+ static async replaceBookingReferences({
+ bookingId,
+ newReferencesToCreate,
+ }: {
+ bookingId: number;
+ newReferencesToCreate: PartialReference[];
+ }) {
+ const newReferenceTypes = newReferencesToCreate.map((reference) => reference.type);
+
+ await prisma.bookingReference.deleteMany({
+ where: {
+ bookingId,
+ type: {
+ in: newReferenceTypes,
+ },
+ },
+ });
+
+ await prisma.bookingReference.createMany({
+ data: newReferencesToCreate.map((reference) => {
+ return { ...reference, bookingId };
+ }),
+ });
+ }
}
diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts
index b1843912f7..cc71c722b1 100644
--- a/packages/lib/server/repository/user.ts
+++ b/packages/lib/server/repository/user.ts
@@ -494,6 +494,32 @@ export class UserRepository {
});
}
+ static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
+ const membershipQuery = {
+ members: {
+ some: {
+ userId,
+ role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
+ },
+ },
+ };
+ const teams = await prisma.team.findMany({
+ where: {
+ id: teamId,
+ OR: [
+ membershipQuery,
+ {
+ parent: { ...membershipQuery },
+ },
+ ],
+ },
+ select: {
+ id: true,
+ },
+ });
+ return !!teams.length;
+ }
+
static async getUserAdminTeams(userId: number): Promise {
const user = await prisma.user.findFirst({
where: {
diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
index 821b09637d..998278d0c9 100644
--- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
@@ -179,6 +179,7 @@ export async function getBookings({
metadata: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
+ schedulingType: true,
team: {
select: {
id: true,
@@ -190,6 +191,7 @@ export async function getBookings({
},
status: true,
paid: true,
+
payment: {
select: {
paymentOption: true,
diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx
index b65acb1b64..115799d733 100644
--- a/packages/trpc/server/routers/viewer/teams/_router.tsx
+++ b/packages/trpc/server/routers/viewer/teams/_router.tsx
@@ -18,6 +18,7 @@ import { hasTeamPlan } from "./procedures/hasTeamPlan";
import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZResendInvitationInputSchema } from "./resendInvitation.schema";
+import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
@@ -154,4 +155,11 @@ export const viewerTeamsRouter = router({
);
return handler(opts);
}),
+ roundRobinReassign: authedProcedure.input(ZRoundRobinReassignInputSchema).mutation(async (opts) => {
+ const handler = await importHandler(
+ namespaced("roundRobinReassign"),
+ () => import("./roundRobin/roundRobinReassign.handler")
+ );
+ return handler(opts);
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts
new file mode 100644
index 0000000000..3332fb6337
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts
@@ -0,0 +1,29 @@
+import { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment";
+import { BookingRepository } from "@calcom/lib/server/repository/booking";
+import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TRoundRobinReassignInputSchema } from "./roundRobinReassign.schema";
+
+type RoundRobinReassignOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TRoundRobinReassignInputSchema;
+};
+
+export const roundRobinReassignHandler = async ({ ctx, input }: RoundRobinReassignOptions) => {
+ const { bookingId } = input;
+
+ // Check if user has access to change booking
+ const isAllowed = await BookingRepository.doesUserIdHaveAccessToBooking({ userId: ctx.user.id, bookingId });
+
+ if (!isAllowed) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });
+ }
+
+ await roundRobinReassignment({ bookingId });
+};
+
+export default roundRobinReassignHandler;
diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts
new file mode 100644
index 0000000000..3894f90b09
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const ZRoundRobinReassignInputSchema = z.object({
+ teamId: z.number(),
+ bookingId: z.number(),
+});
+
+export type TRoundRobinReassignInputSchema = z.infer;
diff --git a/packages/ui/components/editable-heading/EditableHeading.tsx b/packages/ui/components/editable-heading/EditableHeading.tsx
index 3a8b6f24fa..b0dc1fd5c0 100644
--- a/packages/ui/components/editable-heading/EditableHeading.tsx
+++ b/packages/ui/components/editable-heading/EditableHeading.tsx
@@ -18,9 +18,7 @@ export const EditableHeading = function EditableHeading({
const [isEditing, setIsEditing] = useState(false);
const enableEditing = () => setIsEditing(!disabled);
return (
-