feat: Round robin reassignment via round robin algorithm [CAL-3138] (#14308)

* add reassign host to edit dropdown

* add dialog to reassign rr host

* Design improvements

* add translations

* only show reassign for admin/owner

* add first version of email template

* Init rr assign endpoint

* Pass booking information to rr assign endpoint

* Only allow attendee of booking to reassign themselves

* Send email to reassigned and cancelled member

* On reassign change calendar events

* Add workflows for new host

* On reassign new host - rr

* Fix icon

* Abstract reassignment logic

* Update calendar invite

* Add tests

* Clean up

* Merge with `main`

* Type fixes

* Add back dialog and handle no available hosts

* Handle if rr host is not organizer

* Fix calendar invites when organizer doesn't change

* Clean up

* Clean up

* Type fixes

* Type fixes

* Type fixes

* Type fixes

* Type fix

* Type fixes

* Type fixes

* Add custom responses to evt object

* Type fixes

* Type fix

* Type fix

* Type fixes

* Type fixes

* Type fix

* Type fix

* Update tests

* Type fixes

* Fix tests

* Fix tests

* Add booking repository

* Fix tests

* Fix tests

* Add doesUserIdHaveAccessToBooking for user

* Add check if user is team admin

* Check user permission tRPC route

* Type fixes

* Correct Promise.all

* UI fixes

* UI fixes

* Remove unused assigned hosts prop

* Type fix

* Remove unused frontend code

* Include user priority

* Fallback to event type users for older event types

* Get booking workflow reminder

* Revert back to eventType.hosts

* Fix lint

* Handle changing workflows

* Type fixes

* Type fixes

* Type fix

* Fix tests

* Update new booking imports

* Fix imports

* Type fix

* Type fix

* Fix adding all members to reassignment emails

* Fix cancelled RR emails to show old host

* Ensure consistent event titles

* Send new event workflows to new host

* Change event name if organizer changed

* Fix query error

* Delete old booking reference when reassigning

* Type fixes

* Type fixes

* Fix test

* Rename func isAdminOfTeamOrParentOrg

* Select specific workflow fields

* Address workflow feedback

* Delete const

* Address feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Joe Au-Yeung
2024-08-06 13:42:46 +02:00
committed by GitHub
co-authored by CarinaWolli
parent 3aca72fb06
commit 8fa39f2ae5
21 changed files with 1154 additions and 64 deletions
@@ -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({
+49 -28
View File
@@ -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}
/>
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
@@ -0,0 +1,56 @@
import type { Dispatch, SetStateAction } from "react";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
type ReassignDialog = {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
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 (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent title={t("reassign_round_robin_host")} description={t("reassign_to_another_rr_host")}>
{/* TODO add team member reassignment*/}
<DialogFooter>
<DialogClose />
<Button
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending}
onClick={() => {
roundRobinReassignMutation.mutate({ teamId, bookingId });
}}>
{t("reassign")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+1
View File
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
bookingFields: true,
disableGuests: true,
timeZone: true,
teamId: true,
owner: {
select: userSelect,
},
@@ -33,6 +33,7 @@ function mockedSuccessComponentProps(props: Partial<React.ComponentProps<typeof
currency: "usd",
successRedirectUrl: null,
customInputs: [],
teamId: null,
team: null,
workflows: [],
hosts: [],
@@ -2504,6 +2504,13 @@
"USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization",
"USER_ALREADY_INVITED_OR_MEMBER": "User is already invited or a member",
"USER_MEMBER_OF_OTHER_ORGANIZATION": "User is member of an organization that this team is not a part of.",
"booking_reassigned": "Booking has been reassigned",
"reassign": "Reassign",
"reassign_to_another_rr_host": "Reassign booking to another available round robin host",
"assign_team_member": "Assign team member",
"override_team_member_to_assign": "Override which team member you want to assign to.",
"no_available_hosts": "No available hosts",
"reassign_round_robin_host": "Reassign round robin host",
"skip_writing_to_calendar": "Do not write to the ICS feed",
"rescheduling_not_possible": "Rescheduling is not possible as the event has expired",
"event_expired": "This event is expired",
@@ -17,7 +17,12 @@ import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
import type {
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
WorkflowMethods,
} from "@calcom/prisma/client";
import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -43,6 +48,7 @@ type InputWebhook = {
};
type InputWorkflow = {
id?: number;
userId?: number | null;
teamId?: number | null;
name?: string;
@@ -56,6 +62,16 @@ type InputWorkflow = {
sendTo?: string;
};
type InputWorkflowReminder = {
id?: number;
bookingUid: string;
method: WorkflowMethods;
scheduledDate: Date;
scheduled: boolean;
workflowStepId?: number;
workflowId: number;
};
type InputHost = {
userId: number;
isFixed?: boolean;
@@ -213,6 +229,8 @@ export async function addEventTypesToDb(
users?: any[];
userId?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hosts?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
destinationCalendar?: any;
@@ -305,11 +323,17 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
eventType.users?.map((userWithJustId) => {
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<InputUser["teams"]>[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],
+3 -5
View File
@@ -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"
}
}
+3
View File
@@ -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: {
@@ -24,6 +24,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
},
slug: true,
teamId: true,
team: {
select: {
id: true,
@@ -55,7 +55,7 @@ export interface IEventTypePaymentCredentialType {
export type IsFixedAwareUser = User & {
isFixed: boolean;
credentials: CredentialPayload[];
organization: { slug: string };
organization?: { slug: string };
priority?: number;
};
@@ -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);
});
});
@@ -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;
+43 -1
View File
@@ -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: {
@@ -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<Prisma.BookingReferenceSelect>()({
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 };
}),
});
}
}
+26
View File
@@ -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<number[]> {
const user = await prisma.user.findFirst({
where: {
@@ -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,
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
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;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZRoundRobinReassignInputSchema = z.object({
teamId: z.number(),
bookingId: z.number(),
});
export type TRoundRobinReassignInputSchema = z.infer<typeof ZRoundRobinReassignInputSchema>;
@@ -18,9 +18,7 @@ export const EditableHeading = function EditableHeading({
const [isEditing, setIsEditing] = useState(false);
const enableEditing = () => setIsEditing(!disabled);
return (
<div
className="group pointer-events-auto relative truncate"
onClick={enableEditing}>
<div className="group pointer-events-auto relative truncate" onClick={enableEditing}>
<div className={classNames(!disabled && "cursor-pointer", "flex items-center")}>
<label className="min-w-8 relative inline-block">
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value}&nbsp;</span>