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:
co-authored by
Carina Wollendorfer
Udit Takkar
zomars
parent
93e93a9c7a
commit
6df994389b
@@ -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) => {
|
||||
|
||||
+61
@@ -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;
|
||||
+10
@@ -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
|
||||
>;
|
||||
+31
@@ -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;
|
||||
+8
@@ -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>;
|
||||
Reference in New Issue
Block a user