diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts index 5b7ac79e95..9b5ba327c0 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts @@ -40,7 +40,6 @@ import { ApiTags as DocsTags } from "@nestjs/swagger"; import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2"; - import { PrismaClient } from "@calcom/prisma"; @Controller({ diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index a1b80a01d5..b6dc5f00f7 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -19,7 +19,7 @@ import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -44,6 +44,7 @@ import { import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; +import { ReassignDialog } from "@components/dialog/ReassignDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"]; @@ -155,6 +156,45 @@ function BookingListItem(booking: BookingItemProps) { : []), ]; + const editBookingActions: ActionType[] = [ + { + id: "reschedule", + icon: "clock" as const, + label: t("reschedule_booking"), + href: `${bookerUrl}/reschedule/${booking.uid}${ + booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" + }`, + }, + { + id: "reschedule_request", + icon: "send" as const, + iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", + label: t("send_reschedule_request"), + onClick: () => { + setIsOpenRescheduleDialog(true); + }, + }, + { + id: "change_location", + label: t("edit_location"), + onClick: () => { + setIsOpenLocationDialog(true); + }, + icon: "map-pin" as const, + }, + ]; + + if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + editBookingActions.push({ + id: "reassign ", + label: t("reassign"), + onClick: () => { + setIsOpenReassignDialog(true); + }, + icon: "users" as const, + }); + } + let bookedActions: ActionType[] = [ { id: "cancel", @@ -170,33 +210,7 @@ function BookingListItem(booking: BookingItemProps) { { id: "edit_booking", label: t("edit"), - actions: [ - { - id: "reschedule", - icon: "clock" as const, - label: t("reschedule_booking"), - href: `${bookerUrl}/reschedule/${booking.uid}${ - booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" - }`, - }, - { - id: "reschedule_request", - icon: "send" as const, - iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", - label: t("send_reschedule_request"), - onClick: () => { - setIsOpenRescheduleDialog(true); - }, - }, - { - id: "change_location", - label: t("edit_location"), - onClick: () => { - setIsOpenLocationDialog(true); - }, - icon: "map-pin" as const, - }, - ], + actions: editBookingActions, }, ]; @@ -233,6 +247,7 @@ function BookingListItem(booking: BookingItemProps) { .locale(language) .format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); + const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { @@ -309,6 +324,12 @@ function BookingListItem(booking: BookingItemProps) { setIsOpenDialog={setIsOpenRescheduleDialog} bookingUId={booking.uid} /> + >; + teamId: number; + bookingId: number; +}; + +export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.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("unexpected_error_try_again"), "error"); + } + }, + }); + + return ( + { + setIsOpenDialog(open); + }}> + + {/* TODO add team member reassignment*/} + + + + + + + ); +}; diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index f01cee0fc3..c9033fa5aa 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => { bookingFields: true, disableGuests: true, timeZone: true, + teamId: true, owner: { select: userSelect, }, diff --git a/apps/web/modules/bookings/views/bookings-single-view.test.tsx b/apps/web/modules/bookings/views/bookings-single-view.test.tsx index ae19e6f7e6..9a2705626f 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.test.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.test.tsx @@ -33,6 +33,7 @@ function mockedSuccessComponentProps(props: Partial { return usersStore.find((user) => user.id === userWithJustId.id); }) || []; + const hosts = + eventType.users?.map((host) => { + const user = usersStore.find((user) => user.id === host.id); + return { ...host, user }; + }) || []; return { ...baseEventType, ...eventType, workflows: [], users, + hosts, destinationCalendar: eventType.destinationCalendar ? { create: eventType.destinationCalendar, @@ -377,7 +401,7 @@ async function addBookingsToDb( ); } -async function addBookings(bookings: InputBooking[]) { +export async function addBookings(bookings: InputBooking[]) { log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); const allBookings = [...bookings].map((booking) => { if (booking.references) { @@ -471,6 +495,7 @@ async function addWorkflowsToDb(workflows: InputWorkflow[]) { // Create the workflow first const createdWorkflow = await prismock.workflow.create({ data: { + ...(workflow.id && { id: workflow.id }), userId: workflow.userId, teamId: workflow.teamId, trigger: workflow.trigger, @@ -530,7 +555,15 @@ async function addWorkflowsToDb(workflows: InputWorkflow[]) { async function addWorkflows(workflows: InputWorkflow[]) { log.silly("TestData: Creating Workflows", safeStringify(workflows)); - await addWorkflowsToDb(workflows); + return await addWorkflowsToDb(workflows); +} + +export async function addWorkflowReminders(workflowReminders: InputWorkflowReminder[]) { + log.silly("TestData: Creating Workflow Reminders", safeStringify(workflowReminders)); + + return await prismock.workflowReminder.createMany({ + data: workflowReminders, + }); } export async function addUsersToDb( @@ -541,24 +574,28 @@ export async function addUsersToDb( data: users, }); + const allUsers = await prismock.user.findMany({ + include: { + credentials: true, + teams: true, + profiles: true, + schedules: { + include: { + availability: true, + }, + }, + destinationCalendar: true, + }, + }); + log.silly( "Added users to Db", safeStringify({ - allUsers: await prismock.user.findMany({ - include: { - credentials: true, - teams: true, - profiles: true, - schedules: { - include: { - availability: true, - }, - }, - destinationCalendar: true, - }, - }), + allUsers, }) ); + + return allUsers; } export async function addTeamsToDb(teams: NonNullable[number]["team"][]) { @@ -655,6 +692,13 @@ export async function addUsers(users: InputUser[]) { }, }; } + if (user.destinationCalendar) { + newUser.destinationCalendar = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + create: user.destinationCalendar, + }; + } if (user.profiles) { newUser.profiles = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -669,7 +713,7 @@ export async function addUsers(users: InputUser[]) { } // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - await addUsersToDb(prismaUsersCreate); + return await addUsersToDb(prismaUsersCreate); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -701,10 +745,11 @@ export async function createBookingScenario(data: ScenarioData) { // mockBusyCalendarTimes([]); await addWebhooks(data.webhooks || []); // addPaymentMock(); - await addWorkflows(data.workflows || []); + const workflows = await addWorkflows(data.workflows || []); return { eventTypes, + workflows, }; } @@ -1224,13 +1269,6 @@ export function getScenarioData( ...user, organizationId: user.organizationId ?? null, }; - if (user.destinationCalendar) { - newUser.destinationCalendar = { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - create: user.destinationCalendar, - }; - } return newUser; }), apps: [...apps], diff --git a/packages/app-store/skype/config.json b/packages/app-store/skype/config.json index bcd75fbe2b..cf0429d315 100644 --- a/packages/app-store/skype/config.json +++ b/packages/app-store/skype/config.json @@ -6,9 +6,7 @@ "logo": "icon.svg", "url": "https://github.com/anikdhabal/", "variant": "conferencing", - "categories": [ - "conferencing" - ], + "categories": ["conferencing"], "publisher": "Anik Dhabal Babu", "email": "adhabl2002@gmail.com", "appData": { @@ -17,11 +15,11 @@ "label": "{TITLE}", "linkType": "static", "organizerInputPlaceholder": "https://join.skype.com/", - "urlRegExp": "https:\/\/join\\.skype\\.com\/[a-zA-Z0-9]*" + "urlRegExp": "https://join\\.skype\\.com/[a-zA-Z0-9]*" } }, "description": "Skype is for connecting with the people that matter most in your life and work. It's built for both one-on-one and group conversations and works wherever you are – via mobile, PC and Alexa. Skype messaging and HD voice and video calling will help you share experiences and get things done with others.", "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-location-video-static" -} \ No newline at end of file +} diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 1b4b587972..e5fc5a5aa5 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -411,6 +411,9 @@ export default class EventManager { userId: true, attendees: true, references: { + where: { + deleted: null, + }, // NOTE: id field removed from select as we don't require for deletingMany // but was giving error on recreate for reschedule, probably because promise.all() didn't finished select: { diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 38576d80ae..3d7c09bce1 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -24,6 +24,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { }, }, slug: true, + teamId: true, team: { select: { id: true, diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index 385816a633..cb4e7fb2d6 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -55,7 +55,7 @@ export interface IEventTypePaymentCredentialType { export type IsFixedAwareUser = User & { isFixed: boolean; credentials: CredentialPayload[]; - organization: { slug: string }; + organization?: { slug: string }; priority?: number; }; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.test.ts b/packages/features/ee/round-robin/roundRobinReassignment.test.ts new file mode 100644 index 0000000000..4c3ee84d8e --- /dev/null +++ b/packages/features/ee/round-robin/roundRobinReassignment.test.ts @@ -0,0 +1,295 @@ +import { + getDate, + createBookingScenario, + getScenarioData, + getMockBookingAttendee, + TestData, + addWorkflowReminders, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectBookingToBeInDatabase, + expectSuccessfulRoundRobinReschedulingEmails, + expectWorkflowToBeTriggered, +} from "@calcom/web/test/utils/bookingScenario/expects"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, vi, expect } from "vitest"; + +import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +vi.mock("@calcom/core/EventManager"); + +const testDestinationCalendar = { + integration: "test-calendar", + externalId: "test-calendar", +}; + +const testUsers = [ + { + id: 1, + name: "user-1", + timeZone: "Asia/Kolkata", + username: "host-1", + email: "host1@test.com", + schedules: [TestData.schedules.IstWorkHours], + destinationCalendar: testDestinationCalendar, + }, + { + id: 2, + name: "user-2", + timeZone: "Asia/Kolkata", + username: "host-2", + email: "host2@test.com", + schedules: [TestData.schedules.IstWorkHours], + }, + { + id: 3, + name: "user-3", + timeZone: "Asia/Kolkata", + username: "host-3", + email: "host3@test.com", + schedules: [TestData.schedules.IstWorkHours], + }, +]; + +describe("roundRobinReassignment test", () => { + setupAndTeardown(); + + test("reassign new round robin organizer", async ({ emails }) => { + const roundRobinReassignment = (await import("./roundRobinReassignment")).default; + const EventManager = (await import("@calcom/core/EventManager")).default; + + const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); + eventManagerSpy.mockResolvedValue({ referencesToCreate: [] }); + + const users = testUsers; + const originalHost = users[0]; + const newHost = users[1]; + // Assume we are using the RR fairness algorithm. Add an extra booking for user[2] to ensure user[1] is the new host + + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + const { dateString: dateStringMinusOne } = getDate({ dateIncrement: -1 }); + const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 }); + + const bookingToReassignUid = "booking-to-reassign"; + + const bookingData = await createBookingScenario( + getScenarioData({ + workflows: [ + { + userId: originalHost.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slug: "round-robin-event", + schedulingType: SchedulingType.ROUND_ROBIN, + length: 45, + users: users.map((user) => { + return { + id: user.id, + }; + }), + hosts: users.map((user) => { + return { + userId: user.id, + isFixed: false, + }; + }), + }, + ], + bookings: [ + { + id: 123, + eventTypeId: 1, + userId: originalHost.id, + uid: bookingToReassignUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringPlusOne}T05:00:00.000Z`, + endTime: `${dateStringPlusOne}T05:15:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 2, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + ], + }, + { + id: 456, + eventTypeId: 1, + userId: users[2].id, + uid: bookingToReassignUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringMinusOne}T05:00:00.000Z`, + endTime: `${dateStringMinusOne}T05:15:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 2, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + organizer: originalHost, + usersApartFromOrganizer: users.slice(1), + }) + ); + await addWorkflowReminders([ + { + bookingUid: bookingToReassignUid, + method: WorkflowMethods.EMAIL, + scheduledDate: dateStringPlusTwo, + scheduled: true, + workflowStepId: 1, + workflowId: 1, + }, + ]); + + await roundRobinReassignment({ + bookingId: 123, + }); + + expect(eventManagerSpy).toBeCalledTimes(1); + // Triggers moving to new host within event manager + expect(eventManagerSpy).toHaveBeenCalledWith( + expect.any(Object), + bookingToReassignUid, + undefined, + true, + expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]) + ); + + // Use equal fairness rr algorithm + expectBookingToBeInDatabase({ + uid: bookingToReassignUid, + userId: newHost.id, + }); + + expectSuccessfulRoundRobinReschedulingEmails({ + prevOrganizer: originalHost, + newOrganizer: newHost, + emails, + }); + + expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails }); + }); + + // TODO: add fixed hosts test + test("Reassign round robin host with fixed host as organizer", async () => { + const roundRobinReassignment = (await import("./roundRobinReassignment")).default; + const EventManager = (await import("@calcom/core/EventManager")).default; + + const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); + + const users = testUsers; + + const bookingToReassignUid = "booking-to-reassign"; + + const fixedHost = users[0]; + const currentRRHost = users[1]; + const newHost = users[2]; + // Assume we are using the RR fairness algorithm. Add an extra booking for user[2] to ensure user[1] is the new host + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + userId: fixedHost.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slug: "round-robin-event", + schedulingType: SchedulingType.ROUND_ROBIN, + length: 45, + users: users.map((user) => { + return { + id: user.id, + }; + }), + hosts: users.map((user) => { + return { + userId: user.id, + isFixed: !!(user.id === fixedHost.id), + }; + }), + }, + ], + bookings: [ + { + id: 123, + eventTypeId: 1, + userId: fixedHost.id, + uid: bookingToReassignUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringPlusOne}T05:00:00.000Z`, + endTime: `${dateStringPlusOne}T05:15:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + getMockBookingAttendee({ + id: currentRRHost.id, + name: currentRRHost.name, + email: currentRRHost.email, + locale: "en", + timeZone: currentRRHost.timeZone, + }), + ], + }, + ], + organizer: fixedHost, + usersApartFromOrganizer: users.slice(1), + }) + ); + + await roundRobinReassignment({ + bookingId: 123, + }); + + expect(eventManagerSpy).toBeCalledTimes(1); + // Triggers moving to new host within event manager + expect(eventManagerSpy).toHaveBeenCalledWith( + expect.any(Object), + bookingToReassignUid, + undefined, + false, + [] + ); + + // Ensure organizer stays the same + expectBookingToBeInDatabase({ + uid: bookingToReassignUid, + userId: 1, + }); + + const attendees = await BookingRepository.getBookingAttendees(123); + + expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false); + + expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true); + }); +}); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts new file mode 100644 index 0000000000..09264fd69a --- /dev/null +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -0,0 +1,528 @@ +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; + +import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations"; +import EventManager from "@calcom/core/EventManager"; +import { getEventName } from "@calcom/core/event"; +import dayjs from "@calcom/dayjs"; +import { sendRoundRobinCancelledEmails, sendRoundRobinScheduledEmails } from "@calcom/emails"; +import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +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 { + scheduleEmailReminder, + deleteScheduledEmailReminder, +} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; +import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import { isPrismaObjOrUndefined } from "@calcom/lib"; +import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; +import logger from "@calcom/lib/logger"; +import { getLuckyUser } from "@calcom/lib/server"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference"; +import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; +import { prisma } from "@calcom/prisma"; +import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +const bookingSelect = { + uid: true, + title: true, + startTime: true, + endTime: true, + userId: true, + customInputs: true, + responses: true, + description: true, + location: true, + eventTypeId: true, + destinationCalendar: true, + user: { + include: { + destinationCalendar: true, + }, + }, + attendees: true, + references: true, +}; + +export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }) => { + const roundRobinReassignLogger = logger.getSubLogger({ + prefix: ["roundRobinReassign", `${bookingId}`], + }); + + let booking = await prisma.booking.findUnique({ + where: { + id: bookingId, + }, + select: bookingSelect, + }); + + if (!booking) { + roundRobinReassignLogger.error(`Booking ${bookingId} not found`); + throw new Error("Booking not found"); + } + + if (!booking?.user) { + roundRobinReassignLogger.error(`No user associated with booking ${bookingId}`); + throw new Error("Booking not found"); + } + + const eventTypeId = booking.eventTypeId; + + if (!eventTypeId) { + roundRobinReassignLogger.error(`Booking ${bookingId} does not have an event type id`); + throw new Error("Event type not found"); + } + + const eventType = await getEventTypesFromDB(eventTypeId); + + if (!eventType) { + roundRobinReassignLogger.error(`Event type ${eventTypeId} not found`); + throw new Error("Event type not found"); + } + + eventType.hosts = eventType.hosts.length + ? eventType.hosts + : eventType.users.map((user) => ({ user, isFixed: false, priority: 2 })); + + const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed); + + const originalOrganizer = booking.user; + + const attendeeEmailsSet = new Set(booking.attendees.map((attendee) => attendee.email)); + + // Find the current round robin host assigned + const previousRRHost = (() => { + for (const host of roundRobinHosts) { + if (host.user.id === booking.userId) { + return host.user; + } + if (attendeeEmailsSet.has(host.user.email)) { + return host.user; + } + } + })(); + + const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common"); + + // Filter out the current attendees of the booking from the event type + const availableEventTypeUsers = eventType.hosts.reduce((availableUsers, host) => { + if (!attendeeEmailsSet.has(host.user.email) && host.user.email !== originalOrganizer.email) { + availableUsers.push({ ...host.user, isFixed: host.isFixed, priority: host?.priority ?? 2 }); + } + return availableUsers; + }, [] as IsFixedAwareUser[]); + + const availableUsers = await ensureAvailableUsers( + { ...eventType, users: availableEventTypeUsers }, + { + dateFrom: dayjs(booking.startTime).format(), + dateTo: dayjs(booking.endTime).format(), + timeZone: eventType.timeZone || originalOrganizer.timeZone, + }, + roundRobinReassignLogger + ); + + const reassignedRRHost = await getLuckyUser("MAXIMIZE_AVAILABILITY", { + availableUsers, + eventTypeId: eventTypeId, + }); + const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id; + const organizer = hasOrganizerChanged ? reassignedRRHost : booking.user; + const organizerT = await getTranslation(organizer?.locale || "en", "common"); + + const currentBookingTitle = booking.title; + let newBookingTitle = currentBookingTitle; + + const reassignedRRHostT = await getTranslation(reassignedRRHost.locale || "en", "common"); + + const teamMemberPromises = []; + for (const teamMember of eventType.hosts) { + const user = teamMember.user; + // Need to skip over the reassigned user and the organizer user + if (user.email === previousRRHost?.email || user.email === organizer.email) { + continue; + } + + // Check that the team member is a part of the booking + if (booking.attendees.some((attendee) => attendee.email === user.email)) { + teamMemberPromises.push( + getTranslation(user.locale ?? "en", "common").then((tTeamMember) => ({ + id: user.id, + email: user.email, + name: user.name || "", + timeZone: user.timeZone, + language: { translate: tTeamMember, locale: user.locale ?? "en" }, + })) + ); + } + } + + const teamMembers = await Promise.all(teamMemberPromises); + // Assume the RR host was labelled as a team member + if (reassignedRRHost.email !== organizer.email) { + teamMembers.push({ + id: reassignedRRHost.id, + email: reassignedRRHost.email, + name: reassignedRRHost.name || "", + timeZone: reassignedRRHost.timeZone, + language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale ?? "en" }, + }); + } + + const attendeePromises = []; + for (const attendee of booking.attendees) { + if ( + attendee.email === reassignedRRHost.email || + attendee.email === previousRRHost?.email || + teamMembers.some((member) => member.email === attendee.email) + ) { + continue; + } + + attendeePromises.push( + getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({ + email: attendee.email, + name: attendee.name, + timeZone: attendee.timeZone, + language: { translate: tAttendee, locale: attendee.locale ?? "en" }, + })) + ); + } + + const attendeeList = await Promise.all(attendeePromises); + + if (hasOrganizerChanged) { + const bookingResponses = booking.responses; + + const responseSchema = getBookingResponsesSchema({ + bookingFields: eventType.bookingFields, + view: "reschedule", + }); + + const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses); + + const responses = responseSafeParse.success ? responseSafeParse.data : undefined; + + let bookingLocation = booking.location; + + if (eventType.locations.includes({ type: OrganizerDefaultConferencingAppType })) { + const organizerMetadataSafeParse = userMetadataSchema.safeParse(reassignedRRHost.metadata); + + const defaultLocationUrl = organizerMetadataSafeParse.success + ? organizerMetadataSafeParse?.data?.defaultConferencingApp?.appLink + : undefined; + + const currentBookingLocation = booking.location || "integrations:daily"; + + bookingLocation = + defaultLocationUrl || + getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation; + } + + const eventNameObject = { + attendeeName: responses?.name || "Nameless", + eventType: eventType.title, + eventName: eventType.eventName, + // we send on behalf of team if >1 round robin attendee | collective + teamName: teamMembers.length > 1 ? eventType.team?.name : null, + // TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here. + host: organizer.name || "Nameless", + location: bookingLocation || "integrations:daily", + bookingFields: { ...responses }, + t: organizerT, + }; + + newBookingTitle = getEventName(eventNameObject); + + booking = await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + userId: reassignedRRHost.id, + title: newBookingTitle, + }, + select: bookingSelect, + }); + } else { + const previousRRHostAttendee = booking.attendees.find( + (attendee) => attendee.email === previousRRHost.email + ); + await prisma.attendee.update({ + where: { + id: previousRRHostAttendee!.id, + }, + data: { + name: reassignedRRHost.name || "", + email: reassignedRRHost.email, + timeZone: reassignedRRHost.timeZone, + locale: reassignedRRHost.locale, + }, + }); + } + + const destinationCalendar = await (async () => { + if (eventType?.destinationCalendar) { + return [eventType.destinationCalendar]; + } + + if (hasOrganizerChanged) { + const organizerDestinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + userId: reassignedRRHost.id, + }, + }); + if (organizerDestinationCalendar) { + return [organizerDestinationCalendar]; + } + } else { + if (booking.user?.destinationCalendar) return [booking.user?.destinationCalendar]; + } + })(); + + // If changed owner, also change destination calendar + const previousHostDestinationCalendar = hasOrganizerChanged + ? await prisma.destinationCalendar.findFirst({ + where: { + userId: originalOrganizer.id, + }, + }) + : null; + + const evt: CalendarEvent = { + organizer: { + name: organizer.name || "", + email: organizer.email, + language: { + locale: organizer.locale || "en", + translate: organizerT, + }, + timeZone: organizer.timeZone, + timeFormat: getTimeFormatStringFromUserTimeFormat(organizer.timeFormat), + }, + startTime: dayjs(booking.startTime).utc().format(), + endTime: dayjs(booking.endTime).utc().format(), + type: eventType.slug, + title: newBookingTitle, + description: eventType.description, + attendees: attendeeList, + uid: booking.uid, + destinationCalendar, + team: { + members: teamMembers, + name: eventType.team?.name || "", + id: eventType.team?.id || 0, + }, + customInputs: isPrismaObjOrUndefined(booking.customInputs), + ...getCalEventResponses({ + bookingFields: eventType?.bookingFields ?? null, + booking, + }), + }; + + const credentials = await prisma.credential.findMany({ + where: { + userId: organizer.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + const eventManager = new EventManager({ ...organizer, credentials: [...credentials] }); + + const results = await eventManager.reschedule( + evt, + booking.uid, + undefined, + hasOrganizerChanged, + previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [] + ); + + let newReferencesToCreate = []; + newReferencesToCreate = structuredClone(results.referencesToCreate); + + await BookingReferenceRepository.replaceBookingReferences({ + bookingId, + newReferencesToCreate, + }); + + // Send to new RR host + await sendRoundRobinScheduledEmails(evt, [ + { + ...reassignedRRHost, + name: reassignedRRHost.name || "", + username: reassignedRRHost.username || "", + timeFormat: getTimeFormatStringFromUserTimeFormat(reassignedRRHost.timeFormat), + language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale || "en" }, + }, + ]); + + if (previousRRHost) { + // Send to cancelled RR host + // First we need to replace the new RR host with the old RR host in the evt object + const cancelledRRHostEvt = cloneDeep(evt); + cancelledRRHostEvt.title = currentBookingTitle; + if (hasOrganizerChanged) { + cancelledRRHostEvt.organizer = { + name: previousRRHost.name || "", + email: previousRRHost.email, + language: { + locale: previousRRHost.locale || "en", + translate: previousRRHostT, + }, + timeZone: previousRRHost.timeZone, + timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), + }; + } else if (cancelledRRHostEvt.team) { + // Filter out the new RR host from attendees and add the old RR host + const newMembersArray = cancelledRRHostEvt.team?.members || []; + cancelledRRHostEvt.team.members = newMembersArray.filter( + (member) => member.email !== reassignedRRHost.email + ); + cancelledRRHostEvt.team.members.unshift({ + id: previousRRHost.id, + email: previousRRHost.email, + name: previousRRHost.name || "", + timeZone: previousRRHost.timeZone, + language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, + }); + } + + await sendRoundRobinCancelledEmails(cancelledRRHostEvt, [ + { + ...previousRRHost, + name: previousRRHost.name || "", + username: previousRRHost.username || "", + timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), + language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, + }, + ]); + } + + // Handle changing workflows with organizer + if (hasOrganizerChanged) { + const workflowReminders = await prisma.workflowReminder.findMany({ + where: { + bookingUid: booking.uid, + method: WorkflowMethods.EMAIL, + workflowStep: { + action: WorkflowActions.EMAIL_HOST, + workflow: { + trigger: { + in: [ + WorkflowTriggerEvents.BEFORE_EVENT, + WorkflowTriggerEvents.NEW_EVENT, + WorkflowTriggerEvents.AFTER_EVENT, + ], + }, + }, + }, + }, + select: { + id: true, + referenceId: true, + workflowStep: { + select: { + template: true, + workflow: { + select: { + trigger: true, + time: true, + timeUnit: true, + }, + }, + }, + }, + }, + }); + + for (const workflowReminder of workflowReminders) { + const workflowStep = workflowReminder?.workflowStep; + const workflow = workflowStep?.workflow; + + if (workflowStep && workflow) { + await scheduleEmailReminder({ + evt: { + ...evt, + eventType, + }, + action: WorkflowActions.EMAIL_HOST, + triggerEvent: workflow.trigger, + timeSpan: { + time: workflow.time, + timeUnit: workflow.timeUnit, + }, + sendTo: reassignedRRHost.email, + template: workflowStep.template, + }); + } + + await deleteScheduledEmailReminder(workflowReminder.id, workflowReminder.referenceId); + } + // Send new event workflows to new organizer + const newEventWorkflows = await prisma.workflow.findMany({ + where: { + trigger: WorkflowTriggerEvents.NEW_EVENT, + OR: [ + { + isActiveOnAll: true, + teamId: eventType?.teamId, + }, + { + activeOn: { + some: { + eventTypeId: eventTypeId, + }, + }, + }, + ...(eventType?.teamId + ? [ + { + activeOnTeams: { + some: { + teamId: eventType.teamId, + }, + }, + }, + ] + : []), + ...(eventType?.team?.parentId + ? [ + { + isActiveOnAll: true, + teamId: eventType.team.parentId, + }, + ] + : []), + ], + }, + include: { + steps: { + where: { + action: WorkflowActions.EMAIL_HOST, + }, + }, + }, + }); + + const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evt) }; + + await scheduleWorkflowReminders({ + workflows: newEventWorkflows, + smsReminderNumber: null, + calendarEvent: { ...evt, metadata: workflowEventMetadata, eventType: { slug: eventType.slug } }, + hideBranding: !!eventType?.owner?.hideBranding, + }); + } +}; + +export default roundRobinReassignment; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 1a34c7c18c..c881d23dfb 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -1,6 +1,48 @@ -import prisma from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; + +import { UserRepository } from "./user"; export class BookingRepository { + static async getBookingAttendees(bookingId: number) { + return await prisma.attendee.findMany({ + where: { + bookingId, + }, + }); + } + + /** Determines if the user is the organizer, team admin, or org admin that the booking was created under */ + static async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) { + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + }, + select: { + userId: true, + eventType: { + select: { + teamId: 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 isAdminOrUser = await UserRepository.isAdminOfTeamOrParentOrg({ + userId, + teamId: booking.eventType.teamId, + }); + + return isAdminOrUser; + } + static async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) { return await prisma.booking.findFirst({ where: { diff --git a/packages/lib/server/repository/bookingReference.ts b/packages/lib/server/repository/bookingReference.ts index 1d12548a96..905bdec75f 100644 --- a/packages/lib/server/repository/bookingReference.ts +++ b/packages/lib/server/repository/bookingReference.ts @@ -1,6 +1,7 @@ import { Prisma } from "@prisma/client"; import { prisma } from "@calcom/prisma"; +import type { PartialReference } from "@calcom/types/EventManager"; const bookingReferenceSelect = Prisma.validator()({ id: true, @@ -20,4 +21,32 @@ export class BookingReferenceRepository { select: bookingReferenceSelect, }); } + + /** + * If rescheduling a booking with new references from the EventManager. Delete the previous references and replace them with new ones + */ + static async replaceBookingReferences({ + bookingId, + newReferencesToCreate, + }: { + bookingId: number; + newReferencesToCreate: PartialReference[]; + }) { + const newReferenceTypes = newReferencesToCreate.map((reference) => reference.type); + + await prisma.bookingReference.deleteMany({ + where: { + bookingId, + type: { + in: newReferenceTypes, + }, + }, + }); + + await prisma.bookingReference.createMany({ + data: newReferencesToCreate.map((reference) => { + return { ...reference, bookingId }; + }), + }); + } } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index b1843912f7..cc71c722b1 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -494,6 +494,32 @@ export class UserRepository { }); } + static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) { + const membershipQuery = { + members: { + some: { + userId, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + }, + }; + const teams = await prisma.team.findMany({ + where: { + id: teamId, + OR: [ + membershipQuery, + { + parent: { ...membershipQuery }, + }, + ], + }, + select: { + id: true, + }, + }); + return !!teams.length; + } + static async getUserAdminTeams(userId: number): Promise { const user = await prisma.user.findFirst({ where: { diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 821b09637d..998278d0c9 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -179,6 +179,7 @@ export async function getBookings({ metadata: true, seatsShowAttendees: true, seatsShowAvailabilityCount: true, + schedulingType: true, team: { select: { id: true, @@ -190,6 +191,7 @@ export async function getBookings({ }, status: true, paid: true, + payment: { select: { paymentOption: true, diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index b65acb1b64..115799d733 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -18,6 +18,7 @@ import { hasTeamPlan } from "./procedures/hasTeamPlan"; import { ZPublishInputSchema } from "./publish.schema"; import { ZRemoveMemberInputSchema } from "./removeMember.schema"; import { ZResendInvitationInputSchema } from "./resendInvitation.schema"; +import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema"; import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema"; import { ZUpdateInputSchema } from "./update.schema"; import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; @@ -154,4 +155,11 @@ export const viewerTeamsRouter = router({ ); return handler(opts); }), + roundRobinReassign: authedProcedure.input(ZRoundRobinReassignInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("roundRobinReassign"), + () => import("./roundRobin/roundRobinReassign.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts new file mode 100644 index 0000000000..3332fb6337 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts @@ -0,0 +1,29 @@ +import { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TRoundRobinReassignInputSchema } from "./roundRobinReassign.schema"; + +type RoundRobinReassignOptions = { + ctx: { + user: NonNullable; + }; + input: TRoundRobinReassignInputSchema; +}; + +export const roundRobinReassignHandler = async ({ ctx, input }: RoundRobinReassignOptions) => { + const { bookingId } = input; + + // Check if user has access to change booking + const isAllowed = await BookingRepository.doesUserIdHaveAccessToBooking({ userId: ctx.user.id, bookingId }); + + if (!isAllowed) { + throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" }); + } + + await roundRobinReassignment({ bookingId }); +}; + +export default roundRobinReassignHandler; diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts new file mode 100644 index 0000000000..3894f90b09 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRoundRobinReassignInputSchema = z.object({ + teamId: z.number(), + bookingId: z.number(), +}); + +export type TRoundRobinReassignInputSchema = z.infer; diff --git a/packages/ui/components/editable-heading/EditableHeading.tsx b/packages/ui/components/editable-heading/EditableHeading.tsx index 3a8b6f24fa..b0dc1fd5c0 100644 --- a/packages/ui/components/editable-heading/EditableHeading.tsx +++ b/packages/ui/components/editable-heading/EditableHeading.tsx @@ -18,9 +18,7 @@ export const EditableHeading = function EditableHeading({ const [isEditing, setIsEditing] = useState(false); const enableEditing = () => setIsEditing(!disabled); return ( -
+