import { enrichHostsWithDelegationCredentials, enrichUserWithDelegationCredentialsIncludeServiceAccountKey, } from "@calcom/app-store/delegationCredential"; import { getLocationValueForDB, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; import dayjs from "@calcom/dayjs"; import { sendReassignedEmailsAndSMS, sendReassignedScheduledEmailsAndSMS, sendReassignedUpdatedEmailsAndSMS, } from "@calcom/emails/email-manager"; import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import type { ValidActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getBookingResponsesPartialSchema } 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 { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser"; import AssignmentReasonRecorder, { RRReassignmentType, } from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/i18n/server"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { cloneDeep } from "lodash"; import { handleRescheduleEventManager } from "./handleRescheduleEventManager"; import { handleWorkflowsUpdate } from "./roundRobinManualReassignment"; import { bookingSelect } from "./utils/bookingSelect"; import { getDestinationCalendar } from "./utils/getDestinationCalendar"; import { getTeamMembers } from "./utils/getTeamMembers"; export const roundRobinReassignment = async ({ bookingId, orgId, emailsEnabled = true, platformClientParams, reassignedById, actionSource, reassignedByUuid, }: { bookingId: number; orgId: number | null; emailsEnabled?: boolean; platformClientParams?: PlatformClientParams; reassignedById: number; actionSource: ValidActionSource; reassignedByUuid: string; }) => { const roundRobinReassignLogger = logger.getSubLogger({ prefix: ["roundRobinReassign", `${bookingId}`], }); roundRobinReassignLogger.info(`User ${reassignedById} initiating round robin reassignment`); let booking = await prisma.booking.findUnique({ where: { id: bookingId, }, select: bookingSelect, }); if (!booking) { logger.error(`Booking ${bookingId} not found`); throw new Error("Booking not found"); } if (!booking.user) { logger.error(`No user associated with booking ${bookingId}`); throw new Error("Booking not found"); } const eventTypeId = booking.eventTypeId; if (!eventTypeId) { logger.error(`Booking ${bookingId} does not have an event type id`); throw new Error("Event type not found"); } const eventType = await getEventTypesFromDB(eventTypeId); if (!eventType) { logger.error(`Event type ${eventTypeId} not found`); throw new Error("Event type not found"); } if (eventType.hostGroups && eventType.hostGroups.length > 1) { logger.error( `Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed` ); throw new Error("Reassignment not allowed with more than one round robin group"); } eventType.hosts = eventType.hosts.length ? eventType.hosts : eventType.users.map((user) => ({ user, isFixed: false, priority: 2, weight: 100, schedule: null, createdAt: new Date(0), // use earliest possible date as fallback groupId: null, location: null, })); if (eventType.hosts.length === 0) { throw new Error(ErrorCode.EventTypeNoHosts); } 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"); const eventTypeHosts = await enrichHostsWithDelegationCredentials({ orgId, hosts: eventType.hosts, }); // Filter out the current attendees of the booking from the event type const availableEventTypeUsers = eventTypeHosts.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 luckyUserService = getLuckyUserService(); const reassignedRRHost = await luckyUserService.getLuckyUser({ availableUsers, eventType, allRRHosts: eventTypeHosts.filter((host) => !host.isFixed), // todo: only use hosts from virtual queue routingFormResponse: null, }); 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 teamMembers = await getTeamMembers({ eventTypeHosts: eventType.hosts, attendees: booking.attendees, organizer: organizer, previousHost: previousRRHost || null, reassignedHost: reassignedRRHost, }); 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" }, phoneNumber: attendee.phoneNumber || undefined, })) ); } const attendeeList = await Promise.all(attendeePromises); let bookingLocation = booking.location; let attendeeUpdatedId = null; let previousRRHostUserUuid = null; if (hasOrganizerChanged) { const bookingResponses = booking.responses; const responseSchema = getBookingResponsesPartialSchema({ bookingFields: eventType.bookingFields, view: "reschedule", }); const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses); const responses = responseSafeParse.success ? responseSafeParse.data : undefined; if (eventType.locations.some((location) => location.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 }, eventDuration: dayjs(booking.endTime).diff(booking.startTime, "minutes"), t: organizerT, }; newBookingTitle = getEventName(eventNameObject); booking = await prisma.booking.update({ where: { id: bookingId, }, data: { userId: reassignedRRHost.id, userPrimaryEmail: reassignedRRHost.email, title: newBookingTitle, reassignById: reassignedById, idempotencyKey: IdempotencyKeyService.generate({ startTime: booking.startTime, endTime: booking.endTime, userId: reassignedRRHost.id, reassignedById, }), }, select: bookingSelect, }); } else { const previousRRHostAttendee = booking.attendees.find( (attendee) => attendee.email === previousRRHost.email ); if (previousRRHostAttendee) { previousRRHostUserUuid = previousRRHost?.uuid ?? null; attendeeUpdatedId = previousRRHostAttendee.id; await prisma.attendee.update({ where: { id: previousRRHostAttendee.id, }, data: { name: reassignedRRHost.name || "", email: reassignedRRHost.email, timeZone: reassignedRRHost.timeZone, locale: reassignedRRHost.locale, }, }); } } const bookingEventHandlerService = getBookingEventHandlerService(); const featuresRepository = getFeaturesRepository(); const isBookingAuditEnabled = orgId ? await featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit") : false; await bookingEventHandlerService.onReassignment({ bookingUid: booking.uid, actor: makeUserActor(reassignedByUuid), organizationId: orgId, source: actionSource, auditData: { ...(hasOrganizerChanged ? { organizerUuid: { old: originalOrganizer.uuid, new: reassignedRRHost.uuid } } : null), ...(attendeeUpdatedId ? { hostAttendeeUpdated: { id: attendeeUpdatedId, withUserUuid: { old: previousRRHostUserUuid, new: reassignedRRHost.uuid ?? null, }, }, } : null), reassignmentReason: null, reassignmentType: "roundRobin", }, isBookingAuditEnabled, }); roundRobinReassignLogger.info(`Successfully reassigned to user ${reassignedRRHost.id}`); await AssignmentReasonRecorder.roundRobinReassignment({ bookingId, reassignById: reassignedById, reassignmentType: RRReassignmentType.ROUND_ROBIN, }); const destinationCalendar = await getDestinationCalendar({ eventType, booking, newUserId: reassignedRRHost.id, hasOrganizerChanged, }); // 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, iCalUID: booking.iCalUID, destinationCalendar, team: { members: teamMembers, name: eventType.team?.name || "", id: eventType.team?.id || 0, }, schedulingType: eventType.schedulingType, customInputs: isPrismaObjOrUndefined(booking.customInputs), ...getCalEventResponses({ bookingFields: eventType?.bookingFields ?? null, booking, }), hideOrganizerEmail: eventType.hideOrganizerEmail, customReplyToEmail: eventType?.customReplyToEmail, location: bookingLocation, ...(platformClientParams ? platformClientParams : {}), organizationId: orgId, seatsPerTimeSlot: eventType.seatsPerTimeSlot, }; if (hasOrganizerChanged) { // location might changed and will be new created in eventManager.create (organizer default location) evt.videoCallData = undefined; // To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID evt.iCalUID = undefined; } const credentials = await prisma.credential.findMany({ where: { userId: organizer.id, }, include: { user: { select: { email: true, }, }, }, }); const organizerWithCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({ user: { ...organizer, credentials }, }); const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); // remove the event and meeting using the old host's credentials if (hasOrganizerChanged && originalOrganizer.id !== reassignedRRHost.id) { const previousHostCredentials = await getAllCredentialsIncludeServiceAccountKey( originalOrganizer, eventType ); const originalHostEventManager = new EventManager( { ...originalOrganizer, credentials: previousHostCredentials }, apps ); const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common"); const deletionEvent: CalendarEvent = { ...evt, organizer: { id: originalOrganizer.id, name: originalOrganizer.name || "", email: originalOrganizer.email, username: originalOrganizer.username || undefined, timeZone: originalOrganizer.timeZone, language: { translate: originalOrganizerT, locale: originalOrganizer.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(originalOrganizer.timeFormat), }, destinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [], title: booking.title, }; await originalHostEventManager.deleteEventsAndMeetings({ event: deletionEvent, bookingReferences: booking.references, }); } const { evtWithAdditionalInfo } = await handleRescheduleEventManager({ evt, rescheduleUid: booking.uid, newBookingId: undefined, changedOrganizer: hasOrganizerChanged, previousHostDestinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [], initParams: { user: organizerWithCredentials, eventType, }, bookingId, bookingLocation, bookingICalUID: booking.iCalUID, bookingMetadata: booking.metadata, }); const { cancellationReason: _cancellationReason, ...evtWithoutCancellationReason } = evtWithAdditionalInfo; // Send to new RR host if (emailsEnabled) { await sendReassignedScheduledEmailsAndSMS({ calEvent: evtWithoutCancellationReason, members: [ { ...reassignedRRHost, name: reassignedRRHost.name || "", username: reassignedRRHost.username || "", timeFormat: getTimeFormatStringFromUserTimeFormat(reassignedRRHost.timeFormat), language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale || "en" }, }, ], reassigned: { name: reassignedRRHost.name, email: reassignedRRHost.email, byUser: originalOrganizer.name || undefined, }, }); } 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(evtWithAdditionalInfo); 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" }, }); } if (emailsEnabled) { await sendReassignedEmailsAndSMS({ calEvent: cancelledRRHostEvt, members: [ { ...previousRRHost, name: previousRRHost.name || "", username: previousRRHost.username || "", timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, }, ], reassignedTo: { name: reassignedRRHost.name, email: reassignedRRHost.email }, eventTypeMetadata: eventType?.metadata as EventTypeMetadata, }); } } // Handle changing workflows with organizer if (hasOrganizerChanged) { if (emailsEnabled && dayjs(evt.startTime).isAfter(dayjs())) { // send email with event updates to attendees await sendReassignedUpdatedEmailsAndSMS({ calEvent: evtWithoutCancellationReason, eventTypeMetadata: eventType?.metadata as EventTypeMetadata, showAttendees: !!eventType.seatsShowAttendees, }); } await handleWorkflowsUpdate({ booking, newUser: reassignedRRHost, evt: evtWithAdditionalInfo, eventType, orgId, }); } return { bookingId, reassignedTo: { id: reassignedRRHost.id, name: reassignedRRHost.name, email: reassignedRRHost.email, }, }; }; export default roundRobinReassignment;