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-YeungandCarinaWolli authored and GitHub committed 2024-08-06 13:42:46 +02:00
1 parent 3aca72fb06
commit 8fa39f2ae5
21 files changed
+1154 -64

No files matched your search

@@ -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],