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:
1 parent
38890dad30
commit
0103321b71
54 files changed
+4259
-443
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-10
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
+505
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+18
@@ -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;
|
||||
+420
@@ -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;
|
||||
|
||||
+75
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+825
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+112
@@ -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";
|
||||
+55
@@ -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) => {
|
||||
|
||||
+155
@@ -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;
|
||||
|
||||
+13
@@ -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
|
||||
>;
|
||||
|
||||
+45
@@ -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;
|
||||
|
||||
+12
@@ -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
|
||||
>;
|
||||
|
||||
+45
@@ -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>;
|
||||
|
||||
+6
-4
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user