feat: managed event reassignment (#24809)

* init

* --

* update dialog

* reassignment

* further changes

* add unit test

* fix

* type fix??

* fix type??

* emails

* workflows

* reason recorder

* refactor reassigned email

* fix reassigned reason

* fix type

* improve dialog

* add integration tests

* -

* remove unnecessary comments --1

* removed unnecessary comments

* fix type?

* address cubic

* address feedback

* --

* fix type?

* address cubic

* type-fix?

* fix type

* further fixes

* fix location update

* type fix

* propagate error to top

* fix mocking

* fix reported bugs

* fix

* fix success page video URL

* remove PII from logs

* persist video URL

* better audit trail

* revert email function name change

* fix test

* fix flake

* di

* fixes

* extract logic and other repo access from BookingRepository

* fixes

* (☞゚∀゚)☞ udit

* integration test fixes

* mroe fixes

* extract to repo

* extract to repo --2

* fix type

* cubic

* address feedback --1

* --2

* wip

* addressed feedback

* type fixes

* type fixes

* fix issues

* feedback --1

* feedback --2

* BookingAccessService DI

* fix

* break down function into small functions

* fixes --1

* fixes

* typefix

* addressing feedback

* fix merge conflict lost change
This commit is contained in:
Syed Ali Shahbaz authored and GitHub committed 2025-12-01 14:04:24 +04:00
1 parent 38890dad30
commit 0103321b71
54 files changed
+4259 -443

No files matched your search

+6
View File
@@ -113,6 +113,12 @@ export const schemaBookingReadPublic = Booking.extend({
)
.optional(),
responses: z.record(z.any()).nullable(),
// Override metadata to handle reassignment objects from Round Robin/Managed Events
// Safe to use z.any() here because:
// 1. API v1 POST only accepts z.record(z.string()) for metadata (user input restricted)
// 2. API v1 PATCH does not accept metadata changes at all
// 3. Complex metadata (objects) are only set by trusted internal features
metadata: z.record(z.any()).nullable(),
}).pick({
id: true,
userId: true,
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "@calcom/prisma";
@@ -11,69 +11,114 @@ type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("PATCH /api/bookings", () => {
let member1Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
let member0Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
const createdBookingIds: number[] = [];
let testAdminUserId: number | null = null;
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
const member1 = await prisma.user.findFirstOrThrow({
where: { email: "member1-acme@example.com" },
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
const member0 = await prisma.user.findFirstOrThrow({
where: { email: "member0-acme@example.com" },
});
// Create bookings for testing
member1Booking = await prisma.booking.create({
data: {
uid: `test-member1-booking-${Date.now()}`,
title: "Member 1 Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: member1.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member1Booking.id);
member0Booking = await prisma.booking.create({
data: {
uid: `test-member0-booking-${Date.now()}`,
title: "Member 0 Test Booking",
startTime: new Date(Date.now() + 172800000), // Day after tomorrow
endTime: new Date(Date.now() + 176400000), // Day after tomorrow + 1 hour
userId: member0.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member0Booking.id);
});
afterAll(async () => {
if (createdBookingIds.length > 0) {
await prisma.booking.deleteMany({
where: { id: { in: createdBookingIds } },
});
}
// Clean up test admin user if created
if (testAdminUserId) {
await prisma.user.delete({
where: { id: testAdminUserId },
});
}
});
it("Returns 403 when user has no permission to the booking", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Member2 tries to access Member0's booking - should fail
const member2 = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member2.id,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});
req.userId = memberUser.id;
req.userId = member2.id;
await handler(req, res);
expect(res.statusCode).toBe(403);
});
it("Allows PATCH when user is system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Check if admin user already exists before upserting
const existingAdmin = await prisma.user.findUnique({ where: { email: "test-admin@example.com" } });
// Create a system-wide admin user for this test
const adminUser = await prisma.user.upsert({
where: { email: "test-admin@example.com" },
update: { role: "ADMIN" },
create: {
email: "test-admin@example.com",
username: "test-admin",
name: "Test Admin",
role: "ADMIN",
},
});
// Only track for cleanup if we created it (not if it already existed)
if (!existingAdmin) {
testAdminUserId = adminUser.id;
}
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: proUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member0Booking.userId,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});
@@ -86,19 +131,17 @@ describe("PATCH /api/bookings", () => {
it("Allows PATCH when user is org-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member1Booking.title,
startTime: member1Booking.startTime.toISOString(),
endTime: member1Booking.endTime.toISOString(),
userId: member1Booking.userId,
},
query: {
id: booking.id,
id: member1Booking.id,
},
});
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it, beforeAll } from "vitest";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { ZodError } from "zod";
import { prisma } from "@calcom/prisma";
@@ -16,51 +16,67 @@ const DefaultPagination = {
skip: 0,
};
describe("GET /api/bookings", async () => {
describe("GET /api/bookings", () => {
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
let memberUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let memberUserBooking: Awaited<ReturnType<typeof prisma.booking.create>>;
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
// Find an event type for memberUser or use a simple booking
const memberEventType = await prisma.eventType.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
OR: [
{ userId: memberUser.id },
{ team: { members: { some: { userId: memberUser.id } } } }
]
}
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
memberUserBooking = await prisma.booking.create({
data: {
uid: `test-member-booking-${Date.now()}`,
title: "Member Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: memberUser.id,
eventTypeId: memberEventType?.id,
status: "ACCEPTED",
},
});
});
afterAll(async () => {
// Clean up the test booking created in beforeAll
if (memberUserBooking?.id) {
await prisma.booking.delete({
where: { id: memberUserBooking.id },
});
}
});
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
it("Does not return bookings of other users when user has no permission", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: proUser.id,
userId: proUser.id, // Try to access proUser's bookings
},
pagination: DefaultPagination,
});
req.userId = memberUser.id;
req.userId = memberUser.id; // But request is from memberUser
const responseData = await handler(req);
const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
// Should only return memberUser's own bookings, not proUser's
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(responseData.bookings.find((b) => b.id === memberUserBooking.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
@@ -427,6 +427,7 @@ export function BookingActionsDropdown({
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
isManagedEvent={booking.eventType?.parentId != null}
/>
)}
<EditLocationDialog
@@ -109,6 +109,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
} = context;
const seatReferenceUid = getSeatReferenceUid();
const isReassignableRoundRobin =
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups.length <= 1);
const isManagedChildEvent = booking.eventType.parentId != null;
const isReassignable = isReassignableRoundRobin || isManagedChildEvent;
const actions: (ActionType | null)[] = [
{
id: "reschedule",
@@ -154,9 +160,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
isReassignable
? {
id: "reassign",
label: t("reassign"),
+137 -46
View File
@@ -25,7 +25,7 @@ import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { showToast } from "@calcom/ui/components/toast";
enum ReassignType {
ROUND_ROBIN = "round_robin",
AUTO = "auto",
TEAM_MEMBER = "team_member",
}
@@ -35,10 +35,11 @@ type ReassignDialog = {
teamId: number;
bookingId: number;
bookingFromRoutingForm: boolean;
isManagedEvent: boolean;
};
type FormValues = {
reassignType: ReassignType.ROUND_ROBIN | ReassignType.TEAM_MEMBER;
reassignType: ReassignType.AUTO | ReassignType.TEAM_MEMBER;
teamMemberId?: number;
reassignReason?: string;
};
@@ -66,6 +67,7 @@ export const ReassignDialog = ({
teamId,
bookingId,
bookingFromRoutingForm,
isManagedEvent,
}: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -76,18 +78,34 @@ export const ReassignDialog = ({
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 500);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const managedEventQuery = trpc.viewer.teams.getManagedEventUsersToReassign.useInfiniteQuery(
{
bookingId,
limit: 10,
searchTerm: debouncedSearch,
},
{
enabled: isManagedEvent,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const roundRobinQuery = trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
enabled: !isManagedEvent,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = isManagedEvent
? managedEventQuery
: roundRobinQuery;
const allRows = useMemo(() => {
return data?.pages.flatMap((page) => page.items) ?? [];
@@ -110,7 +128,7 @@ export const ReassignDialog = ({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
reassignType: bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.ROUND_ROBIN,
reassignType: bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.AUTO,
},
});
@@ -124,7 +142,22 @@ export const ReassignDialog = ({
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
showToast(t("no_available_hosts"), "error");
} else {
showToast(t("unexpected_error_try_again"), "error");
showToast(t(error.message), "error");
}
},
});
const managedEventReassignMutation = trpc.viewer.teams.managedEventReassign.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");
}
},
});
@@ -144,6 +177,21 @@ export const ReassignDialog = ({
},
});
const managedEventManualReassignMutation = trpc.viewer.teams.managedEventManualReassign.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 [confirmationModal, setConfirmationModal] = useState<{
show: boolean;
membersStatus: "unavailable" | "available" | null;
@@ -153,8 +201,12 @@ export const ReassignDialog = ({
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === ReassignType.ROUND_ROBIN) {
roundRobinReassignMutation.mutate({ teamId, bookingId });
if (values.reassignType === ReassignType.AUTO) {
if (isManagedEvent) {
managedEventReassignMutation.mutate({ bookingId });
} else {
roundRobinReassignMutation.mutate({ teamId, bookingId });
}
} else {
if (values.teamMemberId) {
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
@@ -179,8 +231,8 @@ export const ReassignDialog = ({
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
title={isManagedEvent ? t("reassign_booking") : t("reassign_round_robin_host")}
description={isManagedEvent ? t("reassign_to_another_user") : t("reassign_to_another_rr_host")}
enableOverflow>
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
@@ -188,24 +240,34 @@ export const ReassignDialog = ({
const reassignType: ReassignType = z.nativeEnum(ReassignType).parse(val);
form.setValue("reassignType", reassignType);
}}
defaultValue={bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.ROUND_ROBIN}
defaultValue={bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.AUTO}
className="mt-1 flex flex-col gap-4">
{!bookingFromRoutingForm ? (
<RadioArea.Item
value={ReassignType.ROUND_ROBIN}
value={ReassignType.AUTO}
className="w-full text-sm"
classNames={{ container: "w-full" }}
disabled={bookingFromRoutingForm}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
disabled={bookingFromRoutingForm}
data-testid="reassign-option-auto">
<strong className="mb-1 block">
{isManagedEvent ? t("auto_reassign") : t("round_robin")}
</strong>
<p>{isManagedEvent ? t("auto_reassign_description") : t("round_robin_reassign_description")}</p>
</RadioArea.Item>
) : null}
<RadioArea.Item
value={ReassignType.TEAM_MEMBER}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
classNames={{ container: "w-full" }}
data-testid="reassign-option-specific">
<strong className="mb-1 block">
{isManagedEvent ? t("specific_team_member") : t("team_member_round_robin_reassign")}
</strong>
<p>
{isManagedEvent
? t("specific_team_member_description")
: t("team_member_round_robin_reassign_description")}
</p>
</RadioArea.Item>
</RadioArea.Group>
@@ -217,9 +279,22 @@ export const ReassignDialog = ({
type="text"
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="reassign-user-search"
/>
<div className="scroll-bar mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
{isFetching && teamMemberOptions.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Icon name="loader" className="text-subtle h-5 w-5 animate-spin" />
<p className="text-subtle text-sm">{t("loading")}</p>
</div>
</div>
) : teamMemberOptions.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-subtle text-sm">{t("no_available_users_found_input")}</p>
</div>
) : (
teamMemberOptions.map((member) => (
<label
key={member.value}
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
@@ -232,7 +307,7 @@ export const ReassignDialog = ({
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
watchedTeamMemberId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
@@ -256,16 +331,19 @@ export const ReassignDialog = ({
)}
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
))
)}
{teamMemberOptions.length > 0 && (
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
)}
</div>
</div>
</div>
@@ -275,7 +353,12 @@ export const ReassignDialog = ({
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
loading={
roundRobinReassignMutation.isPending ||
roundRobinManualReassignMutation.isPending ||
managedEventReassignMutation.isPending ||
managedEventManualReassignMutation.isPending
}>
{t("reassign")}
</Button>
</DialogFooter>
@@ -300,11 +383,19 @@ export const ReassignDialog = ({
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
if (isManagedEvent) {
managedEventManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
} else {
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
}
setConfirmationModal({
show: false,
membersStatus: null,
@@ -73,6 +73,7 @@
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
"event_reassigned_subtitle": "You will no longer have the event on your calendar and your round robin likelihood will not be negatively impacted",
"event_reassigned_subtitle_generic": "You will no longer have the event on your calendar",
"organizer": "Organizer",
"reassigned_to": "Reassigned to",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
@@ -3039,6 +3040,12 @@
"booking_reassigned": "Booking has been reassigned",
"reassign": "Reassign",
"reassign_to_another_rr_host": "Reassign booking to another available round robin host",
"reassign_booking": "Reassign booking",
"reassign_to_another_user": "Reassign booking to another available team member",
"auto_reassign": "Auto Reassign",
"auto_reassign_description": "Automatically reassign to another available team member",
"specific_team_member": "Specific Team Member",
"specific_team_member_description": "Manually reassign to a specific team member",
"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",
@@ -3996,6 +4003,7 @@
"slots_taken": "{{takenSeats}}/{{totalSeats}} slots taken",
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
"no_available_users_found_input": "No available users found",
"repeats_num_times": "Repeats {{count}} times",
"view_booking_details": "View Booking Details",
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
+1 -1
View File
@@ -459,7 +459,7 @@ export function getSuccessPageLocationMessage(
const isConfirmed = bookingStatus === BookingStatus.ACCEPTED;
if (bookingStatus === BookingStatus.CANCELLED || bookingStatus === BookingStatus.REJECTED) {
locationToDisplay == t("web_conference");
locationToDisplay = t("web_conference");
} else if (isConfirmed) {
locationToDisplay = `${getHumanReadableLocationValue(location, t)}: ${t(
"meeting_url_in_confirmation_email"
+4 -3
View File
@@ -117,7 +117,8 @@ export const sendScheduledEmailsAndSMS = withReporting(
);
// for rescheduled round robin booking that assigned new members
export const sendRoundRobinScheduledEmailsAndSMS = async ({
// or for reassignment of a managed event
export const sendReassignedScheduledEmailsAndSMS = async ({
calEvent,
members,
eventTypeMetadata,
@@ -178,7 +179,7 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async (
await Promise.all(emailsAndSMSToSend);
};
export const sendRoundRobinUpdatedEmailsAndSMS = async ({
export const sendReassignedUpdatedEmailsAndSMS = async ({
calEvent,
eventTypeMetadata,
}: {
@@ -219,7 +220,7 @@ export const sendRoundRobinCancelledEmailsAndSMS = async (
await Promise.all(emailsAndSMSToSend);
};
export const sendRoundRobinReassignedEmailsAndSMS = async (args: {
export const sendReassignedEmailsAndSMS = async (args: {
calEvent: CalendarEvent;
members: Person[];
reassignedTo: { name: string | null; email: string };
@@ -1,9 +1,14 @@
import { SchedulingType } from "@calcom/prisma/enums";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerCancelledEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
const title = props.reassigned ? "event_request_reassigned" : "event_request_cancelled";
const subtitle = props.reassigned ? t("event_reassigned_subtitle") : "";
const isRoundRobin = props.calEvent.schedulingType === SchedulingType.ROUND_ROBIN;
const subtitle = props.reassigned
? (isRoundRobin ? t("event_reassigned_subtitle") : t("event_reassigned_subtitle_generic"))
: "";
const subject = props.reassigned ? "event_reassigned_subject" : "event_cancelled_subject";
return (
<OrganizerScheduledEmail
@@ -1,13 +1,18 @@
import { SchedulingType } from "@calcom/prisma/enums";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerReassignedEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
const isRoundRobin = props.calEvent.schedulingType === SchedulingType.ROUND_ROBIN;
const subtitle = isRoundRobin ? t("event_reassigned_subtitle") : t("event_reassigned_subtitle_generic");
return (
<OrganizerScheduledEmail
title="event_request_reassigned"
headerType="xCircle"
subject="event_reassigned_subject"
subtitle={<>{t("event_reassigned_subtitle")}</>}
subtitle={<>{subtitle}</>}
callToAction={null}
reassigned={props.reassigned}
{...props}
@@ -0,0 +1,67 @@
import logger from "@calcom/lib/logger";
import type { AssignmentReason, AssignmentReasonEnum } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["AssignmentReasonRepository"] });
export class AssignmentReasonRepository {
constructor(private prismaClient: PrismaClient) {}
/**
* Creates a new assignment reason record for a booking
* @param data - The assignment reason data
* @returns The created assignment reason
*/
async createAssignmentReason(data: {
bookingId: number;
reasonEnum: AssignmentReasonEnum;
reasonString: string;
}): Promise<AssignmentReason> {
log.debug("Creating assignment reason", { bookingId: data.bookingId, reasonEnum: data.reasonEnum });
return this.prismaClient.assignmentReason.create({
data: {
bookingId: data.bookingId,
reasonEnum: data.reasonEnum,
reasonString: data.reasonString,
},
});
}
/**
* Finds all assignment reasons for a booking
* @param bookingId - The booking ID
* @returns Array of assignment reasons
*/
async findByBookingId(bookingId: number): Promise<Omit<AssignmentReason, "id">[]> {
return this.prismaClient.assignmentReason.findMany({
where: { bookingId },
orderBy: { createdAt: "desc" },
select: {
createdAt: true,
bookingId: true,
reasonEnum: true,
reasonString: true,
},
});
}
/**
* Finds the latest assignment reason for a booking
* @param bookingId - The booking ID
* @returns The most recent assignment reason or null
*/
async findLatestByBookingId(bookingId: number): Promise<Omit<AssignmentReason, "id"> | null> {
return this.prismaClient.assignmentReason.findFirst({
where: { bookingId },
orderBy: { createdAt: "desc" },
select: {
createdAt: true,
bookingId: true,
reasonEnum: true,
reasonString: true,
},
});
}
}
@@ -226,7 +226,7 @@ export class BookingEmailSmsHandler {
const {
sendRoundRobinRescheduledEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendReassignedScheduledEmailsAndSMS,
sendRoundRobinCancelledEmailsAndSMS,
} = await import("@calcom/emails/email-manager");
@@ -237,7 +237,7 @@ export class BookingEmailSmsHandler {
rescheduledMembers,
metadata
),
sendRoundRobinScheduledEmailsAndSMS({
sendReassignedScheduledEmailsAndSMS({
calEvent: copyEventAdditionalInfo,
members: newBookedMembers,
eventTypeMetadata: metadata,
@@ -23,9 +23,8 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
user: { id: number; username: string | null; email: string; credentials: CredentialPayload[] },
eventType: EventType
) => {
let allCredentials = user.credentials;
// If it's a team event type query for team credentials
let allCredentials = Array.isArray(user.credentials) ? user.credentials : [];
if (eventType?.team?.id) {
const teamCredentialsQuery = await prisma.credential.findMany({
where: {
@@ -33,10 +32,11 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
},
select: credentialForCalendarServiceSelect,
});
if (Array.isArray(teamCredentialsQuery)) {
allCredentials.push(...teamCredentialsQuery);
}
}
// If it's a managed event type, query for the parent team's credentials
if (eventType?.parentId) {
const teamCredentialsQuery = await prisma.team.findFirst({
where: {
@@ -52,16 +52,15 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
},
},
});
if (teamCredentialsQuery?.credentials) {
allCredentials.push(...teamCredentialsQuery?.credentials);
if (teamCredentialsQuery?.credentials && Array.isArray(teamCredentialsQuery.credentials)) {
allCredentials.push(...teamCredentialsQuery.credentials);
}
}
const { profile } = await new UserRepository(prisma).enrichUserWithItsProfile({
user: user,
});
// If the user is a part of an organization, query for the organization's credentials
if (profile?.organizationId) {
const org = await prisma.team.findUnique({
where: {
@@ -74,7 +73,7 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
},
});
if (org?.credentials) {
if (org?.credentials && Array.isArray(org.credentials)) {
allCredentials.push(...org.credentials);
}
}
@@ -0,0 +1,347 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from "vitest";
import { prisma } from "@calcom/prisma";
import { BookingStatus, RRTimestampBasis } from "@calcom/prisma/enums";
import { BookingRepository } from "./BookingRepository";
// ------------
// Test Helpers
// ------------
// Track resources to clean up
const createdBookingIds: number[] = [];
let testUserId: number;
let testEventTypeId: number | null = null;
async function clearTestBookings() {
if (createdBookingIds.length > 0) {
await prisma.attendee.deleteMany({
where: { bookingId: { in: createdBookingIds } },
});
await prisma.booking.deleteMany({
where: { id: { in: createdBookingIds } },
});
createdBookingIds.length = 0;
}
}
async function createAttendeeNoShowTestBookings() {
// Booking 1: 00:00-01:00
const booking1 = await prisma.booking.create({
data: {
userId: testUserId,
uid: "uid-1",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
},
});
createdBookingIds.push(booking1.id);
// Booking 2: 01:00-02:00 (different time to avoid idempotencyKey collision)
const booking2 = await prisma.booking.create({
data: {
userId: testUserId,
uid: "uid-2",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: true,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T01:00:00.000Z"),
endTime: new Date("2025-05-01T02:00:00.000Z"),
title: "Test Event",
},
});
createdBookingIds.push(booking2.id);
}
async function createHostNoShowTestBookings() {
// Booking 1: 02:00-03:00 (different time to avoid idempotencyKey collision)
const booking1 = await prisma.booking.create({
data: {
userId: testUserId,
uid: "uid-3",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
noShowHost: false,
attendees: {
create: {
email: "att1@example.com",
noShow: false,
name: "Att 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T02:00:00.000Z"),
endTime: new Date("2025-05-01T03:00:00.000Z"),
title: "Test Event",
},
});
createdBookingIds.push(booking1.id);
// Booking 2: 03:00-04:00 (different time to avoid idempotencyKey collision)
const booking2 = await prisma.booking.create({
data: {
userId: testUserId,
uid: "uid-4",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
noShowHost: true,
attendees: {
create: {
email: "att2@example.com",
noShow: false,
name: "Att 2",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T03:00:00.000Z"),
endTime: new Date("2025-05-01T04:00:00.000Z"),
title: "Test Event",
},
});
createdBookingIds.push(booking2.id);
}
// -----------------
// Actual Test Suite
// -----------------
describe("BookingRepository (Integration Tests)", () => {
beforeAll(async () => {
// Use existing seed user
const testUser = await prisma.user.findFirstOrThrow({
where: { email: "member0-acme@example.com" },
});
testUserId = testUser.id;
// Find or create a test event type for this user
let eventType = await prisma.eventType.findFirst({
where: { userId: testUserId },
});
if (!eventType) {
eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: `test-event-type-${Date.now()}`,
length: 30,
userId: testUserId,
},
});
testEventTypeId = eventType.id; // Track for cleanup
} else {
testEventTypeId = eventType.id; // Use existing, don't clean up
}
});
afterAll(async () => {
// Only delete event type if we created it
if (testEventTypeId) {
const eventType = await prisma.eventType.findUnique({
where: { id: testEventTypeId },
select: { slug: true },
});
if (eventType?.slug.startsWith("test-event-type-")) {
await prisma.eventType.delete({
where: { id: testEventTypeId },
});
}
}
});
beforeEach(async () => {
vi.setSystemTime(new Date("2025-05-01T00:00:00.000Z"));
});
afterEach(async () => {
await clearTestBookings();
vi.useRealTimers();
});
describe("getAllBookingsForRoundRobin", () => {
describe("includeNoShowInRRCalculation", () => {
it("should not include bookings where attendee is a no-show", async () => {
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(1);
});
it("should include attendee no-shows when enabled", async () => {
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(2);
});
it("should not include bookings where host is a no-show", async () => {
await createHostNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(1);
});
it("should include host no-shows when enabled", async () => {
await createHostNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(2);
});
it("should not include ANY host/attendee no-shows when disabled", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(2);
});
it("should include ALL host/attendee no-shows when enabled", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "organizer1@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-05-01T00:00:00.000Z"),
endDate: new Date("2025-05-01T23:59:59.999Z"),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(4);
});
});
it("should filter by startTime when rrTimestampBasis=START_TIME", async () => {
const mayBooking = await prisma.booking.create({
data: {
userId: testUserId,
uid: "booking_may",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test@example.com",
noShow: false,
name: "Test",
timeZone: "UTC",
},
},
startTime: new Date("2025-05-26T00:00:00.000Z"),
endTime: new Date("2025-05-26T01:00:00.000Z"),
createdAt: new Date("2025-05-03T00:00:00.000Z"),
title: "Test May",
},
});
createdBookingIds.push(mayBooking.id);
const juneBooking = await prisma.booking.create({
data: {
userId: testUserId,
uid: "booking_june",
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test@example.com",
noShow: true,
name: "Test",
timeZone: "UTC",
},
},
startTime: new Date("2025-06-26T00:00:00.000Z"),
endTime: new Date("2025-06-26T01:00:00.000Z"),
createdAt: new Date("2025-05-03T00:00:00.000Z"),
title: "Test June",
},
});
createdBookingIds.push(juneBooking.id);
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: testUserId, email: "org@example.com" }],
eventTypeId: testEventTypeId,
startDate: new Date("2025-06-01T00:00:00.000Z"),
endDate: new Date("2025-06-30T23:59:59.999Z"),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(1);
expect(bookings[0].startTime.toISOString()).toBe(
"2025-06-26T00:00:00.000Z"
);
});
});
});
@@ -1,256 +0,0 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BookingStatus, RRTimestampBasis } from "@calcom/prisma/enums";
import { BookingRepository } from "./BookingRepository";
const createAttendeeNoShowTestBookings = async () => {
await Promise.all([
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
},
}),
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: true,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
},
}),
]);
};
const createHostNoShowTestBookings = async () => {
await Promise.all([
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
noShowHost: false,
},
}),
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
noShowHost: true,
},
}),
]);
};
describe("BookingRepository", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2025-05-01T00:00:00.000Z"));
vi.resetAllMocks();
});
describe("getAllBookingsForRoundRobin", () => {
describe("includeNoShowInRRCalculation", () => {
it("it should not include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(1);
});
it("it should include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should not include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(1);
});
it("it should include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should not include bookings where the host or an attendee is a no show", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should include bookings where the host or an attendee is a no show", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(4);
});
});
it("should use start time as timestamp basis for the booking count", async () => {
await Promise.all([
prismaMock.booking.create({
data: {
userId: 1,
uid: "booking_may",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-26T00:00:00.000Z"),
endTime: new Date("2025-05-26T01:00:00.000Z"),
createdAt: new Date("2025-05-03T00:00:00.000Z"),
title: "Test Event",
},
}),
prismaMock.booking.create({
data: {
userId: 1,
uid: "booking_june",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: true,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-06-26T00:00:00.000Z"),
endTime: new Date("2025-06-26T01:00:00.000Z"),
createdAt: new Date("2025-05-03T00:00:00.000Z"),
title: "Test Event",
},
}),
]);
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date("2025-06-01T00:00:00.000Z"),
endDate: new Date("2025-06-30T23:59:00.000Z"),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
rrTimestampBasis: RRTimestampBasis.START_TIME,
});
expect(bookings).toHaveLength(1);
expect(bookings[0].startTime.toISOString()).toBe(new Date("2025-06-26T00:00:00.000Z").toISOString());
});
});
});
@@ -1,4 +1,3 @@
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -7,6 +6,63 @@ import { RRTimestampBasis, BookingStatus } from "@calcom/prisma/enums";
import { bookingMinimalSelect } from "@calcom/prisma/selects/booking";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
type ManagedEventReassignmentCreateParams = {
uid: string;
userId: number;
userPrimaryEmail: string;
title: string;
description: string | null;
startTime: Date;
endTime: Date;
status: BookingStatus;
location: string | null;
smsReminderNumber: string | null;
responses?: Prisma.JsonValue | null;
customInputs?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
idempotencyKey: string;
eventTypeId: number;
attendees: {
name: string;
email: string;
timeZone: string;
locale: string | null;
phoneNumber?: string | null;
}[];
paymentId?: number;
iCalUID: string;
iCalSequence: number;
tx?: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends">;
};
export type ManagedEventReassignmentCreatedBooking = {
id: number;
uid: string;
title: string;
description: string | null;
startTime: Date;
endTime: Date;
location: string | null;
metadata: Prisma.JsonValue;
responses: Prisma.JsonValue;
iCalUID: string | null;
iCalSequence: number;
smsReminderNumber: string | null;
attendees: {
name: string;
email: string;
timeZone: string;
locale: string | null;
}[];
};
export type ManagedEventCancellationResult = {
id: number;
uid: string;
metadata: Prisma.JsonValue;
status: BookingStatus;
};
export type FormResponse = Record<
// Field ID
string,
@@ -168,6 +224,11 @@ export class BookingRepository {
eventType: {
select: {
teamId: true,
parent: {
select: {
teamId: true,
},
},
hosts: {
select: {
userId: true,
@@ -190,37 +251,52 @@ export class BookingRepository {
});
}
/** Determines if the user is the organizer, team admin, or org admin that the booking was created under */
async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) {
const booking = await this.prismaClient.booking.findUnique({
async findByIdIncludeEventType({ bookingId }: { bookingId: number }) {
return await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
select: {
userId: true,
user: {
select: {
id: true,
email: true,
},
},
attendees: {
select: {
email: true,
},
},
eventType: {
select: {
teamId: true,
parent: {
select: {
teamId: true,
},
},
hosts: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
users: {
select: {
id: true,
email: true,
},
},
},
},
},
});
if (!booking) return false;
if (userId === booking.userId) return true;
// If the booking doesn't belong to the user and there's no team then return early
if (!booking.eventType || !booking.eventType.teamId) return false;
// TODO add checks for team and org
const userRepo = new UserRepository(this.prismaClient);
const isAdminOrUser = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: booking.eventType.teamId,
});
return isAdminOrUser;
}
async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) {
@@ -1343,6 +1419,94 @@ export class BookingRepository {
});
}
async findByIdForReassignment(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
select: {
id: true,
uid: true,
eventTypeId: true,
userId: true,
startTime: true,
endTime: true,
},
});
}
async findByIdWithAttendeesPaymentAndReferences(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
uid: true,
title: true,
description: true,
customInputs: true,
responses: true,
startTime: true,
endTime: true,
metadata: true,
status: true,
location: true,
smsReminderNumber: true,
iCalUID: true,
iCalSequence: true,
eventTypeId: true,
userId: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
},
orderBy: {
id: "asc",
},
},
user: {
select: {
id: true,
username: true,
email: true,
name: true,
timeZone: true,
locale: true,
timeFormat: true,
},
},
payment: {
select: {
id: true,
},
},
references: {
select: {
uid: true,
type: true,
meetingUrl: true,
meetingId: true,
meetingPassword: true,
externalCalendarId: true,
credentialId: true,
thirdPartyRecurringEventId: true,
delegationCredentialId: true,
},
},
workflowReminders: {
select: {
id: true,
referenceId: true,
method: true,
},
},
},
});
}
async updateBookingAttendees({
bookingId,
newAttendees,
@@ -1369,7 +1533,7 @@ export class BookingRepository {
},
});
}
findByUidIncludeEventTypeAndReferences({ bookingUid }: { bookingUid: string }) {
return this.prismaClient.booking.findUniqueOrThrow({
where: {
@@ -1424,7 +1588,7 @@ export class BookingRepository {
},
});
}
async updateBookingStatus({
bookingId,
status,
@@ -1454,4 +1618,201 @@ export class BookingRepository {
},
});
}
/**
* Cancels a booking as part of the Managed Event reassignment flow.
* Callers only pass domain data; repository handles persistence details.
*/
async cancelBookingForManagedEventReassignment({
bookingId,
cancellationReason,
metadata,
tx,
}: {
bookingId: number;
cancellationReason: string;
metadata?: Record<string, unknown> | null;
tx?: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends">;
}): Promise<ManagedEventCancellationResult> {
const client = tx ?? this.prismaClient;
return client.booking.update({
where: { id: bookingId },
data: {
cancellationReason,
metadata: metadata as unknown as Prisma.InputJsonValue,
status: BookingStatus.CANCELLED,
},
select: {
id: true,
uid: true,
metadata: true,
status: true,
},
});
}
/**
* Creates a booking specifically for Managed Event reassignment flows.
* Encapsulates the select shape so callers don't deal with Prisma selections.
*/
async createBookingForManagedEventReassignment(
params: ManagedEventReassignmentCreateParams
): Promise<ManagedEventReassignmentCreatedBooking> {
const {
uid,
userId,
userPrimaryEmail,
title,
description,
startTime,
endTime,
status,
location,
smsReminderNumber,
responses,
customInputs,
metadata,
idempotencyKey,
eventTypeId,
attendees,
paymentId,
iCalUID,
iCalSequence,
tx,
} = params;
const client = tx ?? this.prismaClient;
return client.booking.create({
data: {
uid,
userPrimaryEmail,
title,
description,
startTime,
endTime,
status,
location,
smsReminderNumber,
responses: responses ?? undefined,
customInputs: customInputs as unknown as Prisma.InputJsonValue ?? undefined,
metadata: metadata as unknown as Prisma.InputJsonValue ?? undefined,
idempotencyKey,
iCalUID,
iCalSequence,
eventType: {
connect: { id: eventTypeId },
},
user: {
connect: { id: userId },
},
attendees: {
createMany: {
data: attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.locale,
phoneNumber: attendee.phoneNumber ?? null,
})),
},
},
payment: paymentId ? { connect: { id: paymentId } } : undefined,
},
select: {
id: true,
uid: true,
title: true,
description: true,
startTime: true,
endTime: true,
location: true,
metadata: true,
responses: true,
iCalUID: true,
iCalSequence: true,
smsReminderNumber: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
},
orderBy: {
id: "asc" as const,
},
},
},
});
}
/**
* Wraps the cancel+create operations for managed events in a single transaction.
*/
async managedEventReassignmentTransaction({
bookingId,
cancellationReason,
metadata,
newBookingPlan,
}: {
bookingId: number;
cancellationReason: string;
metadata?: Record<string, unknown> | null;
newBookingPlan: Omit<ManagedEventReassignmentCreateParams, "tx">;
}): Promise<{
newBooking: ManagedEventReassignmentCreatedBooking;
cancelledBooking: ManagedEventCancellationResult;
}> {
return this.prismaClient.$transaction(async (tx) => {
const cancelledBooking = await this.cancelBookingForManagedEventReassignment({
bookingId,
cancellationReason,
metadata,
tx,
});
const newBooking = await this.createBookingForManagedEventReassignment({
...newBookingPlan,
tx,
});
return { newBooking, cancelledBooking };
});
}
async findByIdForTargetEventTypeSearch(bookingId: number) {
return this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
eventTypeId: true,
userId: true,
startTime: true,
endTime: true,
},
});
}
async findByIdForWithUserIdAndEventTypeId(bookingId: number) {
return this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
eventTypeId: true,
userId: true,
},
});
}
async findByIdForReassignmentValidation(bookingId: number) {
return this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
status: true,
recurringEventId: true,
startTime: true,
endTime: true,
},
});
}
}
@@ -45,14 +45,21 @@ export class BookingAccessService {
async doesUserIdHaveAccessToBooking({
userId,
bookingUid,
bookingId,
}: {
userId: number;
bookingUid: string;
bookingUid?: string;
bookingId?: number;
}): Promise<boolean> {
const bookingRepo = new BookingRepository(this.prismaClient);
const userRepo = new UserRepository(this.prismaClient);
const booking = await bookingRepo.findByUidIncludeEventType({ bookingUid });
// Fetch booking by UID or ID
const booking = bookingUid
? await bookingRepo.findByUidIncludeEventType({ bookingUid })
: bookingId
? await bookingRepo.findByIdIncludeEventType({ bookingId })
: null;
if (!booking) return false;
@@ -69,6 +76,15 @@ export class BookingAccessService {
return isAdminOrUser;
}
// For managed events (child event types), check the parent's teamId
if (booking.eventType?.parent?.teamId) {
const isAdminOrUser = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: booking.eventType.parent.teamId,
});
return isAdminOrUser;
}
if (!booking.userId) return false;
const bookingOwner = await userRepo.getUserOrganizationAndTeams({ userId: booking.userId });
@@ -0,0 +1,13 @@
import type { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { createContainer } from "../di";
import { moduleLoader as bookingAccessServiceModuleLoader } from "../modules/BookingAccessService";
const container = createContainer();
export function getBookingAccessService() {
bookingAccessServiceModuleLoader.loadModule(container);
return container.get<BookingAccessService>(bookingAccessServiceModuleLoader.token);
}
@@ -0,0 +1,14 @@
import type { ManagedEventReassignmentService } from "@calcom/features/ee/managed-event-types/reassignment/services/ManagedEventReassignmentService";
import { createContainer } from "../di";
import { moduleLoader as managedEventReassignmentServiceModuleLoader } from "../modules/ManagedEventReassignment";
const container = createContainer();
export function getManagedEventReassignmentService() {
managedEventReassignmentServiceModuleLoader.loadModule(container);
return container.get<ManagedEventReassignmentService>(
managedEventReassignmentServiceModuleLoader.token
);
}
@@ -0,0 +1,22 @@
import { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di";
export const assignmentReasonRepositoryModule = createModule();
const token = DI_TOKENS.ASSIGNMENT_REASON_REPOSITORY;
const moduleToken = DI_TOKENS.ASSIGNMENT_REASON_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: assignmentReasonRepositoryModule,
moduleToken,
token,
classs: AssignmentReasonRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
@@ -0,0 +1,23 @@
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
export const bookingAccessServiceModule = createModule();
const token = DI_TOKENS.BOOKING_ACCESS_SERVICE;
const moduleToken = DI_TOKENS.BOOKING_ACCESS_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: bookingAccessServiceModule,
moduleToken,
token,
classs: BookingAccessService,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
@@ -0,0 +1,22 @@
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di";
export const credentialRepositoryModule = createModule();
const token = DI_TOKENS.CREDENTIAL_REPOSITORY;
const moduleToken = DI_TOKENS.CREDENTIAL_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: credentialRepositoryModule,
moduleToken,
token,
classs: CredentialRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
+11 -4
View File
@@ -1,9 +1,16 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { createModule } from "../di";
import { type Container, createModule, type ModuleLoader } from "../di";
export const eventTypeRepositoryModule = createModule();
eventTypeRepositoryModule
.bind(DI_TOKENS.EVENT_TYPE_REPOSITORY)
.toClass(EventTypeRepository, [DI_TOKENS.PRISMA_CLIENT]);
const token = DI_TOKENS.EVENT_TYPE_REPOSITORY;
const moduleToken = DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE;
eventTypeRepositoryModule.bind(token).toClass(EventTypeRepository, [DI_TOKENS.PRISMA_CLIENT]);
export const moduleLoader = {
token,
loadModule: function (container: Container) {
container.load(moduleToken, eventTypeRepositoryModule);
},
} satisfies ModuleLoader;
@@ -0,0 +1,32 @@
import { ManagedEventReassignmentService } from "@calcom/features/ee/managed-event-types/reassignment/services/ManagedEventReassignmentService";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
import { moduleLoader as bookingRepositoryModuleLoader } from "./Booking";
import { moduleLoader as eventTypeRepositoryModuleLoader } from "./EventType";
import { moduleLoader as userRepositoryModuleLoader } from "./User";
import { moduleLoader as luckyUserServiceModuleLoader } from "./LuckyUser";
const thisModule = createModule();
const token = DI_TOKENS.MANAGED_EVENT_REASSIGNMENT_SERVICE;
const moduleToken = DI_TOKENS.MANAGED_EVENT_REASSIGNMENT_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: ManagedEventReassignmentService,
depsMap: {
bookingRepository: bookingRepositoryModuleLoader,
eventTypeRepository: eventTypeRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
luckyUserService: luckyUserServiceModuleLoader,
},
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { ManagedEventReassignmentService };
+9
View File
@@ -21,6 +21,8 @@ export const DI_TOKENS = {
USER_REPOSITORY_MODULE: Symbol("UserRepositoryModule"),
BOOKING_REPOSITORY: Symbol("BookingRepository"),
BOOKING_REPOSITORY_MODULE: Symbol("BookingRepositoryModule"),
BOOKING_ACCESS_SERVICE: Symbol("BookingAccessService"),
BOOKING_ACCESS_SERVICE_MODULE: Symbol("BookingAccessServiceModule"),
EVENT_TYPE_REPOSITORY: Symbol("EventTypeRepository"),
EVENT_TYPE_REPOSITORY_MODULE: Symbol("EventTypeRepositoryModule"),
ROUTING_FORM_RESPONSE_REPOSITORY: Symbol("RoutingFormResponseRepository"),
@@ -57,6 +59,13 @@ export const DI_TOKENS = {
ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"),
MEMBERSHIP_SERVICE: Symbol("MembershipService"),
MEMBERSHIP_SERVICE_MODULE: Symbol("MembershipServiceModule"),
ASSIGNMENT_REASON_REPOSITORY: Symbol("AssignmentReasonRepository"),
ASSIGNMENT_REASON_REPOSITORY_MODULE: Symbol("AssignmentReasonRepositoryModule"),
CREDENTIAL_REPOSITORY: Symbol("CredentialRepository"),
CREDENTIAL_REPOSITORY_MODULE: Symbol("CredentialRepositoryModule"),
MANAGED_EVENT_REASSIGNMENT_SERVICE: Symbol("ManagedEventReassignmentService"),
MANAGED_EVENT_REASSIGNMENT_SERVICE_MODULE: Symbol("ManagedEventReassignmentServiceModule"),
// Booking service tokens
...BOOKING_AUDIT_DI_TOKENS,
...BOOKING_DI_TOKENS,
...HASHED_LINK_DI_TOKENS,
@@ -0,0 +1,4 @@
export { managedEventManualReassignment } from "./managedEventManualReassignment";
export { managedEventReassignment } from "./managedEventReassignment";
export * from "./utils";
@@ -0,0 +1,505 @@
import { describe, it, vi, expect, beforeAll, afterAll, afterEach } from "vitest";
import { prisma } from "@calcom/prisma";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
const mockEventManagerCreate = vi.fn().mockResolvedValue({ referencesToCreate: [] });
const mockEventManagerDelete = vi.fn().mockResolvedValue({});
vi.mock("@calcom/features/bookings/lib/EventManager", () => ({
default: class MockEventManager {
create = mockEventManagerCreate;
deleteEventsAndMeetings = mockEventManagerDelete;
},
}));
vi.mock("@calcom/emails/email-manager");
let testTeamId: number;
const userIds: number[] = [];
const eventTypeIds: number[] = [];
const bookingIds: number[] = [];
const mockEventManager = async () => {
mockEventManagerCreate.mockResolvedValue({ referencesToCreate: [] });
mockEventManagerDelete.mockResolvedValue({});
};
const mockEmails = async () => {
const emails = await import("@calcom/emails/email-manager");
vi.spyOn(emails, "sendReassignedScheduledEmailsAndSMS").mockResolvedValue(undefined);
vi.spyOn(emails, "sendReassignedEmailsAndSMS").mockResolvedValue(undefined);
vi.spyOn(emails, "sendReassignedUpdatedEmailsAndSMS").mockResolvedValue(undefined);
};
const createTestUser = async (userData: { email: string; name: string; username: string }) => {
const user = await prisma.user.create({
data: {
email: userData.email,
name: userData.name,
username: userData.username,
timeZone: "UTC",
},
});
userIds.push(user.id);
return user;
};
const createManagedEventStructure = async (params: {
teamId: number;
users: { id: number; username: string | null }[];
}) => {
const { teamId, users } = params;
// Create parent event type (MANAGED)
const parentEventType = await prisma.eventType.create({
data: {
title: "Managed Event Parent",
slug: "managed-event-parent",
length: 30,
schedulingType: SchedulingType.MANAGED,
teamId,
},
});
eventTypeIds.push(parentEventType.id);
// Create child event types (one per user)
const childEventTypes = await Promise.all(
users.map(async (user) => {
const username = user.username || `user-${user.id}`;
const child = await prisma.eventType.create({
data: {
title: `${username} Event`,
slug: `${username}-event`,
length: 30,
userId: user.id,
parentId: parentEventType.id,
},
});
eventTypeIds.push(child.id);
return child;
})
);
return { parentEventType, childEventTypes };
};
const createTestBooking = async (params: {
eventTypeId: number;
userId: number;
startTime: Date;
endTime: Date;
}) => {
const uniqueId = `test-idempotency-${Date.now()}-${Math.random()}`;
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${uniqueId}`,
idempotencyKey: `test-idempotency-${uniqueId}`,
title: "Test Booking",
startTime: params.startTime,
endTime: params.endTime,
eventTypeId: params.eventTypeId,
userId: params.userId,
status: BookingStatus.ACCEPTED,
attendees: {
create: [
{
name: "Test Attendee",
email: "attendee@test.com",
timeZone: "UTC",
},
],
},
},
});
bookingIds.push(booking.id);
return booking;
};
beforeAll(async () => {
await mockEventManager();
await mockEmails();
// Create test team
const team = await prisma.team.create({
data: {
name: "Test Team",
slug: "test-team",
},
});
testTeamId = team.id;
});
afterEach(async () => {
// Clean up bookings
if (bookingIds.length > 0) {
await prisma.bookingReference.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await prisma.attendee.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await prisma.assignmentReason.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await prisma.booking.deleteMany({
where: { id: { in: bookingIds } },
});
bookingIds.splice(0, bookingIds.length);
}
// Clean up event types
if (eventTypeIds.length > 0) {
await prisma.eventType.deleteMany({
where: { id: { in: eventTypeIds } },
});
eventTypeIds.splice(0, eventTypeIds.length);
}
// Clean up users
if (userIds.length > 0) {
await prisma.user.deleteMany({
where: { id: { in: userIds } },
});
userIds.splice(0, userIds.length);
}
});
afterAll(async () => {
// Clean up team
if (testTeamId) {
await prisma.team.delete({
where: { id: testTeamId },
});
}
});
describe("managedEventManualReassignment - Integration Tests", () => {
it("should reassign booking from one user to another within managed event", async () => {
const managedEventManualReassignment = (await import("./managedEventManualReassignment")).default;
// Create users
const originalUser = await createTestUser({
email: "original@test.com",
name: "Original User",
username: "original-user",
});
const newUser = await createTestUser({
email: "new@test.com",
name: "New User",
username: "new-user",
});
// Add users to team
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: originalUser.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: newUser.id, accepted: true, role: "MEMBER" },
],
});
// Create managed event structure
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: originalUser.id, username: originalUser.username! },
{ id: newUser.id, username: newUser.username! },
],
});
// Create booking for original user - schedule tomorrow at 10am UTC (within 9am-5pm availability window)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: originalUser.id,
startTime,
endTime,
});
// Perform reassignment
await managedEventManualReassignment({
bookingId: originalBooking.id,
newUserId: newUser.id,
reassignedById: originalUser.id,
orgId: null,
emailsEnabled: false,
isAutoReassignment: false,
});
// Verify original booking is cancelled
const cancelledBooking = await prisma.booking.findUnique({
where: { id: originalBooking.id },
});
expect(cancelledBooking?.status).toBe(BookingStatus.CANCELLED);
// Verify new booking was created for new user
const newBooking = await prisma.booking.findFirst({
where: {
eventTypeId: childEventTypes[1].id,
userId: newUser.id,
status: BookingStatus.ACCEPTED,
},
});
expect(newBooking).toBeTruthy();
expect(newBooking?.eventTypeId).toBe(childEventTypes[1].id);
expect(newBooking?.userId).toBe(newUser.id);
// Verify new booking has different UID
expect(newBooking?.uid).not.toBe(originalBooking.uid);
});
it("should record assignment reason for manual reassignment", async () => {
const managedEventManualReassignment = (await import("./managedEventManualReassignment")).default;
await prisma.booking.deleteMany({
where: {
idempotencyKey: { startsWith: "test-idempotency-" },
},
});
const originalUser = await createTestUser({
email: "original2@test.com",
name: "Original User 2",
username: "original-user-2",
});
const newUser = await createTestUser({
email: "new2@test.com",
name: "New User 2",
username: "new-user-2",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: originalUser.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: newUser.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: originalUser.id, username: originalUser.username! },
{ id: newUser.id, username: newUser.username! },
],
});
// Schedule booking tomorrow at 10am UTC (within 9am-5pm availability window)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: originalUser.id,
startTime,
endTime,
});
const reassignReason = "User requested different host";
await managedEventManualReassignment({
bookingId: originalBooking.id,
newUserId: newUser.id,
reassignedById: originalUser.id,
reassignReason,
orgId: null,
emailsEnabled: false,
isAutoReassignment: false,
});
// Verify assignment reason was recorded
const assignmentReason = await prisma.assignmentReason.findFirst({
where: {
booking: {
eventTypeId: childEventTypes[1].id,
userId: newUser.id,
},
},
});
expect(assignmentReason).toBeTruthy();
expect(assignmentReason?.reasonString).toContain("Manual-reassigned");
expect(assignmentReason?.reasonString).toContain(reassignReason);
});
it("should preserve booking details (attendees, time) during reassignment", async () => {
const managedEventManualReassignment = (await import("./managedEventManualReassignment")).default;
await prisma.booking.deleteMany({
where: {
idempotencyKey: { startsWith: "test-idempotency-" },
},
});
const originalUser = await createTestUser({
email: "original3@test.com",
name: "Original User 3",
username: "original-user-3",
});
const newUser = await createTestUser({
email: "new3@test.com",
name: "New User 3",
username: "new-user-3",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: originalUser.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: newUser.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: originalUser.id, username: originalUser.username! },
{ id: newUser.id, username: newUser.username! },
],
});
// Schedule booking tomorrow at 10am UTC (within 9am-5pm availability window)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: originalUser.id,
startTime,
endTime,
});
await managedEventManualReassignment({
bookingId: originalBooking.id,
newUserId: newUser.id,
reassignedById: originalUser.id,
orgId: null,
emailsEnabled: false,
isAutoReassignment: false,
});
const newBooking = await prisma.booking.findFirst({
where: {
eventTypeId: childEventTypes[1].id,
userId: newUser.id,
},
select: {
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
},
orderBy: {
id: "asc",
},
},
},
});
expect(newBooking).toBeTruthy();
// Verify time is preserved
expect(newBooking?.startTime).toEqual(startTime);
expect(newBooking?.endTime).toEqual(endTime);
// Verify attendees are copied
expect(newBooking?.attendees.length).toBe(1);
expect(newBooking?.attendees[0].email).toBe("attendee@test.com");
});
it("should call email functions when emailsEnabled is true", async () => {
const managedEventManualReassignment = (await import("./managedEventManualReassignment")).default;
const emails = await import("@calcom/emails/email-manager");
const originalUser = await createTestUser({
email: "original5@test.com",
name: "Original User 5",
username: "original-user-5",
});
const newUser = await createTestUser({
email: "new5@test.com",
name: "New User 5",
username: "new-user-5",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: originalUser.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: newUser.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: originalUser.id, username: originalUser.username! },
{ id: newUser.id, username: newUser.username! },
],
});
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: originalUser.id,
startTime,
endTime,
});
await managedEventManualReassignment({
bookingId: originalBooking.id,
newUserId: newUser.id,
reassignedById: originalUser.id,
orgId: null,
emailsEnabled: true,
isAutoReassignment: false,
});
// Verify email functions were called
expect(emails.sendReassignedScheduledEmailsAndSMS).toHaveBeenCalledTimes(1);
expect(emails.sendReassignedEmailsAndSMS).toHaveBeenCalledTimes(1);
expect(emails.sendReassignedUpdatedEmailsAndSMS).toHaveBeenCalledTimes(1);
const newBooking = await prisma.booking.findFirst({
where: { userId: newUser.id, eventTypeId: childEventTypes[1].id },
});
expect(newBooking).toBeTruthy();
expect(newBooking?.userId).toBe(newUser.id);
});
it("should throw error when booking does not exist", async () => {
const managedEventManualReassignment = (await import("./managedEventManualReassignment")).default;
const originalUser = await createTestUser({
email: "original6@test.com",
name: "Original User 6",
username: "original-user-6",
});
const newUser = await createTestUser({
email: "new6@test.com",
name: "New User 6",
username: "new-user-6",
});
await expect(
managedEventManualReassignment({
bookingId: 999999, // Non-existent booking ID
newUserId: newUser.id,
reassignedById: originalUser.id,
orgId: null,
emailsEnabled: false,
isAutoReassignment: false,
})
).rejects.toThrow();
});
});
@@ -0,0 +1,18 @@
import { prisma } from "@calcom/prisma";
import type { ManagedEventManualReassignmentParams } from "./services/ManagedEventManualReassignmentService";
import { createManagedEventManualReassignmentService } from "./services/container";
/**
* Entry point for manual managed event reassignment
*
* This delegates to the service layer without direct repository knowledge.
* The container handles all dependency injection.
*/
// TODO: Remove this function with better dependency injection
export async function managedEventManualReassignment(params: ManagedEventManualReassignmentParams) {
const service = createManagedEventManualReassignmentService(prisma);
return service.execute(params);
}
export default managedEventManualReassignment;
@@ -0,0 +1,420 @@
import { describe, it, vi, expect, beforeAll, afterAll, afterEach } from "vitest";
import { prisma } from "@calcom/prisma";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
const mockEventManagerCreate = vi.fn().mockResolvedValue({ referencesToCreate: [] });
const mockEventManagerDelete = vi.fn().mockResolvedValue({});
vi.mock("@calcom/features/bookings/lib/EventManager", () => ({
default: class MockEventManager {
create = mockEventManagerCreate;
deleteEventsAndMeetings = mockEventManagerDelete;
},
}));
vi.mock("@calcom/emails/email-manager");
let testTeamId: number;
const userIds: number[] = [];
const eventTypeIds: number[] = [];
const bookingIds: number[] = [];
const mockEventManager = async () => {
mockEventManagerCreate.mockResolvedValue({ referencesToCreate: [] });
mockEventManagerDelete.mockResolvedValue({});
};
const mockEmails = async () => {
const emails = await import("@calcom/emails/email-manager");
vi.spyOn(emails, "sendReassignedScheduledEmailsAndSMS").mockResolvedValue(undefined);
vi.spyOn(emails, "sendReassignedEmailsAndSMS").mockResolvedValue(undefined);
vi.spyOn(emails, "sendReassignedUpdatedEmailsAndSMS").mockResolvedValue(undefined);
};
const createTestUser = async (userData: { email: string; name: string; username: string }) => {
const user = await prisma.user.create({
data: {
email: userData.email,
name: userData.name,
username: userData.username,
timeZone: "UTC",
schedules: {
create: {
name: "Default Schedule",
timeZone: "UTC",
availability: {
create: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:00:00.000Z"),
endTime: new Date("1970-01-01T17:00:00.000Z"),
},
],
},
},
},
},
});
userIds.push(user.id);
return user;
};
const createManagedEventStructure = async (params: {
teamId: number;
users: { id: number; username: string | null }[];
}) => {
const { teamId, users } = params;
const parentEventType = await prisma.eventType.create({
data: {
title: "Managed Event Parent",
slug: "managed-event-parent",
length: 30,
schedulingType: SchedulingType.MANAGED,
teamId,
},
});
eventTypeIds.push(parentEventType.id);
const childEventTypes = await Promise.all(
users.map(async (user) => {
const username = user.username || `user-${user.id}`;
const child = await prisma.eventType.create({
data: {
title: `${username} Event`,
slug: `${username}-event`,
length: 30,
userId: user.id,
parentId: parentEventType.id,
},
});
eventTypeIds.push(child.id);
return child;
})
);
return { parentEventType, childEventTypes };
};
const createTestBooking = async (params: {
eventTypeId: number;
userId: number;
startTime: Date;
endTime: Date;
}) => {
const idempotencyKey = `test-idempotency-${Date.now()}-${Math.random()}`;
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${Date.now()}-${Math.random()}`,
title: "Test Booking",
idempotencyKey,
startTime: params.startTime,
endTime: params.endTime,
eventTypeId: params.eventTypeId,
userId: params.userId,
status: BookingStatus.ACCEPTED,
attendees: {
create: [
{
name: "Test Attendee",
email: "attendee@test.com",
timeZone: "UTC",
},
],
},
},
});
bookingIds.push(booking.id);
return booking;
};
beforeAll(async () => {
await mockEventManager();
await mockEmails();
const team = await prisma.team.create({
data: {
name: "Test Team Auto",
slug: "test-team-auto",
},
});
testTeamId = team.id;
});
afterEach(async () => {
// Clean up all test bookings, including those created by reassignment
// We query by eventTypeId to catch both original bookings and reassignment-created bookings
const testBookings = await prisma.booking.findMany({
where: {
OR: [
{ uid: { startsWith: "test-booking-" } },
{ eventTypeId: { in: eventTypeIds } },
{ userId: { in: userIds } },
],
},
select: { id: true },
});
const testBookingIds = testBookings.map((b) => b.id);
if (testBookingIds.length > 0) {
await prisma.bookingReference.deleteMany({
where: { bookingId: { in: testBookingIds } },
});
await prisma.attendee.deleteMany({
where: { bookingId: { in: testBookingIds } },
});
await prisma.assignmentReason.deleteMany({
where: { bookingId: { in: testBookingIds } },
});
await prisma.booking.deleteMany({
where: { id: { in: testBookingIds } },
});
}
bookingIds.splice(0, bookingIds.length);
if (eventTypeIds.length > 0) {
await prisma.eventType.deleteMany({
where: { id: { in: eventTypeIds } },
});
eventTypeIds.splice(0, eventTypeIds.length);
}
if (userIds.length > 0) {
await prisma.user.deleteMany({
where: { id: { in: userIds } },
});
userIds.splice(0, userIds.length);
}
});
afterAll(async () => {
if (testTeamId) {
await prisma.team.delete({
where: { id: testTeamId },
});
}
});
describe("managedEventReassignment - Integration Tests", () => {
it("should auto-reassign booking to available user using LuckyUser algorithm", async () => {
const managedEventReassignment = (await import("./managedEventReassignment")).default;
const user1 = await createTestUser({
email: "user1@test.com",
name: "User 1",
username: "user1",
});
const user2 = await createTestUser({
email: "user2@test.com",
name: "User 2",
username: "user2",
});
const user3 = await createTestUser({
email: "user3@test.com",
name: "User 3",
username: "user3",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: user1.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: user2.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: user3.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: user1.id, username: user1.username! },
{ id: user2.id, username: user2.username! },
{ id: user3.id, username: user3.username! },
],
});
// Schedule booking tomorrow at 10am UTC (within 9am-5pm availability window)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: user1.id,
startTime,
endTime,
});
await managedEventReassignment({
bookingId: originalBooking.id,
reassignedById: user1.id,
orgId: null,
emailsEnabled: false,
});
// Verify original booking is cancelled
const cancelledBooking = await prisma.booking.findUnique({
where: { id: originalBooking.id },
});
expect(cancelledBooking?.status).toBe(BookingStatus.CANCELLED);
// Verify a new booking was created for another user
const newBooking = await prisma.booking.findFirst({
where: {
userId: { not: user1.id },
startTime,
endTime,
status: BookingStatus.ACCEPTED,
},
});
expect(newBooking).toBeTruthy();
expect([user2.id, user3.id]).toContain(newBooking?.userId);
});
it("should record auto-reassignment reason", async () => {
const managedEventReassignment = (await import("./managedEventReassignment")).default;
const user1 = await createTestUser({
email: "user1-reason@test.com",
name: "User 1 Reason",
username: "user1-reason",
});
const user2 = await createTestUser({
email: "user2-reason@test.com",
name: "User 2 Reason",
username: "user2-reason",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: user1.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: user2.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: user1.id, username: user1.username! },
{ id: user2.id, username: user2.username! },
],
});
// Schedule booking tomorrow at 10am UTC (within 9am-5pm availability window)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: user1.id,
startTime,
endTime,
});
await managedEventReassignment({
bookingId: originalBooking.id,
reassignedById: user1.id,
orgId: null,
emailsEnabled: false,
});
const assignmentReason = await prisma.assignmentReason.findFirst({
where: {
booking: {
userId: user2.id,
startTime,
},
},
});
expect(assignmentReason).toBeTruthy();
expect(assignmentReason?.reasonString).toContain("Auto-reassigned");
});
it("should call email functions when emailsEnabled is true", async () => {
const managedEventReassignment = (await import("./managedEventReassignment")).default;
const emails = await import("@calcom/emails/email-manager");
const user1 = await createTestUser({
email: "user1-email@test.com",
name: "User 1 Email Test",
username: "user1-email",
});
const user2 = await createTestUser({
email: "user2-email@test.com",
name: "User 2 Email Test",
username: "user2-email",
});
await prisma.membership.createMany({
data: [
{ teamId: testTeamId, userId: user1.id, accepted: true, role: "MEMBER" },
{ teamId: testTeamId, userId: user2.id, accepted: true, role: "MEMBER" },
],
});
const { childEventTypes } = await createManagedEventStructure({
teamId: testTeamId,
users: [
{ id: user1.id, username: user1.username! },
{ id: user2.id, username: user2.username! },
],
});
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(10, 0, 0, 0);
const startTime = tomorrow;
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const originalBooking = await createTestBooking({
eventTypeId: childEventTypes[0].id,
userId: user1.id,
startTime,
endTime,
});
await managedEventReassignment({
bookingId: originalBooking.id,
reassignedById: user1.id,
orgId: null,
emailsEnabled: true,
});
expect(emails.sendReassignedScheduledEmailsAndSMS).toHaveBeenCalledTimes(1);
expect(emails.sendReassignedEmailsAndSMS).toHaveBeenCalledTimes(1);
expect(emails.sendReassignedUpdatedEmailsAndSMS).toHaveBeenCalledTimes(1);
const newBooking = await prisma.booking.findFirst({
where: { userId: user2.id, startTime },
});
expect(newBooking).toBeTruthy();
expect(newBooking?.userId).toBe(user2.id);
});
it("should throw error when booking does not exist", async () => {
const managedEventReassignment = (await import("./managedEventReassignment")).default;
const user1 = await createTestUser({
email: "user1-error@test.com",
name: "User 1 Error",
username: "user1-error",
});
await expect(
managedEventReassignment({
bookingId: 999999, // Non-existent booking ID
reassignedById: user1.id,
orgId: null,
emailsEnabled: false,
})
).rejects.toThrow();
});
});
@@ -0,0 +1,30 @@
import { getManagedEventReassignmentService } from "@calcom/features/di/containers/ManagedEventReassignment";
interface ManagedEventReassignmentParams {
bookingId: number;
orgId: number | null;
reassignReason?: string;
reassignedById: number;
emailsEnabled?: boolean;
}
// TODO: Remove this function with better dependency injection
export async function managedEventReassignment({
bookingId,
orgId,
reassignReason = "Auto-reassigned to another team member",
reassignedById,
emailsEnabled = true,
}: ManagedEventReassignmentParams) {
const service = getManagedEventReassignmentService();
return await service.executeAutoReassignment({
bookingId,
orgId,
reassignReason,
reassignedById,
emailsEnabled,
});
}
export default managedEventReassignment;
@@ -0,0 +1,75 @@
import { withReporting } from "@calcom/lib/sentryWrapper";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
export enum ManagedEventReassignmentType {
MANUAL = "manual",
AUTO = "auto",
}
interface ManagedEventAssignmentReasonServiceDeps {
userRepository: UserRepository;
assignmentReasonRepository: AssignmentReasonRepository;
}
/**
* Service for recording managed event assignment reasons
* Uses constructor injection for dependencies (no direct repository instantiation)
*/
export class ManagedEventAssignmentReasonService {
private readonly userRepository: UserRepository;
private readonly assignmentReasonRepository: AssignmentReasonRepository;
constructor(deps: ManagedEventAssignmentReasonServiceDeps) {
this.userRepository = deps.userRepository;
this.assignmentReasonRepository = deps.assignmentReasonRepository;
}
/**
* Record a managed event reassignment reason
*
* @param newBookingId - The ID of the NEW booking created during reassignment
* @param reassignById - The ID of the user who performed the reassignment
* @param reassignReason - Optional reason for the reassignment
* @param reassignmentType - Whether this was manual or automatic reassignment
*/
recordReassignment = withReporting(
this._recordReassignment.bind(this),
"ManagedEventAssignmentReasonService.recordReassignment"
);
private async _recordReassignment({
newBookingId,
reassignById,
reassignReason,
reassignmentType,
}: {
newBookingId: number;
reassignById: number;
reassignReason?: string;
reassignmentType: ManagedEventReassignmentType;
}) {
const reassignedBy = await this.userRepository.findByIdWithUsername(reassignById);
const reasonEnum = AssignmentReasonEnum.REASSIGNED;
const reassignmentTypeLabel =
reassignmentType === ManagedEventReassignmentType.AUTO ? "Auto-reassigned" : "Manual-reassigned";
const reasonString = `${reassignmentTypeLabel} by: ${reassignedBy?.username || "team member"}${
reassignReason ? `. Reason: ${reassignReason}` : ""
}`;
await this.assignmentReasonRepository.createAssignmentReason({
bookingId: newBookingId,
reasonEnum,
reasonString,
});
return {
reasonEnum,
reasonString,
};
}
}
@@ -0,0 +1,825 @@
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
sendReassignedEmailsAndSMS,
sendReassignedScheduledEmailsAndSMS,
sendReassignedUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import {
BookingRepository,
type ManagedEventReassignmentCreatedBooking,
type ManagedEventCancellationResult,
} from "@calcom/features/bookings/repositories/BookingRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type loggerType from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import {
ManagedEventAssignmentReasonService,
ManagedEventReassignmentType,
} from "@calcom/features/ee/managed-event-types/reassignment/services/ManagedEventAssignmentReasonRecorder";
import {
findTargetChildEventType,
validateManagedEventReassignment,
buildNewBookingPlan,
} from "@calcom/features/ee/managed-event-types/reassignment/utils";
interface ManagedEventManualReassignmentServiceDeps {
prisma: PrismaClient;
bookingRepository: BookingRepository;
userRepository: UserRepository;
eventTypeRepository: EventTypeRepository;
assignmentReasonService: ManagedEventAssignmentReasonService;
}
export interface ManagedEventManualReassignmentParams {
bookingId: number;
newUserId: number;
orgId: number | null;
reassignReason?: string;
reassignedById: number;
emailsEnabled?: boolean;
isAutoReassignment?: boolean;
}
export class ManagedEventManualReassignmentService {
private readonly prisma: PrismaClient;
private readonly bookingRepository: BookingRepository;
private readonly userRepository: UserRepository;
private readonly eventTypeRepository: EventTypeRepository;
private readonly assignmentReasonService: ManagedEventAssignmentReasonService;
private readonly log = logger.getSubLogger({ prefix: ["ManagedEventManualReassignmentService"] });
constructor(deps: ManagedEventManualReassignmentServiceDeps) {
this.prisma = deps.prisma;
this.bookingRepository = deps.bookingRepository;
this.userRepository = deps.userRepository;
this.eventTypeRepository = deps.eventTypeRepository;
this.assignmentReasonService = deps.assignmentReasonService;
}
async execute({
bookingId,
newUserId,
orgId,
reassignReason,
reassignedById,
emailsEnabled = true,
isAutoReassignment = false,
}: ManagedEventManualReassignmentParams) {
const reassignLogger = this.log.getSubLogger({
prefix: ["manualReassignment", `${bookingId}`],
});
reassignLogger.info(`User ${reassignedById} initiating manual reassignment to user ${newUserId}`);
await validateManagedEventReassignment({ bookingId, bookingRepository: this.bookingRepository });
const {
parentEventType,
currentEventTypeDetails,
targetEventTypeDetails,
originalUser,
newUser,
originalBookingFull,
originalUserT,
newUserT,
} = await this.resolveTargetEntities(bookingId, newUserId, reassignLogger);
const newBookingPlan = buildNewBookingPlan({
originalBookingFull,
targetEventTypeDetails,
newUser,
newUserT,
reassignedById,
});
const { newBooking, cancelledBooking } = await this.executeBookingReassignmentTransaction({
originalBookingFull,
newBookingPlan,
newUser,
reassignLogger,
});
const apps = eventTypeAppMetadataOptionalSchema.parse(targetEventTypeDetails?.metadata?.apps);
await this.removeOriginalCalendarEvents({
originalUser,
currentEventTypeDetails,
originalBookingFull,
originalUserT,
apps,
logger: reassignLogger,
});
const calendarResult = await this.createCalendarEventsForNewUser({
newUser,
newUserT,
targetEventTypeDetails,
newBooking,
originalBookingFull,
apps,
logger: reassignLogger,
});
const { bookingLocation, videoCallUrl, videoCallData, additionalInformation } = calendarResult;
try {
await WorkflowRepository.deleteAllWorkflowReminders(originalBookingFull.workflowReminders);
reassignLogger.info(`Cancelled ${originalBookingFull.workflowReminders.length} workflow reminders`);
} catch (error) {
reassignLogger.error("Error cancelling workflow reminders", error);
}
if (targetEventTypeDetails.workflows && targetEventTypeDetails.workflows.length > 0) {
try {
const creditService = new CreditService();
const bookerBaseUrl = await getBookerBaseUrl(orgId);
const bookerUrl = `${bookerBaseUrl}/${newUser.username}/${targetEventTypeDetails.slug}`;
await WorkflowService.scheduleWorkflowsForNewBooking({
workflows: targetEventTypeDetails.workflows.map((w) => w.workflow),
smsReminderNumber: newBooking.smsReminderNumber || null,
calendarEvent: {
type: targetEventTypeDetails.slug,
uid: newBooking.uid,
title: newBooking.title,
startTime: dayjs(newBooking.startTime).utc().format(),
endTime: dayjs(newBooking.endTime).utc().format(),
organizer: {
id: newUser.id,
name: newUser.name || "",
email: newUser.email,
timeZone: newUser.timeZone,
language: { translate: newUserT, locale: newUser.locale ?? "en" },
},
attendees: newBooking.attendees.map(
(att: { name: string; email: string; timeZone: string; locale: string | null }) => ({
name: att.name,
email: att.email,
timeZone: att.timeZone,
language: { translate: newUserT, locale: att.locale ?? "en" },
})
),
location: bookingLocation || undefined,
description: newBooking.description || undefined,
eventType: { slug: targetEventTypeDetails.slug },
bookerUrl,
metadata: videoCallUrl ? { videoCallUrl, ...additionalInformation } : undefined,
},
hideBranding: !!targetEventTypeDetails.owner?.hideBranding,
seatReferenceUid: undefined,
isDryRun: false,
isConfirmedByDefault: targetEventTypeDetails.requiresConfirmation ? false : true,
isNormalBookingOrFirstRecurringSlot: true,
isRescheduleEvent: false,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
reassignLogger.info("Scheduled workflow reminders for new booking");
} catch (error) {
reassignLogger.error("Error scheduling workflow reminders for new booking", error);
}
}
if (emailsEnabled) {
await this.sendReassignmentNotifications({
newBooking,
targetEventTypeDetails,
parentEventType,
newUser,
newUserT,
originalUser,
originalUserT,
bookingLocation,
videoCallData,
additionalInformation,
reassignReason,
logger: reassignLogger,
});
}
await this.recordAssignmentReason({
newBookingId: newBooking.id,
reassignedById,
reassignReason,
isAutoReassignment,
logger: reassignLogger,
});
reassignLogger.info("Reassignment completed successfully", {
originalBookingId: cancelledBooking.id,
newBookingId: newBooking.id,
fromUserId: originalUser.id,
toUserId: newUser.id,
});
return {
newBooking,
cancelledBooking,
};
}
/**
* Resolves and validates all entities needed for reassignment
*/
private async resolveTargetEntities(bookingId: number, newUserId: number, reassignLogger: typeof logger) {
const {
currentChildEventType,
parentEventType,
targetChildEventType,
originalBooking,
} = await findTargetChildEventType({
bookingId,
newUserId,
bookingRepository: this.bookingRepository,
eventTypeRepository: this.eventTypeRepository,
});
reassignLogger.info("Found target child event type", {
currentChildId: currentChildEventType.id,
targetChildId: targetChildEventType.id,
parentId: parentEventType.id,
});
const [currentEventTypeDetails, targetEventTypeDetails] = await Promise.all([
getEventTypesFromDB(currentChildEventType.id),
getEventTypesFromDB(targetChildEventType.id),
]);
if (!currentEventTypeDetails || !targetEventTypeDetails) {
throw new Error("Failed to load event type details");
}
const newUser = await this.userRepository.findByIdWithCredentialsAndCalendar({
userId: newUserId,
});
if (!newUser) {
throw new Error(`User ${newUserId} not found`);
}
const originalUser = await this.userRepository.findByIdWithCredentialsAndCalendar({
userId: originalBooking.userId ?? 0,
});
if (!originalUser) {
throw new Error("Original booking user not found");
}
const originalBookingFull = await this.bookingRepository.findByIdWithAttendeesPaymentAndReferences(
bookingId
);
if (!originalBookingFull) {
throw new Error("Original booking not found");
}
const { getTranslation } = await import("@calcom/lib/server/i18n");
const newUserT = await getTranslation(newUser.locale ?? "en", "common");
const originalUserT = await getTranslation(originalUser.locale ?? "en", "common");
return {
parentEventType,
currentEventTypeDetails,
targetEventTypeDetails,
originalUser,
newUser,
originalBookingFull,
originalUserT,
newUserT,
};
}
private async executeBookingReassignmentTransaction({
originalBookingFull,
newBookingPlan,
newUser,
reassignLogger,
}: {
originalBookingFull: NonNullable<
Awaited<ReturnType<BookingRepository["findByIdWithAttendeesPaymentAndReferences"]>>
>;
newBookingPlan: Parameters<BookingRepository["managedEventReassignmentTransaction"]>[0]["newBookingPlan"];
newUser: { name: string | null; email: string };
reassignLogger: typeof logger;
}) {
const reassignmentResult = await this.bookingRepository.managedEventReassignmentTransaction({
bookingId: originalBookingFull.id,
cancellationReason: `Reassigned to ${newUser.name || newUser.email}`,
metadata:
typeof originalBookingFull.metadata === "object" && originalBookingFull.metadata !== null
? (originalBookingFull.metadata as Record<string, unknown>)
: undefined,
newBookingPlan,
});
const newBooking: ManagedEventReassignmentCreatedBooking = reassignmentResult.newBooking;
const cancelledBooking: ManagedEventCancellationResult = reassignmentResult.cancelledBooking;
reassignLogger.info("Booking duplication completed", {
originalBookingId: cancelledBooking.id,
newBookingId: newBooking.id,
});
return { newBooking, cancelledBooking };
}
/**
* Removes calendar events from the original user's calendar
*/
private async removeOriginalCalendarEvents({
originalUser,
currentEventTypeDetails,
originalBookingFull,
originalUserT,
apps,
logger,
}: {
originalUser: NonNullable<Awaited<ReturnType<UserRepository["findByIdWithCredentialsAndCalendar"]>>>;
currentEventTypeDetails: NonNullable<Awaited<ReturnType<typeof getEventTypesFromDB>>>;
originalBookingFull: NonNullable<
Awaited<ReturnType<BookingRepository["findByIdWithAttendeesPaymentAndReferences"]>>
>;
originalUserT: Awaited<ReturnType<typeof import("@calcom/lib/server/i18n")["getTranslation"]>>;
apps: ReturnType<typeof eventTypeAppMetadataOptionalSchema.parse>;
logger: ReturnType<typeof loggerType.getSubLogger>;
}) {
if (!originalUser) return;
const originalUserCredentials = await getAllCredentialsIncludeServiceAccountKey(
originalUser,
currentEventTypeDetails
);
const originalEventManager = new EventManager(
{ ...originalUser, credentials: originalUserCredentials },
apps
);
try {
await originalEventManager.deleteEventsAndMeetings({
event: {
type: currentEventTypeDetails.slug,
organizer: {
id: originalUser.id,
name: originalUser.name || "",
email: originalUser.email,
timeZone: originalUser.timeZone,
language: { translate: originalUserT, locale: originalUser.locale ?? "en" },
},
startTime: dayjs(originalBookingFull.startTime).utc().format(),
endTime: dayjs(originalBookingFull.endTime).utc().format(),
title: originalBookingFull.title,
uid: originalBookingFull.uid,
attendees: [],
iCalUID: originalBookingFull.iCalUID,
},
bookingReferences: originalBookingFull.references,
});
logger.info("Deleted calendar events from original user");
} catch (error) {
logger.error("Error deleting calendar events", error);
}
}
/**
* Creates calendar events for the new user and updates the booking with location/video details
*/
private async createCalendarEventsForNewUser({
newUser,
newUserT,
targetEventTypeDetails,
newBooking,
originalBookingFull,
apps,
logger,
}: {
newUser: NonNullable<Awaited<ReturnType<UserRepository["findByIdWithCredentialsAndCalendar"]>>>;
newUserT: Awaited<ReturnType<typeof import("@calcom/lib/server/i18n")["getTranslation"]>>;
targetEventTypeDetails: NonNullable<Awaited<ReturnType<typeof getEventTypesFromDB>>>;
newBooking: ManagedEventReassignmentCreatedBooking;
originalBookingFull: NonNullable<
Awaited<ReturnType<BookingRepository["findByIdWithAttendeesPaymentAndReferences"]>>
>;
apps: ReturnType<typeof eventTypeAppMetadataOptionalSchema.parse>;
logger: ReturnType<typeof loggerType.getSubLogger>;
}): Promise<{
bookingLocation: string | null;
videoCallUrl: string | null;
videoCallData: CalendarEvent["videoCallData"];
additionalInformation: AdditionalInformation;
}> {
const newUserCredentialsWithServiceAccountKey = await getAllCredentialsIncludeServiceAccountKey(
newUser,
targetEventTypeDetails
);
const newUserForEventManager = {
...newUser,
credentials: newUserCredentialsWithServiceAccountKey,
destinationCalendar: newUser.destinationCalendar,
};
const newEventManager = new EventManager(newUserForEventManager, apps);
let bookingLocation = originalBookingFull.location || newBooking.location || null;
let conferenceCredentialId: number | null = null;
const isManagedEventType = !!targetEventTypeDetails.parentId;
const isTeamEventType = !!targetEventTypeDetails.team;
const locationResult = BookingLocationService.getLocationForHost({
hostMetadata: newUser.metadata,
eventTypeLocations: targetEventTypeDetails.locations || [],
isManagedEventType,
isTeamEventType,
});
bookingLocation = locationResult.bookingLocation;
if (locationResult.requiresActualLink) {
conferenceCredentialId = locationResult.conferenceCredentialId;
}
let videoCallUrl: string | null = null;
let videoCallData: CalendarEvent["videoCallData"] = undefined;
const additionalInformation: AdditionalInformation = {};
try {
const bookerUrl = await getBookerBaseUrl(targetEventTypeDetails.team?.parentId ?? null);
const attendees = newBooking.attendees.map(
(att: { name: string; email: string; timeZone: string; locale: string | null }) => ({
name: att.name,
email: att.email,
timeZone: att.timeZone,
language: { translate: newUserT, locale: att.locale ?? "en" },
})
);
const builder = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl,
title: newBooking.title,
startTime: dayjs(newBooking.startTime).utc().format(),
endTime: dayjs(newBooking.endTime).utc().format(),
additionalNotes: newBooking.description || undefined,
})
.withEventType({
id: targetEventTypeDetails.id,
slug: targetEventTypeDetails.slug,
description: newBooking.description,
hideOrganizerEmail: targetEventTypeDetails.hideOrganizerEmail,
schedulingType: targetEventTypeDetails.schedulingType,
})
.withOrganizer({
id: newUser.id,
name: newUser.name,
email: newUser.email,
timeZone: newUser.timeZone,
language: { translate: newUserT, locale: newUser.locale ?? "en" },
})
.withAttendees(attendees)
.withLocation({
location: bookingLocation || null,
conferenceCredentialId: conferenceCredentialId ?? undefined,
})
.withIdentifiers({
iCalUID: newBooking.iCalUID || undefined,
})
.withUid(newBooking.uid);
if (newUser.destinationCalendar) {
builder.withDestinationCalendar([newUser.destinationCalendar]);
}
if (targetEventTypeDetails.team) {
builder.withTeam({
id: targetEventTypeDetails.team.id || 0,
name: targetEventTypeDetails.team.name || "",
members: [
{
id: newUser.id,
name: newUser.name || "",
email: newUser.email,
timeZone: newUser.timeZone,
language: { translate: newUserT, locale: newUser.locale ?? "en" },
},
],
});
}
const evt = builder.build();
if (!evt) {
throw new Error("Failed to build CalendarEvent");
}
const createManager = await newEventManager.create(evt);
const results = createManager.results || [];
const referencesToCreate = createManager.referencesToCreate || [];
if (results.length > 0) {
const allFailed = results.every((res) => !res.success);
const someFailed = results.some((res) => !res.success);
if (allFailed) {
logger.error("All calendar integrations failed during reassignment", {
eventTypeId: targetEventTypeDetails.id,
eventTypeSlug: targetEventTypeDetails.slug,
userId: newUser.id,
results: results.map((res) => ({
type: res.type,
success: res.success,
error: res.error,
})),
});
} else if (someFailed) {
logger.warn("Some calendar integrations failed during reassignment", {
eventTypeId: targetEventTypeDetails.id,
userId: newUser.id,
failedIntegrations: results
.filter((res) => !res.success)
.map((res) => ({
type: res.type,
error: res.error,
})),
});
}
}
videoCallUrl = evt.videoCallData?.url ?? null;
if (results.length) {
const createdEvent = results[0]?.createdEvent;
additionalInformation.hangoutLink = createdEvent?.hangoutLink;
additionalInformation.conferenceData = createdEvent?.conferenceData;
additionalInformation.entryPoints = createdEvent?.entryPoints;
evt.additionalInformation = additionalInformation;
videoCallUrl =
additionalInformation.hangoutLink ||
createdEvent?.url ||
getVideoCallUrlFromCalEvent(evt) ||
videoCallUrl;
}
videoCallData = evt.videoCallData;
try {
const responses = {
...(typeof newBooking.responses === "object" && newBooking.responses),
location: {
value: bookingLocation,
optionValue: "",
},
};
const finalVideoCallUrlForMetadata = videoCallUrl
? getVideoCallUrlFromCalEvent(evt) || videoCallUrl
: null;
const bookingMetadataUpdate = finalVideoCallUrlForMetadata
? { videoCallUrl: finalVideoCallUrlForMetadata }
: {};
const referencesToCreateForDb = referencesToCreate.map((reference) => {
const { credentialId, ...restReference } = reference;
return {
...restReference,
...(credentialId && credentialId > 0 ? { credentialId } : {}),
};
});
await this.bookingRepository.updateLocationById({
where: { id: newBooking.id },
data: {
location: bookingLocation,
metadata: {
...(typeof newBooking.metadata === "object" && newBooking.metadata
? newBooking.metadata
: {}),
...bookingMetadataUpdate,
},
referencesToCreate: referencesToCreateForDb,
responses,
iCalSequence: (newBooking.iCalSequence || 0) + 1,
},
});
logger.info("Updated booking location and created calendar references", {
referencesCount: referencesToCreate.length,
videoCallUrl: bookingMetadataUpdate.videoCallUrl ? "[redacted]" : null,
});
} catch (error) {
logger.error("Error updating booking location and references", error);
}
logger.info("Created calendar events for new user");
} catch (error) {
logger.error("Error creating calendar events for new user", error);
}
return {
bookingLocation,
videoCallUrl,
videoCallData,
additionalInformation,
};
}
/**
* Sends reassignment notification emails to all parties
*/
private async sendReassignmentNotifications({
newBooking,
targetEventTypeDetails,
parentEventType,
newUser,
newUserT,
originalUser,
originalUserT,
bookingLocation,
videoCallData,
additionalInformation,
reassignReason,
logger,
}: {
newBooking: ManagedEventReassignmentCreatedBooking;
targetEventTypeDetails: NonNullable<Awaited<ReturnType<typeof getEventTypesFromDB>>>;
parentEventType: Awaited<ReturnType<typeof findTargetChildEventType>>["parentEventType"];
newUser: NonNullable<Awaited<ReturnType<UserRepository["findByIdWithCredentialsAndCalendar"]>>>;
newUserT: Awaited<ReturnType<typeof import("@calcom/lib/server/i18n")["getTranslation"]>>;
originalUser: NonNullable<Awaited<ReturnType<UserRepository["findByIdWithCredentialsAndCalendar"]>>>;
originalUserT: Awaited<ReturnType<typeof import("@calcom/lib/server/i18n")["getTranslation"]>>;
bookingLocation: string | null;
videoCallData: CalendarEvent["videoCallData"];
additionalInformation: AdditionalInformation;
reassignReason?: string;
logger: ReturnType<typeof loggerType.getSubLogger>;
}) {
try {
const eventTypeMetadata = targetEventTypeDetails.metadata as EventTypeMetadata | undefined;
const bookerUrlForEmail = await getBookerBaseUrl(targetEventTypeDetails.team?.parentId ?? null);
const attendeesForEmail = newBooking.attendees.map(
(att: { name: string; email: string; timeZone: string; locale: string | null }) => ({
name: att.name,
email: att.email,
timeZone: att.timeZone,
language: { translate: newUserT, locale: att.locale ?? "en" },
})
);
const emailBuilder = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl: bookerUrlForEmail,
title: newBooking.title,
startTime: dayjs(newBooking.startTime).utc().format(),
endTime: dayjs(newBooking.endTime).utc().format(),
additionalNotes: newBooking.description || undefined,
})
.withEventType({
id: targetEventTypeDetails.id,
slug: targetEventTypeDetails.slug,
description: newBooking.description,
schedulingType: parentEventType.schedulingType,
})
.withOrganizer({
id: newUser.id,
name: newUser.name,
email: newUser.email,
timeZone: newUser.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale ?? "en" },
})
.withAttendees(attendeesForEmail)
.withLocation({
location: bookingLocation || null,
})
.withUid(newBooking.uid);
if (videoCallData) {
emailBuilder.withVideoCallData(videoCallData);
}
const calEvent = emailBuilder.build();
if (!calEvent) {
throw new Error("Failed to build CalendarEvent for emails");
}
calEvent.additionalInformation = additionalInformation;
await sendReassignedScheduledEmailsAndSMS({
calEvent,
members: [
{
...newUser,
name: newUser.name || "",
username: newUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale || "en" },
},
],
eventTypeMetadata,
reassigned: {
name: newUser.name,
email: newUser.email,
reason: reassignReason,
byUser: originalUser.name || undefined,
},
});
logger.info("Sent scheduled email to new host");
if (originalUser) {
const cancelledCalEvent = {
...calEvent,
organizer: {
id: originalUser.id,
name: originalUser.name || "",
email: originalUser.email,
timeZone: originalUser.timeZone,
language: { translate: originalUserT, locale: originalUser.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(originalUser.timeFormat),
},
};
await sendReassignedEmailsAndSMS({
calEvent: cancelledCalEvent,
members: [
{
...originalUser,
name: originalUser.name || "",
username: originalUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(originalUser.timeFormat),
language: { translate: originalUserT, locale: originalUser.locale || "en" },
},
],
reassignedTo: { name: newUser.name, email: newUser.email },
eventTypeMetadata,
});
logger.info("Sent reassignment email to original host");
}
if (dayjs(calEvent.startTime).isAfter(dayjs())) {
await sendReassignedUpdatedEmailsAndSMS({
calEvent,
eventTypeMetadata,
});
logger.info("Sent update emails to attendees");
}
} catch (error) {
logger.error("Error sending notification emails", error);
}
}
/**
* Records the assignment reason for the reassignment
*/
private async recordAssignmentReason({
newBookingId,
reassignedById,
reassignReason,
isAutoReassignment,
logger,
}: {
newBookingId: number;
reassignedById: number;
reassignReason?: string;
isAutoReassignment: boolean;
logger: ReturnType<typeof loggerType.getSubLogger>;
}) {
try {
const assignmentResult = await this.assignmentReasonService.recordReassignment({
newBookingId,
reassignById: reassignedById,
reassignReason,
reassignmentType: isAutoReassignment
? ManagedEventReassignmentType.AUTO
: ManagedEventReassignmentType.MANUAL,
});
logger.info("Recorded assignment reason", assignmentResult);
} catch (error) {
logger.error("Error recording assignment reason", error);
}
}
}
@@ -0,0 +1,219 @@
import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import dayjs from "@calcom/dayjs";
import type { LuckyUserService } from "@calcom/features/bookings/lib/getLuckyUser";
import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import logger from "@calcom/lib/logger";
import { SchedulingType } from "@calcom/prisma/enums";
import { managedEventManualReassignment } from "../managedEventManualReassignment";
import { validateManagedEventReassignment } from "../utils";
interface ManagedEventReassignmentParams {
bookingId: number;
orgId: number | null;
reassignReason?: string;
reassignedById: number;
emailsEnabled?: boolean;
}
interface ManagedEventTypeChain {
currentChild: {
id: number;
parentId: number | null;
userId: number | null;
};
parent: Awaited<ReturnType<typeof getEventTypesFromDB>>;
}
interface ManagedEventReassignmentServiceDeps {
bookingRepository: BookingRepository;
eventTypeRepository: EventTypeRepository;
userRepository: UserRepository;
luckyUserService: LuckyUserService;
}
export class ManagedEventReassignmentService {
private readonly log: typeof logger;
private readonly bookingRepository: BookingRepository;
private readonly eventTypeRepository: EventTypeRepository;
private readonly userRepository: UserRepository;
private readonly luckyUserService: LuckyUserService;
constructor(deps: ManagedEventReassignmentServiceDeps) {
this.bookingRepository = deps.bookingRepository;
this.eventTypeRepository = deps.eventTypeRepository;
this.userRepository = deps.userRepository;
this.luckyUserService = deps.luckyUserService;
this.log = logger.getSubLogger({ prefix: ["ManagedEventReassignmentService"] });
}
async executeAutoReassignment({
bookingId,
orgId,
reassignReason = "Auto-reassigned to another team member",
reassignedById,
emailsEnabled = true,
}: ManagedEventReassignmentParams) {
const reassignLogger = this.log.getSubLogger({ prefix: [`booking:${bookingId}`] });
reassignLogger.info(`User ${reassignedById} initiating auto-reassignment`);
await validateManagedEventReassignment({ bookingId, bookingRepository: this.bookingRepository });
const booking = await this.fetchAndValidateBookingForReassignment(bookingId);
const { currentChild, parent } = await this.fetchManagedEventTypeChain(
booking.eventTypeId!,
reassignLogger
);
const eligibleUsers = await this.findEligibleReassignmentUsers(
currentChild.parentId!,
currentChild.userId,
orgId,
reassignLogger
);
const availableUsers = await ensureAvailableUsers(
{ ...parent, users: eligibleUsers },
{
dateFrom: dayjs(booking.startTime).format(),
dateTo: dayjs(booking.endTime).format(),
timeZone: parent.timeZone || eligibleUsers[0]?.timeZone || "UTC",
},
reassignLogger
);
reassignLogger.info(`${availableUsers.length} users available at booking time`);
const selectedUser = await this.selectReassignmentUser(availableUsers, parent, reassignLogger);
return await managedEventManualReassignment({
bookingId,
newUserId: selectedUser.id,
orgId,
reassignReason,
reassignedById,
emailsEnabled,
isAutoReassignment: true,
});
}
private async fetchAndValidateBookingForReassignment(bookingId: number) {
const booking = await this.bookingRepository.findByIdForReassignment(bookingId);
if (!booking || !booking.eventTypeId) {
throw new Error("Booking or event type not found");
}
return booking;
}
private async fetchManagedEventTypeChain(
eventTypeId: number,
log: typeof logger
): Promise<ManagedEventTypeChain> {
const currentChildEventType = await this.eventTypeRepository.findByIdWithParent(eventTypeId);
if (!currentChildEventType || !currentChildEventType.parentId) {
throw new Error("Booking is not on a managed event type");
}
const parentEventType = await getEventTypesFromDB(currentChildEventType.parentId);
if (!parentEventType) {
throw new Error("Parent event type not found");
}
if (parentEventType.schedulingType !== SchedulingType.MANAGED) {
throw new Error("Parent event type must be a MANAGED type");
}
log.info("Found parent managed event type", {
parentId: parentEventType.id,
currentChildId: currentChildEventType.id,
});
return {
currentChild: currentChildEventType,
parent: parentEventType,
};
}
private async findEligibleReassignmentUsers(
parentId: number,
currentUserId: number | null,
orgId: number | null,
log: typeof logger
): Promise<IsFixedAwareUser[]> {
const allChildEventTypes = await this.eventTypeRepository.findManyChildEventTypes(
parentId,
currentUserId
);
const userIds = allChildEventTypes.map((et) => et.userId).filter((id): id is number => id !== null);
if (userIds.length === 0) {
throw new Error("No other users available for reassignment in this managed event");
}
const usersWithSelectedCalendars =
await this.userRepository.findManyByIdsWithCredentialsAndSelectedCalendars({
userIds,
});
log.info(`Found ${usersWithSelectedCalendars.length} potential reassignment targets`);
const enrichedUsers = await enrichUsersWithDelegationCredentials({
orgId,
users: usersWithSelectedCalendars,
});
const usersWithSchedules = enrichedUsers.filter((user) => {
const hasSchedules = user.schedules && user.schedules.length > 0;
if (!hasSchedules) {
log.warn(`User ${user.id} skipped: no schedules configured`);
}
return hasSchedules;
});
if (usersWithSchedules.length === 0) {
throw new Error(
"No eligible users found for reassignment. All team members must have availability schedules configured."
);
}
log.info(`${usersWithSchedules.length} users with schedules configured`);
return usersWithSchedules.map((user) => ({
...user,
isFixed: false as const,
})) as unknown as IsFixedAwareUser[];
}
private async selectReassignmentUser(
availableUsers: [IsFixedAwareUser, ...IsFixedAwareUser[]],
parentEventType: NonNullable<Awaited<ReturnType<typeof getEventTypesFromDB>>>,
log: typeof logger
): Promise<IsFixedAwareUser> {
const luckyUser: IsFixedAwareUser = await this.luckyUserService.getLuckyUser({
availableUsers,
eventType: parentEventType,
allRRHosts: [],
routingFormResponse: null,
});
if (!luckyUser) {
throw new Error("Failed to select a user for reassignment");
}
log.info(`Selected user ${luckyUser.id} for reassignment`);
return luckyUser;
}
}
@@ -0,0 +1,100 @@
import type { PrismaClient } from "@calcom/prisma";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import type { LuckyUserService } from "@calcom/features/bookings/lib/getLuckyUser";
import { ManagedEventManualReassignmentService } from "./ManagedEventManualReassignmentService";
import { ManagedEventReassignmentService } from "./ManagedEventReassignmentService";
import { ManagedEventAssignmentReasonService } from "./ManagedEventAssignmentReasonRecorder";
/**
* Dependency Injection Container for Reassignment Services
*
* This ensures:
* - Repositories are only instantiated once per request
* - Services receive explicit dependencies via constructor
* - No direct `new Repository(prisma)` calls in business logic
*/
export class ReassignmentServiceContainer {
private readonly prisma: PrismaClient;
private readonly bookingRepository: BookingRepository;
private readonly userRepository: UserRepository;
private readonly eventTypeRepository: EventTypeRepository;
private readonly assignmentReasonRepository: AssignmentReasonRepository;
private readonly assignmentReasonService: ManagedEventAssignmentReasonService;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
this.bookingRepository = new BookingRepository(prisma);
this.userRepository = new UserRepository(prisma);
this.eventTypeRepository = new EventTypeRepository(prisma);
this.assignmentReasonRepository = new AssignmentReasonRepository(prisma);
this.assignmentReasonService = new ManagedEventAssignmentReasonService({
userRepository: this.userRepository,
assignmentReasonRepository: this.assignmentReasonRepository,
});
}
/**
* Creates an instance of ManagedEventManualReassignmentService
* with all required dependencies injected
*/
getManagedEventManualReassignmentService(): ManagedEventManualReassignmentService {
return new ManagedEventManualReassignmentService({
prisma: this.prisma,
bookingRepository: this.bookingRepository,
userRepository: this.userRepository,
eventTypeRepository: this.eventTypeRepository,
assignmentReasonService: this.assignmentReasonService,
});
}
/**
* Creates an instance of ManagedEventReassignmentService
* with all required dependencies injected
* @param luckyUserService - The lucky user service for round-robin selection
*/
getManagedEventReassignmentService(luckyUserService: LuckyUserService): ManagedEventReassignmentService {
return new ManagedEventReassignmentService({
bookingRepository: this.bookingRepository,
userRepository: this.userRepository,
eventTypeRepository: this.eventTypeRepository,
luckyUserService,
});
}
/**
* Access to repositories for utilities that need them
* These should be passed as parameters to pure functions
*/
getRepositories() {
return {
bookingRepository: this.bookingRepository,
userRepository: this.userRepository,
eventTypeRepository: this.eventTypeRepository,
};
}
}
/**
* Factory function for creating the reassignment service
* This is the main entry point for external code
*/
export function createManagedEventManualReassignmentService(prisma: PrismaClient) {
const container = new ReassignmentServiceContainer(prisma);
return container.getManagedEventManualReassignmentService();
}
/**
* Factory function for creating the auto-reassignment service
* @param luckyUserService - The lucky user service for round-robin selection
*/
export function createManagedEventReassignmentService(
prisma: PrismaClient,
luckyUserService: LuckyUserService
) {
const container = new ReassignmentServiceContainer(prisma);
return container.getManagedEventReassignmentService(luckyUserService);
}
@@ -0,0 +1,96 @@
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import dayjs from "@calcom/dayjs";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
import { APP_NAME } from "@calcom/lib/constants";
const translator = short();
interface BuildNewBookingPlanParams {
originalBookingFull: NonNullable<
Awaited<ReturnType<BookingRepository["findByIdWithAttendeesPaymentAndReferences"]>>
>;
targetEventTypeDetails: NonNullable<Awaited<ReturnType<typeof getEventTypesFromDB>>>;
newUser: NonNullable<Awaited<ReturnType<UserRepository["findByIdWithCredentialsAndCalendar"]>>>;
newUserT: Awaited<ReturnType<typeof import("@calcom/lib/server/i18n")["getTranslation"]>>;
reassignedById: number;
}
export function buildNewBookingPlan({
originalBookingFull,
targetEventTypeDetails,
newUser,
newUserT,
reassignedById,
}: BuildNewBookingPlanParams) {
const bookingFields =
typeof originalBookingFull.responses === "object" &&
originalBookingFull.responses !== null &&
!Array.isArray(originalBookingFull.responses)
? originalBookingFull.responses
: null;
const newBookingTitle = getEventName({
attendeeName: originalBookingFull.attendees[0]?.name || "Nameless",
eventType: targetEventTypeDetails.title,
eventName: targetEventTypeDetails.eventName,
teamName: targetEventTypeDetails.team?.name,
host: newUser.name || "Nameless",
location: originalBookingFull.location || "",
bookingFields,
eventDuration: dayjs(originalBookingFull.endTime).diff(originalBookingFull.startTime, "minutes"),
t: newUserT,
});
const uidSeed = `${newUser.username || "user"}:${dayjs(originalBookingFull.startTime).utc().format()}:${Date.now()}:reassignment`;
const generatedUid = translator.fromUUID(uuidv5(uidSeed, uuidv5.URL));
return {
uid: generatedUid,
userId: newUser.id,
userPrimaryEmail: newUser.email,
title: newBookingTitle,
description: originalBookingFull.description,
startTime: originalBookingFull.startTime,
endTime: originalBookingFull.endTime,
status: originalBookingFull.status,
location: originalBookingFull.location,
smsReminderNumber: originalBookingFull.smsReminderNumber,
responses: originalBookingFull.responses === null ? undefined : originalBookingFull.responses,
customInputs:
typeof originalBookingFull.customInputs === "object" &&
originalBookingFull.customInputs !== null &&
!Array.isArray(originalBookingFull.customInputs)
? (originalBookingFull.customInputs as Record<string, unknown>)
: undefined,
metadata:
typeof originalBookingFull.metadata === "object" && originalBookingFull.metadata !== null
? (originalBookingFull.metadata as Record<string, unknown>)
: undefined,
idempotencyKey: IdempotencyKeyService.generate({
startTime: originalBookingFull.startTime,
endTime: originalBookingFull.endTime,
userId: newUser.id,
reassignedById,
}),
eventTypeId: targetEventTypeDetails.id,
attendees: originalBookingFull.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.locale,
phoneNumber: attendee.phoneNumber ?? null,
})),
paymentId:
originalBookingFull.payment.length > 0 && originalBookingFull.payment[0]?.id
? originalBookingFull.payment[0]!.id
: undefined,
iCalUID: `${generatedUid}@${APP_NAME}`,
iCalSequence: 0,
};
}
@@ -0,0 +1,112 @@
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { SchedulingType } from "@calcom/prisma/enums";
interface FindTargetChildEventTypeParams {
bookingId: number;
newUserId: number;
bookingRepository: BookingRepository;
eventTypeRepository: EventTypeRepository;
}
interface FindTargetChildEventTypeResult {
currentChildEventType: {
id: number;
parentId: number;
userId: number | null;
};
parentEventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
targetChildEventType: {
id: number;
parentId: number;
userId: number | null;
};
originalBooking: {
id: number;
eventTypeId: number | null;
userId: number | null;
};
}
/**
* Pure function that finds and validates the target child event type for managed event reassignment
*
* Dependencies are injected via parameters - no direct instantiation of repositories
*
* @param bookingRepository - Injected booking repository
* @param eventTypeRepository - Injected event type repository
* @throws Error if booking is not on a managed event type
* @throws Error if parent is not MANAGED type
* @throws Error if target user doesn't have a child event type
* @returns Current child, parent, and target child event types
*/
export async function findTargetChildEventType({
bookingId,
newUserId,
bookingRepository,
eventTypeRepository,
}: FindTargetChildEventTypeParams): Promise<FindTargetChildEventTypeResult> {
const booking = await bookingRepository.findByIdForWithUserIdAndEventTypeId(bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (!booking.eventTypeId) {
throw new Error("Booking does not have an event type");
}
const currentChildEventType = await eventTypeRepository.findByIdWithParentAndUserId(booking.eventTypeId);
if (!currentChildEventType) {
throw new Error("Event type not found");
}
if (!currentChildEventType.parentId) {
throw new Error("Booking is not on a managed event type");
}
const parentEventType = await getEventTypesFromDB(currentChildEventType.parentId);
if (!parentEventType) {
throw new Error("Parent event type not found");
}
if (parentEventType.schedulingType !== SchedulingType.MANAGED) {
throw new Error("Parent event type must be a MANAGED type");
}
const targetChildEventType = await eventTypeRepository.findByIdTargetChildEventType(newUserId, currentChildEventType.parentId);
if (!targetChildEventType) {
throw new Error(
`User ${newUserId} does not have a child event type for this managed event. ` +
`Only users who are assigned to the parent managed event can be reassigned to.`
);
}
if (currentChildEventType.userId === newUserId) {
throw new Error("Cannot reassign to the same user");
}
if (!targetChildEventType.parentId) {
throw new Error("Target child event type is missing parentId");
}
return {
currentChildEventType: {
id: currentChildEventType.id,
parentId: currentChildEventType.parentId,
userId: currentChildEventType.userId,
},
parentEventType,
targetChildEventType: {
id: targetChildEventType.id,
parentId: targetChildEventType.parentId,
userId: targetChildEventType.userId,
},
originalBooking: booking,
};
}
@@ -0,0 +1,3 @@
export { findTargetChildEventType } from "./findTargetChildEventType";
export { validateManagedEventReassignment } from "./validateManagedEventReassignment";
export { buildNewBookingPlan } from "./buildNewBookingPlan";
@@ -0,0 +1,55 @@
import { BookingStatus } from "@calcom/prisma/enums";
import { type BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
interface ValidateReassignmentParams {
bookingId: number;
bookingRepository: BookingRepository;
}
interface ValidateReassignmentResult {
booking: {
id: number;
status: BookingStatus;
recurringEventId: string | null;
startTime: Date;
endTime: Date;
};
}
/**
* Validates that a booking can be reassigned
*
* @throws Error if booking is already cancelled
* @throws Error if booking has already ended
* @throws Error if booking is recurring (Phase 1 limitation)
*/
export async function validateManagedEventReassignment({
bookingId,
bookingRepository,
}: ValidateReassignmentParams): Promise<ValidateReassignmentResult> {
const booking = await bookingRepository.findByIdForReassignmentValidation(bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === BookingStatus.CANCELLED) {
throw new Error("Cannot reassign already cancelled booking");
}
if (booking.endTime && new Date() > new Date(booking.endTime)) {
throw new Error("Cannot reassign a booking that has already ended");
}
if (booking.recurringEventId) {
throw new Error(
"Reassignment of recurring bookings is not yet supported for managed events"
);
}
return { booking };
}
@@ -465,9 +465,9 @@ describe("roundRobinManualReassignment test", () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
await mockEventManagerReschedule();
const sendRoundRobinReassignedEmailsAndSMSSpy = vi.spyOn(
const sendReassignedEmailsAndSMSSpy = vi.spyOn(
await import("@calcom/emails/email-manager"),
"sendRoundRobinReassignedEmailsAndSMS"
"sendReassignedEmailsAndSMS"
);
const testDestinationCalendar = createTestDestinationCalendar();
@@ -523,7 +523,7 @@ describe("roundRobinManualReassignment test", () => {
reassignedById: 1,
});
expect(sendRoundRobinReassignedEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
expect(sendReassignedEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
});
});
@@ -4,9 +4,9 @@ import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@ca
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
sendRoundRobinReassignedEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinUpdatedEmailsAndSMS,
sendReassignedEmailsAndSMS,
sendReassignedScheduledEmailsAndSMS,
sendReassignedUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
@@ -302,6 +302,7 @@ export const roundRobinManualReassignment = async ({
name: eventType.team?.name || "",
id: eventType.team?.id || 0,
},
schedulingType: eventType.schedulingType,
hideOrganizerEmail: eventType.hideOrganizerEmail,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
@@ -391,7 +392,7 @@ export const roundRobinManualReassignment = async ({
// Send emails
if (emailsEnabled) {
await sendRoundRobinScheduledEmailsAndSMS({
await sendReassignedScheduledEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
members: [
{
@@ -421,7 +422,7 @@ export const roundRobinManualReassignment = async ({
};
if (previousRRHost && emailsEnabled) {
await sendRoundRobinReassignedEmailsAndSMS({
await sendReassignedEmailsAndSMS({
calEvent: cancelledEvt,
members: [
{
@@ -440,7 +441,7 @@ export const roundRobinManualReassignment = async ({
if (hasOrganizerChanged) {
if (emailsEnabled && dayjs(evt.startTime).isAfter(dayjs())) {
// send email with event updates to attendees
await sendRoundRobinUpdatedEmailsAndSMS({
await sendReassignedUpdatedEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
});
@@ -8,9 +8,9 @@ import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@cal
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
sendRoundRobinReassignedEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinUpdatedEmailsAndSMS,
sendReassignedEmailsAndSMS,
sendReassignedScheduledEmailsAndSMS,
sendReassignedUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
@@ -330,6 +330,7 @@ export const roundRobinReassignment = async ({
name: eventType.team?.name || "",
id: eventType.team?.id || 0,
},
schedulingType: eventType.schedulingType,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
bookingFields: eventType?.bookingFields ?? null,
@@ -420,7 +421,7 @@ export const roundRobinReassignment = async ({
// Send to new RR host
if (emailsEnabled) {
await sendRoundRobinScheduledEmailsAndSMS({
await sendReassignedScheduledEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
members: [
{
@@ -471,7 +472,7 @@ export const roundRobinReassignment = async ({
}
if (emailsEnabled) {
await sendRoundRobinReassignedEmailsAndSMS({
await sendReassignedEmailsAndSMS({
calEvent: cancelledRRHostEvt,
members: [
{
@@ -492,7 +493,7 @@ export const roundRobinReassignment = async ({
if (hasOrganizerChanged) {
if (emailsEnabled && dayjs(evt.startTime).isAfter(dayjs())) {
// send email with event updates to attendees
await sendRoundRobinUpdatedEmailsAndSMS({
await sendReassignedUpdatedEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
});
@@ -6,7 +6,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { eventTypeSelect } from "@calcom/lib/server/eventTypeSelect";
import type { PrismaClient } from "@calcom/prisma";
import { prisma, availabilityUserSelect } from "@calcom/prisma";
import { prisma, availabilityUserSelect, userSelect as userSelectWithSelectedCalendars } from "@calcom/prisma";
import type { EventType as PrismaEventType } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -1527,4 +1527,142 @@ export class EventTypeRepository {
},
});
}
async findByIdWithParent(eventTypeId: number) {
return this.prismaClient.eventType.findUnique({
where: { id: eventTypeId },
select: {
id: true,
parentId: true,
userId: true,
},
});
}
async findManyChildEventTypes(parentId: number, excludeUserId?: number | null) {
return this.prismaClient.eventType.findMany({
where: {
parentId,
...(excludeUserId !== undefined ? { userId: { not: excludeUserId } } : {}),
},
select: {
id: true,
userId: true,
},
});
}
async findManyWithPagination(params: {
where: Prisma.EventTypeWhereInput;
skip: number;
take: number;
orderBy?: Prisma.EventTypeOrderByWithRelationInput;
}) {
const [eventTypes, total] = await Promise.all([
this.prismaClient.eventType.findMany({
where: params.where,
skip: params.skip,
take: params.take,
orderBy: params.orderBy,
}),
this.prismaClient.eventType.count({ where: params.where }),
]);
return { eventTypes, total };
}
/**
* List child event types for a given parent.
* Supports search, user exclusion, cursor pagination.
*/
async listChildEventTypes({
parentEventTypeId,
excludeUserId,
searchTerm,
limit,
cursor,
}: {
parentEventTypeId: number;
excludeUserId?: number | null;
searchTerm?: string | null;
limit: number;
cursor?: number | null;
}) {
// Build where clause explicitly to avoid type issues with conditional spreads
const eventTypeWhere = {
parentId: parentEventTypeId,
...(excludeUserId ? { userId: { not: excludeUserId } } : {}),
...(searchTerm ? {
owner: {
OR: [
{ name: { contains: searchTerm, mode: "insensitive" as const } },
{ email: { contains: searchTerm, mode: "insensitive" as const } },
],
},
} : {}),
};
// Extract query to preserve type inference
const rowsQuery = this.prismaClient.eventType.findMany({
where: eventTypeWhere,
select: {
id: true,
userId: true,
owner: {
select: {
...userSelectWithSelectedCalendars,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
},
},
take: limit + 1, // over-fetch for nextCursor
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { id: "asc" }, // deterministic pagination
});
const [totalCount, rows] = await Promise.all([
this.prismaClient.eventType.count({ where: eventTypeWhere }),
rowsQuery,
]);
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
return {
totalCount,
items,
hasMore,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
}
async findByIdWithParentAndUserId(eventTypeId: number) {
return this.prismaClient.eventType.findUnique({
where: { id: eventTypeId },
select: {
id: true,
parentId: true,
userId: true,
schedulingType: true,
},
});
}
async findByIdTargetChildEventType(userId: number, parentId: number) {
return this.prismaClient.eventType.findUnique({
where: {
userId_parentId: {
userId,
parentId,
},
},
select: {
id: true,
parentId: true,
userId: true,
},
});
}
}
@@ -16,6 +16,7 @@ import { Prisma } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { MembershipRole, BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { userSelect as prismaUserSelect } from "@calcom/prisma/selects/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { UpId, UserProfile } from "@calcom/types/UserProfile";
@@ -1165,4 +1166,58 @@ export class UserRepository {
},
});
}
async findByIdWithCredentialsAndCalendar({ userId }: { userId: number }) {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
name: true,
timeZone: true,
locale: true,
timeFormat: true,
metadata: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
destinationCalendar: true,
},
});
}
/**
* Finds a user by ID returning only their username
* @param userId - The user ID
* @returns User with username or null
*/
async findByIdWithUsername(userId: number): Promise<{ username: string | null } | null> {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: { username: true },
});
}
async findManyByIdsWithCredentialsAndSelectedCalendars({ userIds }: { userIds: number[] }) {
const users = await this.prismaClient.user.findMany({
where: {
id: {
in: userIds,
},
},
select: {
...prismaUserSelect, // Use the proper userSelect from @calcom/prisma/selects/user which includes schedules
credentials: {
select: credentialForCalendarServiceSelect,
},
selectedCalendars: {
select: {
eventTypeId: true,
},
},
},
});
return users.map(withSelectedCalendars);
}
}
@@ -508,6 +508,7 @@ export async function getBookings({
"EventType.hideOrganizerEmail",
"EventType.disableCancelling",
"EventType.disableRescheduling",
"EventType.parentId",
eb
.cast<SchedulingType | null>(
eb
@@ -27,6 +27,9 @@ import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZResendInvitationInputSchema } from "./resendInvitation.schema";
import { ZGetManagedEventUsersToReassignInputSchema } from "./managedEvents/getManagedEventUsersToReassign.schema";
import { ZManagedEventManualReassignInputSchema } from "./managedEvents/managedEventManualReassign.schema";
import { ZManagedEventReassignInputSchema } from "./managedEvents/managedEventReassign.schema";
import { ZGetRoundRobinHostsInputSchema } from "./roundRobin/getRoundRobinHostsToReasign.schema";
import { ZRoundRobinManualReassignInputSchema } from "./roundRobin/roundRobinManualReassign.schema";
import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
@@ -161,6 +164,23 @@ export const viewerTeamsRouter = router({
const { default: handler } = await import("./roundRobin/getRoundRobinHostsToReasign.handler");
return handler(opts);
}),
// Managed Events Reassignment
managedEventReassign: authedProcedure.input(ZManagedEventReassignInputSchema).mutation(async (opts) => {
const { default: handler } = await import("./managedEvents/managedEventReassign.handler");
return handler(opts);
}),
managedEventManualReassign: authedProcedure
.input(ZManagedEventManualReassignInputSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./managedEvents/managedEventManualReassign.handler");
return handler(opts);
}),
getManagedEventUsersToReassign: authedProcedure
.input(ZGetManagedEventUsersToReassignInputSchema)
.query(async (opts) => {
const { default: handler } = await import("./managedEvents/getManagedEventUsersToReassign.handler");
return handler(opts);
}),
checkIfMembershipExists: authedProcedure
.input(ZCheckIfMembershipExistsInputSchema)
.mutation(async (opts) => {
@@ -0,0 +1,155 @@
import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import dayjs from "@calcom/dayjs";
import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TGetManagedEventUsersToReassignInputSchema } from "./getManagedEventUsersToReassign.schema";
type GetManagedEventUsersToReassignOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetManagedEventUsersToReassignInputSchema;
};
async function getManagedEventUsersFromDB({
parentEventTypeId,
organizationId,
prisma,
searchTerm,
cursor,
limit = 20,
excludeUserId,
}: {
parentEventTypeId: number;
organizationId: number | null;
prisma: PrismaClient;
searchTerm?: string;
cursor?: number;
limit?: number;
excludeUserId?: number;
}) {
const eventTypeRepository = new EventTypeRepository(prisma);
const { totalCount, items, hasMore, nextCursor } = await eventTypeRepository.listChildEventTypes({
parentEventTypeId,
excludeUserId,
searchTerm,
limit,
cursor,
});
const users = items
.filter((et): et is typeof et & { owner: NonNullable<typeof et.owner> } => et.owner !== null)
.map((et) => withSelectedCalendars(et.owner));
return {
users: await enrichUsersWithDelegationCredentials({
orgId: organizationId,
users,
}),
totalCount,
hasNextPage: hasMore,
nextCursor,
};
}
export const getManagedEventUsersToReassign = async ({
ctx,
input,
}: GetManagedEventUsersToReassignOptions) => {
const { prisma, user } = ctx;
const { bookingId, limit, cursor, searchTerm } = input;
const organizationId = user.organizationId;
const gettingManagedEventUsersToReassignLogger = logger.getSubLogger({
prefix: ["gettingManagedEventUsersToReassign", `${bookingId}`],
});
const bookingRepository = new BookingRepository(prisma);
const eventTypeRepository = new EventTypeRepository(prisma);
const booking = await bookingRepository.findByIdForTargetEventTypeSearch(bookingId);
if (!booking) {
throw new Error(`Booking ${bookingId} not found`);
}
if (!booking.eventTypeId) {
throw new Error("Booking requires an event type to reassign users");
}
const childEventType = await eventTypeRepository.findByIdWithParent(booking.eventTypeId);
if (!childEventType) {
throw new Error(`Event type ${booking.eventTypeId} not found`);
}
if (!childEventType.parentId) {
throw new Error("Booking is not on a managed event type");
}
const { users, totalCount, nextCursor } = await getManagedEventUsersFromDB({
parentEventTypeId: childEventType.parentId,
organizationId,
prisma,
searchTerm,
cursor,
limit,
excludeUserId: booking.userId ?? undefined,
});
let availableUsers: IsFixedAwareUser[] = [];
try {
const eventType = await getEventTypesFromDB(booking.eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
availableUsers = await ensureAvailableUsers(
{
...eventType,
users: users as IsFixedAwareUser[],
},
{
dateFrom: dayjs(booking.startTime).format(),
dateTo: dayjs(booking.endTime).format(),
timeZone: "UTC",
},
gettingManagedEventUsersToReassignLogger
);
} catch (error) {
if (error instanceof Error && error.message === ErrorCode.NoAvailableUsersFound) {
availableUsers = [];
} else {
gettingManagedEventUsersToReassignLogger.error(error);
}
}
const availableUserIds = new Set(availableUsers.map((u) => u.id));
const items = users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
status: availableUserIds.has(user.id) ? ("available" as const) : ("unavailable" as const),
}));
return {
items,
nextCursor,
totalCount,
};
};
export default getManagedEventUsersToReassign;
@@ -0,0 +1,13 @@
import { z } from "zod";
export const ZGetManagedEventUsersToReassignInputSchema = z.object({
bookingId: z.number().int(),
cursor: z.number().int().optional(), // For pagination
limit: z.number().min(1).max(100).optional(),
searchTerm: z.string().optional(),
});
export type TGetManagedEventUsersToReassignInputSchema = z.infer<
typeof ZGetManagedEventUsersToReassignInputSchema
>;
@@ -0,0 +1,45 @@
import { managedEventManualReassignment } from "@calcom/features/ee/managed-event-types/reassignment";
import { getBookingAccessService } from "@calcom/features/di/containers/BookingAccessService";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TManagedEventManualReassignInputSchema } from "./managedEventManualReassign.schema";
type ManagedEventManualReassignOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TManagedEventManualReassignInputSchema;
};
export const managedEventManualReassignHandler = async ({
ctx,
input,
}: ManagedEventManualReassignOptions) => {
const { bookingId, teamMemberId, reassignReason } = input;
// Check if user has access to change booking
const bookingAccessService = getBookingAccessService();
const isAllowed = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: ctx.user.id,
bookingId,
});
if (!isAllowed) {
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });
}
await managedEventManualReassignment({
bookingId,
newUserId: teamMemberId,
orgId: ctx.user.organizationId ?? null,
reassignReason,
reassignedById: ctx.user.id,
});
return { success: true };
};
export default managedEventManualReassignHandler;
@@ -0,0 +1,12 @@
import { z } from "zod";
export const ZManagedEventManualReassignInputSchema = z.object({
bookingId: z.number().int(),
teamMemberId: z.number().int(),
reassignReason: z.string().optional(),
});
export type TManagedEventManualReassignInputSchema = z.infer<
typeof ZManagedEventManualReassignInputSchema
>;
@@ -0,0 +1,45 @@
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { managedEventReassignment } from "@calcom/features/ee/managed-event-types/reassignment";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TManagedEventReassignInputSchema } from "./managedEventReassign.schema";
type ManagedEventReassignOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TManagedEventReassignInputSchema;
};
export const managedEventReassignHandler = async ({ ctx, input }: ManagedEventReassignOptions) => {
const { bookingId } = input;
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findByIdForReassignment(bookingId);
if (!booking?.uid) {
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
}
const bookingAccessService = new BookingAccessService(prisma);
const isAllowed = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: ctx.user.id,
bookingUid: booking.uid,
});
if (!isAllowed) {
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });
}
return await managedEventReassignment({
bookingId,
orgId: ctx.user.organizationId,
reassignedById: ctx.user.id,
});
};
export default managedEventReassignHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZManagedEventReassignInputSchema = z.object({
bookingId: z.number().int(),
});
export type TManagedEventReassignInputSchema = z.infer<typeof ZManagedEventReassignInputSchema>;
@@ -1,6 +1,5 @@
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { roundRobinManualReassignment } from "@calcom/features/ee/round-robin/roundRobinManualReassignment";
import { prisma } from "@calcom/prisma";
import { getBookingAccessService } from "@calcom/features/di/containers/BookingAccessService";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -18,8 +17,11 @@ export const roundRobinManualReassignHandler = async ({ ctx, input }: RoundRobin
const { bookingId, teamMemberId, reassignReason } = input;
// Check if user has access to change booking
const bookingRepo = new BookingRepository(prisma);
const isAllowed = await bookingRepo.doesUserIdHaveAccessToBooking({ userId: ctx.user.id, bookingId });
const bookingAccessService = getBookingAccessService();
const isAllowed = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: ctx.user.id,
bookingId,
});
if (!isAllowed) {
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });
@@ -1,6 +1,5 @@
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment";
import { prisma } from "@calcom/prisma";
import { getBookingAccessService } from "@calcom/features/di/containers/BookingAccessService";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -18,8 +17,11 @@ export const roundRobinReassignHandler = async ({ ctx, input }: RoundRobinReassi
const { bookingId } = input;
// Check if user has access to change booking
const bookingRepo = new BookingRepository(prisma);
const isAllowed = await bookingRepo.doesUserIdHaveAccessToBooking({ userId: ctx.user.id, bookingId });
const bookingAccessService = getBookingAccessService();
const isAllowed = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: ctx.user.id,
bookingId,
});
if (!isAllowed) {
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });