feat: round robin handover to specific host (#17215)

* frontend dialog

* handler + fe calling handler

* reafctors + start work on fix hosts tests

* restore rr

* update tests + fixing types

* use a getRRHostsToReasign handler + error handle + i18n

* typefixes

* assert type

* fix: adds missing translation

* Update roundRobinManualReassignment.ts

* Update roundRobinManualReassignment.ts

* Update apps/web/components/dialog/ReassignDialog.tsx

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update roundRobinManualReassignment.ts

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
sean-brydon
2024-10-21 19:52:09 -07:00
committed by GitHub
co-authored by Carina Wollendorfer Udit Takkar zomars
parent 93e93a9c7a
commit 6df994389b
13 changed files with 1104 additions and 79 deletions
+122 -12
View File
@@ -1,9 +1,24 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { classNames } from "@calcom/lib";
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";
import {
Button,
Form,
Label,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
showToast,
Select,
RadioGroup as RadioArea,
} from "@calcom/ui";
type ReassignDialog = {
isOpenDialog: boolean;
@@ -12,9 +27,44 @@ type ReassignDialog = {
bookingId: number;
};
type FormValues = {
reassignType: "round_robin" | "team_member";
teamMemberId?: number;
};
export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [animationParentRef] = useAutoAnimate<HTMLFormElement>({
duration: 150,
easing: "ease-in-out",
});
// Were using legacy list members here because we don't currently have an easy way to paginate a select via infinite scroll
const teamMembers = trpc.viewer.teams.getRoundRobinHostsToReassign.useQuery({
bookingId,
exclude: "fixedHosts",
});
const teamMemberOptions = useMemo(() => {
if (teamMembers.isLoading)
return [
{
label: "Loading...",
value: 0,
},
];
return teamMembers.data?.map((member) => ({
label: member.name,
value: member.id,
}));
}, [teamMembers]);
const form = useForm<FormValues>({
defaultValues: {
reassignType: "round_robin",
},
});
const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
onSuccess: async () => {
@@ -31,6 +81,33 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
},
});
const roundRobinManualReassignMutation = trpc.viewer.teams.roundRobinManualReassign.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(error.message), "error");
}
},
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === "round_robin") {
roundRobinReassignMutation.mutate({ teamId, bookingId });
} else {
if (values.teamMemberId) {
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
}
}
};
const watchedReassignType = form.watch("reassignType");
return (
<Dialog
open={isOpenDialog}
@@ -39,17 +116,50 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
}}>
<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>
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className={classNames("mt-1 flex flex-col gap-4")}>
<RadioArea.Item
value="round_robin"
className={classNames("w-full text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="team_member"
className={classNames("text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Select
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
options={teamMemberOptions}
onChange={(event) => {
form.setValue("teamMemberId", event?.value);
}}
/>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
@@ -2673,6 +2673,11 @@
"choose_hosts_schedule": "Choose host schedules",
"hosts_schedule_description": "You can select a different schedule for each host. Each host's default schedule is chosen by default.",
"no_hosts_description": "No hosts assigned, add hosts in assignment tab.",
"team_member_round_robin_reassign": "Manually reassign round robin host",
"team_member_round_robin_reassign_description": "Manually reassign round robin host to another available host",
"round_robin_reassign_description": "Reassign round robin host to another available host",
"invalid_round_robin_host": "New user is not a valid round-robin host for this event type",
"user_is_round_robin_fixed": "New user is a fixed host for this event type",
"reschedule_with_same_timeslot": "Reschedule with same timeslot",
"reschedule_with_same_timeslot_of_new_event": "Reschedule with same timeslot of the new event",
"reschedule_to_the_new_event_with_different_timeslot": "Reschedule to the new event with different timeslot",
@@ -2680,4 +2685,4 @@
"we_dont_have_id_for_the_new_event": "We don't have ID for the new event. Please go to the Routing Form and save it again.",
"rerouted_booking_successfully_redirecting_to_booking_page": "Rerouted booking successfully. Redirecting to booking page",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}
@@ -0,0 +1,250 @@
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("roundRobinManualReassignment test", () => {
setupAndTeardown();
test("manually reassign round robin organizer", async ({ emails }) => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).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];
const { dateString: dateStringPlusOne } = 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) => ({ id: user.id })),
hosts: users.map((user) => ({ 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",
}),
],
},
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await addWorkflowReminders([
{
bookingUid: bookingToReassignUid,
method: WorkflowMethods.EMAIL,
scheduledDate: dateStringPlusTwo,
scheduled: true,
workflowStepId: 1,
workflowId: 1,
},
]);
await roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
});
expect(eventManagerSpy).toBeCalledTimes(1);
expect(eventManagerSpy).toHaveBeenCalledWith(
expect.any(Object),
bookingToReassignUid,
undefined,
true,
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
);
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: newHost.id,
});
expectSuccessfulRoundRobinReschedulingEmails({
prevOrganizer: originalHost,
newOrganizer: newHost,
emails,
});
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
});
test("Manually reassign round robin host with fixed host as organizer", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).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];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
workflows: [
{
userId: fixedHost.id,
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
},
],
eventTypes: [
{
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({
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 roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
});
expect(eventManagerSpy).toBeCalledTimes(1);
// Ensure organizer stays the same
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: fixedHost.id,
});
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,433 @@
// 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 { sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinScheduledEmailsAndSMS } from "@calcom/emails";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
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 { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
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 { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { BookingSelectResult } from "./utils/bookingSelect";
import { bookingSelect } from "./utils/bookingSelect";
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
import { getTeamMembers } from "./utils/getTeamMembers";
enum ErrorCode {
InvalidRoundRobinHost = "invalid_round_robin_host",
UserIsFixed = "user_is_round_robin_fixed",
}
export const roundRobinManualReassignment = async ({
bookingId,
newUserId,
orgId,
}: {
bookingId: number;
newUserId: number;
orgId: number | null;
}) => {
const roundRobinReassignLogger = logger.getSubLogger({
prefix: ["roundRobinManualReassign", `${bookingId}`],
});
let booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: bookingSelect,
});
if (!booking || !booking.user) {
roundRobinReassignLogger.error(`Booking ${bookingId} not found or has no associated user`);
throw new Error("Booking not found or has no associated user");
}
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");
}
const eventTypeHosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
user,
isFixed: false,
priority: 2,
weight: 100,
weightAdjustment: 0,
schedule: null,
}));
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
const currentRRHost = booking.attendees.find((attendee) =>
eventTypeHosts.some((host) => !host.isFixed && host.user.email === attendee.email)
);
const newUserHost = eventTypeHosts.find((host) => host.user.id === newUserId);
if (!newUserHost) {
throw new Error(ErrorCode.InvalidRoundRobinHost);
}
if (newUserHost.isFixed) {
throw new Error(ErrorCode.UserIsFixed);
}
const originalOrganizer = booking.user;
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
const newUser = newUserHost.user;
const newUserT = await getTranslation(newUser.locale || "en", "common");
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
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 newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
const defaultLocationUrl = newUserMetadataSafeParse.success
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
: undefined;
const currentBookingLocation = booking.location || "integrations:daily";
bookingLocation =
defaultLocationUrl ||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
}
const newBookingTitle = getEventName({
attendeeName: responses?.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
teamName: eventType.team?.name,
host: newUser.name || "Nameless",
location: bookingLocation || "integrations:daily",
bookingFields: { ...responses },
eventDuration: eventType.length,
t: newUserT,
});
booking = await prisma.booking.update({
where: { id: bookingId },
data: {
userId: newUserId,
title: newBookingTitle,
userPrimaryEmail: newUser.email,
},
select: bookingSelect,
});
} else if (currentRRHost) {
// Update the round-robin host attendee
await prisma.attendee.update({
where: { id: currentRRHost.id },
data: {
name: newUser.name || "",
email: newUser.email,
timeZone: newUser.timeZone,
locale: newUser.locale,
},
});
}
const destinationCalendar = await getDestinationCalendar({
eventType,
booking,
newUserId,
hasOrganizerChanged,
});
const teamMembers = await getTeamMembers({
eventTypeHosts,
attendees: booking.attendees,
organizer: newUser,
previousHost: originalOrganizer,
reassignedHost: newUser,
});
const attendeePromises = booking.attendees.map(async (attendee) => ({
email: attendee.email,
name: attendee.name,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
phoneNumber: attendee.phoneNumber || undefined,
}));
const attendeeList = await Promise.all(attendeePromises);
const evt: CalendarEvent = {
type: eventType.slug,
title: booking.title,
description: eventType.description,
startTime: dayjs(booking.startTime).utc().format(),
endTime: dayjs(booking.endTime).utc().format(),
organizer: {
email: newUser.email,
name: newUser.name || "",
timeZone: newUser.timeZone,
language: { translate: newUserT, locale: newUser.locale || "en" },
},
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,
}),
cancellationReason: "Manually re-assigned",
};
const credentials = await prisma.credential.findMany({
where: { userId: newUser.id },
include: { user: { select: { email: true } } },
});
const eventManager = new EventManager({ ...newUser, credentials });
const previousHostDestinationCalendar = hasOrganizerChanged
? await prisma.destinationCalendar.findFirst({
where: { userId: originalOrganizer.id },
})
: null;
const results = await eventManager.reschedule(
evt,
booking.uid,
undefined,
hasOrganizerChanged,
previousHostDestinationCalendar ? [previousHostDestinationCalendar] : []
);
const newReferencesToCreate = structuredClone(results.referencesToCreate);
await BookingReferenceRepository.replaceBookingReferences({
bookingId,
newReferencesToCreate,
});
// Send emails
await sendRoundRobinScheduledEmailsAndSMS(evt, [
{
...newUser,
name: newUser.name || "",
username: newUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale || "en" },
},
]);
// Send cancellation email to original organizer
const cancelledEvt = cloneDeep(evt);
cancelledEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate: originalOrganizerT, locale: originalOrganizer.locale || "en" },
};
await sendRoundRobinCancelledEmailsAndSMS(
cancelledEvt,
[
{
...originalOrganizer,
name: originalOrganizer.name || "",
username: originalOrganizer.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(originalOrganizer.timeFormat),
language: { translate: originalOrganizerT, locale: originalOrganizer.locale || "en" },
},
],
eventType?.metadata as EventTypeMetadata
);
if (hasOrganizerChanged) {
// Handle changing workflows with organizer
await handleWorkflowsUpdate({
booking,
newUser,
evt,
eventType,
orgId,
});
}
return booking;
};
async function handleWorkflowsUpdate({
booking,
newUser,
evt,
eventType,
orgId,
}: {
booking: BookingSelectResult;
newUser: {
id: number;
email: string;
locale?: string | null;
};
evt: CalendarEvent;
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
orgId: number | null;
}) {
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,
},
},
},
},
},
});
const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evt) };
const bookerUrl = await getBookerBaseUrl(orgId);
for (const workflowReminder of workflowReminders) {
const workflowStep = workflowReminder?.workflowStep;
const workflow = workflowStep?.workflow;
if (workflowStep && workflow) {
await scheduleEmailReminder({
evt: {
...evt,
metadata: workflowEventMetadata,
eventType,
bookerUrl,
},
action: WorkflowActions.EMAIL_HOST,
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo: newUser.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: eventType.id,
},
},
},
...(eventType?.teamId
? [
{
activeOnTeams: {
some: {
teamId: eventType.teamId,
},
},
},
]
: []),
...(eventType?.team?.parentId
? [
{
isActiveOnAll: true,
teamId: eventType.team.parentId,
},
]
: []),
],
},
include: {
steps: {
where: {
action: WorkflowActions.EMAIL_HOST,
},
},
},
});
await scheduleWorkflowReminders({
workflows: newEventWorkflows,
smsReminderNumber: null,
calendarEvent: {
...evt,
metadata: workflowEventMetadata,
eventType: { slug: eventType.slug },
bookerUrl,
},
hideBranding: !!eventType?.owner?.hideBranding,
});
}
export default roundRobinManualReassignment;
@@ -30,26 +30,9 @@ import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata } 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,
};
import { bookingSelect } from "./utils/bookingSelect";
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
import { getTeamMembers } from "./utils/getTeamMembers";
export const roundRobinReassignment = async ({
bookingId,
@@ -70,26 +53,26 @@ export const roundRobinReassignment = async ({
});
if (!booking) {
roundRobinReassignLogger.error(`Booking ${bookingId} not found`);
logger.error(`Booking ${bookingId} not found`);
throw new Error("Booking not found");
}
if (!booking?.user) {
roundRobinReassignLogger.error(`No user associated with booking ${bookingId}`);
if (!booking.user) {
logger.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`);
logger.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`);
logger.error(`Event type ${eventTypeId} not found`);
throw new Error("Event type not found");
}
@@ -145,7 +128,7 @@ export const roundRobinReassignment = async ({
const reassignedRRHost = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers,
eventType: {
id: eventTypeId,
id: eventType.id,
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
},
allRRHosts: eventType.hosts.filter((host) => !host.isFixed),
@@ -160,29 +143,14 @@ export const roundRobinReassignment = async ({
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;
}
const teamMembers = await getTeamMembers({
eventTypeHosts: eventType.hosts,
attendees: booking.attendees,
organizer: organizer,
previousHost: previousRRHost || null,
reassignedHost: reassignedRRHost,
});
// 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({
@@ -288,24 +256,12 @@ export const roundRobinReassignment = async ({
});
}
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];
}
})();
const destinationCalendar = await getDestinationCalendar({
eventType,
booking,
newUserId: reassignedRRHost.id,
hasOrganizerChanged,
});
// If changed owner, also change destination calendar
const previousHostDestinationCalendar = hasOrganizerChanged
@@ -0,0 +1,26 @@
import type { Prisma } from "@calcom/prisma/client";
export 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 type BookingSelectResult = Prisma.BookingGetPayload<{
select: typeof bookingSelect;
}>;
@@ -0,0 +1,36 @@
import type { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { prisma } from "@calcom/prisma";
import type { DestinationCalendar } from "@calcom/prisma/client";
import type { BookingSelectResult } from "./bookingSelect";
export async function getDestinationCalendar({
eventType,
booking,
newUserId,
hasOrganizerChanged,
}: {
eventType?: Awaited<ReturnType<typeof getEventTypesFromDB>>;
booking?: BookingSelectResult;
newUserId?: number;
hasOrganizerChanged: boolean;
}): Promise<DestinationCalendar[] | undefined> {
if (eventType?.destinationCalendar) {
return [eventType.destinationCalendar];
}
if (hasOrganizerChanged && newUserId) {
const newUserDestinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: newUserId,
},
});
if (newUserDestinationCalendar) {
return [newUserDestinationCalendar];
}
} else {
if (booking?.user?.destinationCalendar) return [booking.user.destinationCalendar];
}
return undefined;
}
@@ -0,0 +1,79 @@
import type { getEventTypeResponse } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { BookingSelectResult } from "./bookingSelect";
type Attendee = {
name: string;
id: number;
email: string;
timeZone: string;
locale: string | null;
bookingId: number | null;
phoneNumber: string | null;
noShow: boolean | null;
};
// TODO: We have far too many differnt types here. Theyre all users or hosts at the end of the day.
type OrganizerType =
| getEventTypeResponse["hosts"][number]["user"]
| IsFixedAwareUser
| {
id: number;
email: string;
name: string | null;
locale: string | null;
timeZone: string;
username: string | null;
};
export async function getTeamMembers({
eventTypeHosts,
attendees,
organizer,
previousHost,
reassignedHost,
}: {
eventTypeHosts: getEventTypeResponse["hosts"];
attendees: Attendee[];
organizer: OrganizerType;
previousHost: BookingSelectResult["user"] | getEventTypeResponse["hosts"][number]["user"] | null;
reassignedHost: getEventTypeResponse["hosts"][number]["user"];
}) {
const teamMemberPromises = eventTypeHosts
.filter((host) => {
const user = host.user;
return (
user.email !== previousHost?.email &&
user.email !== organizer.email &&
attendees.some((attendee) => attendee.email === user.email)
);
})
.map(async (host) => {
const user = host.user;
const tTeamMember = await getTranslation(user.locale ?? "en", "common");
return {
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);
if (reassignedHost.email !== organizer.email) {
const tReassignedHost = await getTranslation(reassignedHost.locale ?? "en", "common");
teamMembers.push({
id: reassignedHost.id,
email: reassignedHost.email,
name: reassignedHost.name || "",
timeZone: reassignedHost.timeZone,
language: { translate: tReassignedHost, locale: reassignedHost.locale ?? "en" },
});
}
return teamMembers;
}
@@ -23,6 +23,8 @@ import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZResendInvitationInputSchema } from "./resendInvitation.schema";
import { ZGetRoundRobinHostsToReassignInputSchema } from "./roundRobin/getRoundRobinHostsToReasign.schema";
import { ZRoundRobinManualReassignInputSchema } from "./roundRobin/roundRobinManualReassign.schema";
import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -181,6 +183,24 @@ export const viewerTeamsRouter = router({
);
return handler(opts);
}),
roundRobinManualReassign: authedProcedure
.input(ZRoundRobinManualReassignInputSchema)
.mutation(async (opts) => {
const handler = await importHandler(
namespaced("roundRobinManualReassign"),
() => import("./roundRobin/roundRobinManualReassign.handler")
);
return handler(opts);
}),
getRoundRobinHostsToReassign: authedProcedure
.input(ZGetRoundRobinHostsToReassignInputSchema)
.query(async (opts) => {
const handler = await importHandler(
namespaced("getRoundRobinHostsToReassign"),
() => import("./roundRobin/getRoundRobinHostsToReasign.handler")
);
return handler(opts);
}),
checkIfMembershipExists: authedProcedure
.input(ZCheckIfMembershipExistsInputSchema)
.mutation(async (opts) => {
@@ -0,0 +1,61 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TGetRoundRobinHostsToReassignInputSchema } from "./getRoundRobinHostsToReasign.schema";
type GetRoundRobinHostsToReassignOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetRoundRobinHostsToReassignInputSchema;
};
export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobinHostsToReassignOptions) => {
const { prisma } = ctx;
const { isOrgAdmin } = ctx.user.organization;
const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
if (!hasPermsToView) {
return [];
}
const booking = await prisma.booking.findUniqueOrThrow({
where: { id: input.bookingId },
select: {
eventType: {
select: {
id: true,
},
},
},
});
if (!booking?.eventType) {
throw new Error("Booking not found");
}
// If input.exclude is "fixedHosts", exclude fixed hosts from the list
const excludeFixedHosts = input.exclude === "fixedHosts";
const eventTypeHosts = await prisma.host.findMany({
where: {
eventTypeId: booking.eventType.id,
isFixed: excludeFixedHosts ? false : undefined,
},
select: {
user: {
select: {
id: true,
name: true,
},
},
},
});
const hosts = eventTypeHosts.map((host) => host.user);
return hosts;
};
export default getRoundRobinHostsToReassign;
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZGetRoundRobinHostsToReassignInputSchema = z.object({
bookingId: z.number(),
exclude: z.enum(["fixedHosts"]).optional(),
});
export type TGetRoundRobinHostsToReassignInputSchema = z.infer<
typeof ZGetRoundRobinHostsToReassignInputSchema
>;
@@ -0,0 +1,31 @@
import { roundRobinManualReassignment } from "@calcom/features/ee/round-robin/roundRobinManualReassignment";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TRoundRobinManualReassignInputSchema } from "./roundRobinManualReassign.schema";
type RoundRobinManualReassignOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TRoundRobinManualReassignInputSchema;
};
export const roundRobinManualReassignHandler = async ({ ctx, input }: RoundRobinManualReassignOptions) => {
const { bookingId, teamMemberId } = 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 roundRobinManualReassignment({ bookingId, newUserId: teamMemberId, orgId: ctx.user.organizationId });
return { success: true };
};
export default roundRobinManualReassignHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZRoundRobinManualReassignInputSchema = z.object({
bookingId: z.number(),
teamMemberId: z.number(),
});
export type TRoundRobinManualReassignInputSchema = z.infer<typeof ZRoundRobinManualReassignInputSchema>;