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>