diff --git a/apps/api/v1/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts index cf9b6b25c9cb6b..e1fa89c611e4a0 100644 --- a/apps/api/v1/lib/validations/booking.ts +++ b/apps/api/v1/lib/validations/booking.ts @@ -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, diff --git a/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts index 251583cb280bb7..b32bb45afe813c 100644 --- a/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts +++ b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts @@ -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>; + let member0Booking: Awaited>; + 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" }, + }); + + 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 } }, + }); + } - if (acmeOrg) { - await prisma.organizationSettings.upsert({ - where: { - organizationId: acmeOrg.id, - }, - update: { - isAdminAPIEnabled: true, - }, - create: { - organizationId: acmeOrg.id, - orgAutoAcceptEmail: "acme.com", - isAdminAPIEnabled: true, - }, + // 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({ 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({ 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({ 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, }, }); diff --git a/apps/api/v1/test/lib/bookings/_get.integration-test.ts b/apps/api/v1/test/lib/bookings/_get.integration-test.ts index 2f2ba4f98e0ee8..79b1906626574c 100644 --- a/apps/api/v1/test/lib/bookings/_get.integration-test.ts +++ b/apps/api/v1/test/lib/bookings/_get.integration-test.ts @@ -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>; + let proUserBooking: Awaited>; + let memberUser: Awaited>; + let memberUserBooking: Awaited>; + 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 } } } } + ] + } + }); + + 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", }, }); + }); - if (acmeOrg) { - await prisma.organizationSettings.upsert({ - where: { - organizationId: acmeOrg.id, - }, - update: { - isAdminAPIEnabled: true, - }, - create: { - organizationId: acmeOrg.id, - orgAutoAcceptEmail: "acme.com", - isAdminAPIEnabled: true, - }, + 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({ 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); diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index f88aaaac812fcb..267d7912742226 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -427,6 +427,7 @@ export function BookingActionsDropdown({ bookingId={booking.id} teamId={booking.eventType?.team?.id || 0} bookingFromRoutingForm={isBookingFromRoutingForm} + isManagedEvent={booking.eventType?.parentId != null} /> )} { 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({ 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); }}>
{!bookingFromRoutingForm ? ( - {t("round_robin")} -

{t("round_robin_reassign_description")}

+ disabled={bookingFromRoutingForm} + data-testid="reassign-option-auto"> + + {isManagedEvent ? t("auto_reassign") : t("round_robin")} + +

{isManagedEvent ? t("auto_reassign_description") : t("round_robin_reassign_description")}

) : null} - {t("team_member_round_robin_reassign")} -

{t("team_member_round_robin_reassign_description")}

+ classNames={{ container: "w-full" }} + data-testid="reassign-option-specific"> + + {isManagedEvent ? t("specific_team_member") : t("team_member_round_robin_reassign")} + +

+ {isManagedEvent + ? t("specific_team_member_description") + : t("team_member_round_robin_reassign_description")} +

@@ -217,9 +279,22 @@ export const ReassignDialog = ({ type="text" placeholder={t("search")} onChange={(e) => setSearchTerm(e.target.value)} + data-testid="reassign-user-search" />
- {teamMemberOptions.map((member) => ( + {isFetching && teamMemberOptions.length === 0 ? ( +
+
+ +

{t("loading")}

+
+
+ ) : teamMemberOptions.length === 0 ? ( +
+

{t("no_available_users_found_input")}

+
+ ) : ( + teamMemberOptions.map((member) => ( - ))} -
- -
+ )) + )} + {teamMemberOptions.length > 0 && ( +
+ +
+ )}
@@ -275,7 +353,12 @@ export const ReassignDialog = ({ @@ -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, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 83975b720d5d3a..541e357cf5b8ac 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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?", @@ -3036,6 +3037,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", @@ -3993,6 +4000,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", diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index 503383a7231ec6..c5504a236b520d 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -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" diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 36b7086fb38e7d..4affbac3d251f8 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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 }; diff --git a/packages/emails/src/templates/OrganizerCancelledEmail.tsx b/packages/emails/src/templates/OrganizerCancelledEmail.tsx index 2e3848508f88fe..838968a57148c7 100644 --- a/packages/emails/src/templates/OrganizerCancelledEmail.tsx +++ b/packages/emails/src/templates/OrganizerCancelledEmail.tsx @@ -1,9 +1,14 @@ +import { SchedulingType } from "@calcom/prisma/enums"; + import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; export const OrganizerCancelledEmail = (props: React.ComponentProps) => { 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 ( ) => { 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 ( {t("event_reassigned_subtitle")}} + subtitle={<>{subtitle}} callToAction={null} reassigned={props.reassigned} {...props} diff --git a/packages/features/assignment-reason/repositories/AssignmentReasonRepository.ts b/packages/features/assignment-reason/repositories/AssignmentReasonRepository.ts new file mode 100644 index 00000000000000..b6b6c4cd4aebb9 --- /dev/null +++ b/packages/features/assignment-reason/repositories/AssignmentReasonRepository.ts @@ -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 { + 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[]> { + 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 | null> { + return this.prismaClient.assignmentReason.findFirst({ + where: { bookingId }, + orderBy: { createdAt: "desc" }, + select: { + createdAt: true, + bookingId: true, + reasonEnum: true, + reasonString: true, + }, + }); + } +} + diff --git a/packages/features/bookings/lib/BookingEmailSmsHandler.ts b/packages/features/bookings/lib/BookingEmailSmsHandler.ts index 0b3884f54cf6c3..4071dc53df73c2 100644 --- a/packages/features/bookings/lib/BookingEmailSmsHandler.ts +++ b/packages/features/bookings/lib/BookingEmailSmsHandler.ts @@ -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, diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts index 6ed2b690c239d1..46cb8f8a09ac03 100644 --- a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts @@ -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); } } diff --git a/packages/features/bookings/repositories/BookingRepository.integration-test.ts b/packages/features/bookings/repositories/BookingRepository.integration-test.ts new file mode 100644 index 00000000000000..7774f8e33ab445 --- /dev/null +++ b/packages/features/bookings/repositories/BookingRepository.integration-test.ts @@ -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" + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts deleted file mode 100644 index 1638f899f9077a..00000000000000 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ /dev/null @@ -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()); - }); - }); -}); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 1a43d305fd8528..d2277944fbf618 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -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 | null; + metadata?: Record | 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; +}; + +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 | null; + tx?: Omit; + }): Promise { + 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 { + 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 | null; + newBookingPlan: Omit; + }): 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, + }, + }); + } } diff --git a/packages/features/bookings/services/BookingAccessService.ts b/packages/features/bookings/services/BookingAccessService.ts index 779435dfb40419..0d13b8388f7dee 100644 --- a/packages/features/bookings/services/BookingAccessService.ts +++ b/packages/features/bookings/services/BookingAccessService.ts @@ -45,14 +45,21 @@ export class BookingAccessService { async doesUserIdHaveAccessToBooking({ userId, bookingUid, + bookingId, }: { userId: number; - bookingUid: string; + bookingUid?: string; + bookingId?: number; }): Promise { 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 }); diff --git a/packages/features/di/containers/BookingAccessService.ts b/packages/features/di/containers/BookingAccessService.ts new file mode 100644 index 00000000000000..db13566a727942 --- /dev/null +++ b/packages/features/di/containers/BookingAccessService.ts @@ -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(bookingAccessServiceModuleLoader.token); +} + + diff --git a/packages/features/di/containers/ManagedEventReassignment.ts b/packages/features/di/containers/ManagedEventReassignment.ts new file mode 100644 index 00000000000000..5611b3863f81bb --- /dev/null +++ b/packages/features/di/containers/ManagedEventReassignment.ts @@ -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( + managedEventReassignmentServiceModuleLoader.token + ); +} + diff --git a/packages/features/di/modules/AssignmentReason.ts b/packages/features/di/modules/AssignmentReason.ts new file mode 100644 index 00000000000000..7fe3e1f9669c88 --- /dev/null +++ b/packages/features/di/modules/AssignmentReason.ts @@ -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, +}; + diff --git a/packages/features/di/modules/BookingAccessService.ts b/packages/features/di/modules/BookingAccessService.ts new file mode 100644 index 00000000000000..06cfe743bf480a --- /dev/null +++ b/packages/features/di/modules/BookingAccessService.ts @@ -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, +}; + + diff --git a/packages/features/di/modules/Credential.ts b/packages/features/di/modules/Credential.ts new file mode 100644 index 00000000000000..c3755417301a20 --- /dev/null +++ b/packages/features/di/modules/Credential.ts @@ -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, +}; + diff --git a/packages/features/di/modules/EventType.ts b/packages/features/di/modules/EventType.ts index 4cc4f2a75eb1a5..972315506fd8df 100644 --- a/packages/features/di/modules/EventType.ts +++ b/packages/features/di/modules/EventType.ts @@ -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; diff --git a/packages/features/di/modules/ManagedEventReassignment.ts b/packages/features/di/modules/ManagedEventReassignment.ts new file mode 100644 index 00000000000000..9e5e894899b991 --- /dev/null +++ b/packages/features/di/modules/ManagedEventReassignment.ts @@ -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 }; + diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 4b8436ec157975..9b8d4ab543a38d 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -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, diff --git a/packages/features/ee/managed-event-types/reassignment/index.ts b/packages/features/ee/managed-event-types/reassignment/index.ts new file mode 100644 index 00000000000000..8a9ec5a34859bf --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/index.ts @@ -0,0 +1,4 @@ +export { managedEventManualReassignment } from "./managedEventManualReassignment"; +export { managedEventReassignment } from "./managedEventReassignment"; +export * from "./utils"; + diff --git a/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.integration-test.ts b/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.integration-test.ts new file mode 100644 index 00000000000000..69b8480c7f65ee --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.integration-test.ts @@ -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(); + }); +}); + diff --git a/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.ts b/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.ts new file mode 100644 index 00000000000000..0434418886a7f4 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/managedEventManualReassignment.ts @@ -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; diff --git a/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.integration-test.ts b/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.integration-test.ts new file mode 100644 index 00000000000000..fb687988902131 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.integration-test.ts @@ -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(); + }); +}); + diff --git a/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.ts b/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.ts new file mode 100644 index 00000000000000..5a86c86a314748 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/managedEventReassignment.ts @@ -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; + diff --git a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventAssignmentReasonRecorder.ts b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventAssignmentReasonRecorder.ts new file mode 100644 index 00000000000000..09cf7c78bdbc7e --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventAssignmentReasonRecorder.ts @@ -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, + }; + } +} diff --git a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts new file mode 100644 index 00000000000000..ba8e12cc5220d2 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts @@ -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> + >; + newBookingPlan: Parameters[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) + : 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>>; + currentEventTypeDetails: NonNullable>>; + originalBookingFull: NonNullable< + Awaited> + >; + originalUserT: Awaited>; + apps: ReturnType; + logger: ReturnType; + }) { + 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>>; + newUserT: Awaited>; + targetEventTypeDetails: NonNullable>>; + newBooking: ManagedEventReassignmentCreatedBooking; + originalBookingFull: NonNullable< + Awaited> + >; + apps: ReturnType; + logger: ReturnType; + }): 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>>; + parentEventType: Awaited>["parentEventType"]; + newUser: NonNullable>>; + newUserT: Awaited>; + originalUser: NonNullable>>; + originalUserT: Awaited>; + bookingLocation: string | null; + videoCallData: CalendarEvent["videoCallData"]; + additionalInformation: AdditionalInformation; + reassignReason?: string; + logger: ReturnType; + }) { + 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; + }) { + 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); + } + } +} diff --git a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventReassignmentService.ts b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventReassignmentService.ts new file mode 100644 index 00000000000000..1f23889670e2a6 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventReassignmentService.ts @@ -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>; +} + +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 { + 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 { + 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>>, + log: typeof logger + ): Promise { + 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; + } +} + diff --git a/packages/features/ee/managed-event-types/reassignment/services/container.ts b/packages/features/ee/managed-event-types/reassignment/services/container.ts new file mode 100644 index 00000000000000..4990a9efac15c3 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/services/container.ts @@ -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); +} + diff --git a/packages/features/ee/managed-event-types/reassignment/utils/buildNewBookingPlan.ts b/packages/features/ee/managed-event-types/reassignment/utils/buildNewBookingPlan.ts new file mode 100644 index 00000000000000..647a091a83da86 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/utils/buildNewBookingPlan.ts @@ -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> + >; + targetEventTypeDetails: NonNullable>>; + newUser: NonNullable>>; + newUserT: Awaited>; + 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) + : undefined, + metadata: + typeof originalBookingFull.metadata === "object" && originalBookingFull.metadata !== null + ? (originalBookingFull.metadata as Record) + : 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, + }; +} + diff --git a/packages/features/ee/managed-event-types/reassignment/utils/findTargetChildEventType.ts b/packages/features/ee/managed-event-types/reassignment/utils/findTargetChildEventType.ts new file mode 100644 index 00000000000000..478a929f653666 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/utils/findTargetChildEventType.ts @@ -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>; + 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 { + 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, + }; +} + diff --git a/packages/features/ee/managed-event-types/reassignment/utils/index.ts b/packages/features/ee/managed-event-types/reassignment/utils/index.ts new file mode 100644 index 00000000000000..d93218e8338723 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/utils/index.ts @@ -0,0 +1,3 @@ +export { findTargetChildEventType } from "./findTargetChildEventType"; +export { validateManagedEventReassignment } from "./validateManagedEventReassignment"; +export { buildNewBookingPlan } from "./buildNewBookingPlan"; diff --git a/packages/features/ee/managed-event-types/reassignment/utils/validateManagedEventReassignment.ts b/packages/features/ee/managed-event-types/reassignment/utils/validateManagedEventReassignment.ts new file mode 100644 index 00000000000000..3a345d44734342 --- /dev/null +++ b/packages/features/ee/managed-event-types/reassignment/utils/validateManagedEventReassignment.ts @@ -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 { + + 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 }; +} + diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts index 295a9124ef7319..7603e346ef1529 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts @@ -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); }); }); diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 43646cfda1d450..7d017dd8d2695e 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -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, }); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 5b97357ab939f3..c2030dfce0cb43 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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, }); diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 2e2e5da67415e7..70058c5a529c80 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -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, + }, + }); + } } diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 99070eec1c5622..ebdc6baf999eaa 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -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); + } } diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 5a81a91a0f675a..13101937523361 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -508,6 +508,7 @@ export async function getBookings({ "EventType.hideOrganizerEmail", "EventType.disableCancelling", "EventType.disableRescheduling", + "EventType.parentId", eb .cast( eb diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index b0dce5f91ad201..b52c40e08c49f4 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -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) => { diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.handler.ts new file mode 100644 index 00000000000000..6b9483b4e19fb2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.handler.ts @@ -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; + 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 } => 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; + diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.schema.ts new file mode 100644 index 00000000000000..093f78277966bb --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/getManagedEventUsersToReassign.schema.ts @@ -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 +>; + diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.handler.ts new file mode 100644 index 00000000000000..b5d5b605bb81c0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.handler.ts @@ -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; + }; + 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; + diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.schema.ts new file mode 100644 index 00000000000000..514e31e200598e --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventManualReassign.schema.ts @@ -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 +>; + diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.handler.ts new file mode 100644 index 00000000000000..81a97e88874d29 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.handler.ts @@ -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; + }; + 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; + diff --git a/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.schema.ts new file mode 100644 index 00000000000000..25ee600ff8bcd8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/managedEvents/managedEventReassign.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZManagedEventReassignInputSchema = z.object({ + bookingId: z.number().int(), +}); + +export type TManagedEventReassignInputSchema = z.infer; + diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts index b05c87718df282..aa4cfcb5d6c97e 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts @@ -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" }); diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts index c407c00909f6bf..e160ae37242bfd 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts @@ -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" });