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:
1 parent
3aca72fb06
commit
8fa39f2ae5
21 files changed
+1154
-64
No files matched your search
-1
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user