import type { App, DestinationCalendar, EventTypeCustomInput } from "@prisma/client"; import { Prisma } from "@prisma/client"; import type { IncomingMessage } from "http"; import { isValidPhoneNumber } from "libphonenumber-js"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { NextApiRequest } from "next"; import type { TFunction } from "next-i18next"; import short, { uuid } from "short-uuid"; import type { Logger } from "tslog"; import { v5 as uuidv5 } from "uuid"; import z from "zod"; import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId"; import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata"; import type { LocationObject } from "@calcom/app-store/locations"; import { MeetLocationType, OrganizerDefaultConferencingAppType, getLocationValueForDB, } from "@calcom/app-store/locations"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendRescheduledEmails, sendRoundRobinCancelledEmails, sendRoundRobinRescheduledEmails, sendRoundRobinScheduledEmails, sendScheduledEmails, } from "@calcom/emails"; import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { cancelWorkflowReminders, scheduleWorkflowReminders, } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { getFullName } from "@calcom/features/form-builder/utils"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { cancelScheduledJobs, scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger"; import { isPrismaObjOrUndefined, parseBookingLimit, parseDurationLimit, parseRecurringEvent, } from "@calcom/lib"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { handlePayment } from "@calcom/lib/payment/handlePayment"; import { getPiiFreeCalendarEvent, getPiiFreeEventType, getPiiFreeUser } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { checkBookingLimits, checkDurationLimits, getLuckyUser } from "@calcom/lib/server"; import { getTranslation } from "@calcom/lib/server/i18n"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { slugify } from "@calcom/lib/slugify"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma, { userSelect } from "@calcom/prisma"; import type { BookingReference } from "@calcom/prisma/client"; import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, bookingCreateSchemaLegacyPropsForApi, customInputSchema, userMetadata as userMetadataSchema, } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, AppsStatus, CalendarEvent, IntervalLimit, Person, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; import { checkForConflicts } from "./conflictChecker/checkForConflicts"; import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials"; import getBookingDataSchema from "./getBookingDataSchema"; import handleSeats from "./handleSeats/handleSeats"; import type { BookingSeat } from "./handleSeats/types"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BookingType = Prisma.PromiseReturnType; export type Booking = Prisma.PromiseReturnType; export type NewBookingEventType = | Awaited> | Awaited>; // Work with Typescript to require reqBody.end type ReqBodyWithoutEnd = z.infer>; type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; export type Invitee = { email: string; name: string; firstName: string; lastName: string; timeZone: string; language: { translate: TFunction; locale: string; }; }[]; export type OrganizerUser = Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; }; export type OriginalRescheduledBooking = Awaited>; type AwaitedBookingData = Awaited>; export type RescheduleReason = AwaitedBookingData["rescheduleReason"]; export type NoEmail = AwaitedBookingData["noEmail"]; export type AdditionalNotes = AwaitedBookingData["notes"]; export type ReqAppsStatus = AwaitedBookingData["appsStatus"]; export type SmsReminderNumber = AwaitedBookingData["smsReminderNumber"]; export type EventTypeId = AwaitedBookingData["eventTypeId"]; export type ReqBodyMetadata = ReqBodyWithEnd["metadata"]; export type IsConfirmedByDefault = ReturnType["isConfirmedByDefault"]; export type PaymentAppData = ReturnType; export interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; app: { categories: App["categories"]; dirName: string; }; key: Prisma.JsonValue; } export const getEventTypesFromDB = async (eventTypeId: number) => { const eventType = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId, }, select: { id: true, customInputs: true, disableGuests: true, users: { select: { credentials: { select: credentialForCalendarServiceSelect, }, ...userSelect.select, }, }, slug: true, team: { select: { id: true, name: true, parentId: true, }, }, bookingFields: true, title: true, length: true, eventName: true, schedulingType: true, description: true, periodType: true, periodStartDate: true, periodEndDate: true, periodDays: true, periodCountCalendarDays: true, lockTimeZoneToggleOnBookingPage: true, requiresConfirmation: true, requiresBookerEmailVerification: true, minimumBookingNotice: true, userId: true, price: true, currency: true, metadata: true, destinationCalendar: true, hideCalendarNotes: true, seatsPerTimeSlot: true, recurringEvent: true, seatsShowAttendees: true, seatsShowAvailabilityCount: true, bookingLimits: true, durationLimits: true, assignAllTeamMembers: true, parentId: true, useEventTypeDestinationCalendarEmail: true, owner: { select: { hideBranding: true, }, }, workflows: { include: { workflow: { include: { steps: true, }, }, }, }, locations: true, timeZone: true, schedule: { select: { id: true, availability: true, timeZone: true, }, }, hosts: { select: { isFixed: true, priority: true, user: { select: { credentials: { select: credentialForCalendarServiceSelect, }, ...userSelect.select, }, }, }, }, availability: { select: { date: true, startTime: true, endTime: true, days: true, }, }, secondaryEmailId: true, secondaryEmail: { select: { id: true, email: true, }, }, }, }); return { ...eventType, metadata: EventTypeMetaDataSchema.parse(eventType?.metadata || {}), recurringEvent: parseRecurringEvent(eventType?.recurringEvent), customInputs: customInputSchema.array().parse(eventType?.customInputs || []), locations: (eventType?.locations ?? []) as LocationObject[], bookingFields: getBookingFieldsWithSystemFields(eventType || {}), isDynamic: false, }; }; type IsFixedAwareUser = User & { isFixed: boolean; credentials: CredentialPayload[]; organization: { slug: string }; priority?: number; }; const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string[], req: IncomingMessage) => { try { if (!eventType.id) { if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { throw new Error("dynamicUserList is not properly defined or empty."); } const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(req); const users = await findUsersByUsername({ usernameList: dynamicUserList, orgSlug: isValidOrgDomain ? currentOrgDomain : null, }); return users; } const hosts = eventType.hosts || []; if (!Array.isArray(hosts)) { throw new Error("eventType.hosts is not properly defined."); } const users = hosts.map(({ user, isFixed, priority }) => ({ ...user, isFixed, priority, })); return users.length ? users : eventType.users; } catch (error) { if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { throw new HttpError({ statusCode: 400, message: error.message }); } throw new HttpError({ statusCode: 500, message: "Unable to load users" }); } }; export async function ensureAvailableUsers( eventType: Awaited> & { users: IsFixedAwareUser[]; }, input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType }, loggerWithEventDetails: Logger ) { const availableUsers: IsFixedAwareUser[] = []; const getStartDateTimeUtc = (startDateTimeInput: string, timeZone?: string) => { return timeZone === "Etc/GMT" ? dayjs.utc(startDateTimeInput) : dayjs(startDateTimeInput).tz(timeZone).utc(); }; const startDateTimeUtc = getStartDateTimeUtc(input.dateFrom, input.timeZone); const endDateTimeUtc = input.timeZone === "Etc/GMT" ? dayjs.utc(input.dateTo) : dayjs(input.dateTo).tz(input.timeZone).utc(); const duration = dayjs(input.dateTo).diff(input.dateFrom, "minute"); const originalBookingDuration = input.originalRescheduledBooking ? dayjs(input.originalRescheduledBooking.endTime).diff( dayjs(input.originalRescheduledBooking.startTime), "minutes" ) : undefined; const bookingLimits = parseBookingLimit(eventType?.bookingLimits); const durationLimits = parseDurationLimit(eventType?.durationLimits); let busyTimesFromLimitsBookingsAllUsers: Awaited> = []; if (eventType && (bookingLimits || durationLimits)) { busyTimesFromLimitsBookingsAllUsers = await getBusyTimesForLimitChecks({ userIds: eventType.users.map((u) => u.id), eventTypeId: eventType.id, startDate: startDateTimeUtc.format(), endDate: endDateTimeUtc.format(), rescheduleUid: input.originalRescheduledBooking?.uid ?? null, bookingLimits, durationLimits, }); } for (const user of eventType.users) { const { oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { ...input, userId: user.id, eventTypeId: eventType.id, duration: originalBookingDuration, returnDateOverrides: false, dateFrom: startDateTimeUtc.format(), dateTo: endDateTimeUtc.format(), }, { user, eventType, rescheduleUid: input.originalRescheduledBooking?.uid ?? null, busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers, } ); log.debug( "calendarBusyTimes==>>>", JSON.stringify({ bufferedBusyTimes, dateRanges, isRecurringEvent: eventType.recurringEvent }) ); if (!dateRanges.length) { loggerWithEventDetails.error( `User does not have availability at this time.`, safeStringify({ startDateTimeUtc, endDateTimeUtc, input, }) ); continue; } let foundConflict = false; let dateRangeForBooking = false; //check if event time is within the date range for (const dateRange of dateRanges) { if ( (startDateTimeUtc.isAfter(dateRange.start) || startDateTimeUtc.isSame(dateRange.start)) && (endDateTimeUtc.isBefore(dateRange.end) || endDateTimeUtc.isSame(dateRange.end)) ) { dateRangeForBooking = true; break; } } if (!dateRangeForBooking) { loggerWithEventDetails.error( `No date range for booking.`, safeStringify({ startDateTimeUtc, endDateTimeUtc, input, }) ); continue; } try { foundConflict = checkForConflicts(bufferedBusyTimes, startDateTimeUtc, duration); } catch (error) { loggerWithEventDetails.error("Unable set isAvailableToBeBooked. Using true. ", error); } // no conflicts found, add to available users. if (!foundConflict) { availableUsers.push(user); } } if (!availableUsers.length) { loggerWithEventDetails.error( `No available users found.`, safeStringify({ startDateTimeUtc, endDateTimeUtc, input, }) ); throw new Error(ErrorCode.NoAvailableUsersFound); } return availableUsers; } async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boolean) { return prisma.booking.findFirst({ where: { uid: uid, status: { in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING], }, }, include: { attendees: { select: { name: true, email: true, locale: true, timeZone: true, ...(seatsEventType && { bookingSeat: true, id: true }), }, }, user: { select: { id: true, name: true, email: true, locale: true, timeZone: true, destinationCalendar: true, credentials: { select: { id: true, userId: true, key: true, type: true, teamId: true, appId: true, invalid: true, user: { select: { email: true, }, }, }, }, }, }, destinationCalendar: true, payment: true, references: true, workflowReminders: true, }, }); } export async function getBookingData({ req, eventType, schema, }: { req: NextApiRequest; eventType: Awaited>; schema: T; }) { const reqBody = await schema.parseAsync(req.body); const reqBodyWithEnd = (reqBody: ReqBodyWithoutEnd): reqBody is ReqBodyWithEnd => { // Use the event length to auto-set the event end time. if (!Object.prototype.hasOwnProperty.call(reqBody, "end")) { reqBody.end = dayjs.utc(reqBody.start).add(eventType.length, "minutes").format(); } return true; }; if (!reqBodyWithEnd(reqBody)) { throw new Error(ErrorCode.RequestBodyWithouEnd); } // reqBody.end is no longer an optional property. if (reqBody.customInputs) { // Check if required custom inputs exist handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs); const reqBodyWithLegacyProps = bookingCreateSchemaLegacyPropsForApi.parse(reqBody); return { ...reqBody, name: reqBodyWithLegacyProps.name, email: reqBodyWithLegacyProps.email, guests: reqBodyWithLegacyProps.guests, location: reqBodyWithLegacyProps.location || "", smsReminderNumber: reqBodyWithLegacyProps.smsReminderNumber, notes: reqBodyWithLegacyProps.notes, rescheduleReason: reqBodyWithLegacyProps.rescheduleReason, // So TS doesn't complain about unknown properties calEventUserFieldsResponses: undefined, calEventResponses: undefined, customInputs: undefined, }; } if (!reqBody.responses) { throw new Error("`responses` must not be nullish"); } const responses = reqBody.responses; const { userFieldsResponses: calEventUserFieldsResponses, responses: calEventResponses } = getCalEventResponses({ bookingFields: eventType.bookingFields, responses, }); return { ...reqBody, name: responses.name, email: responses.email, guests: responses.guests ? responses.guests : [], location: responses.location?.optionValue || responses.location?.value || "", smsReminderNumber: responses.smsReminderNumber, notes: responses.notes || "", calEventUserFieldsResponses, rescheduleReason: responses.rescheduleReason, calEventResponses, // So TS doesn't complain about unknown properties customInputs: undefined, }; } async function createBooking({ originalRescheduledBooking, evt, eventTypeId, eventTypeSlug, reqBodyUser, reqBodyMetadata, reqBodyRecurringEventId, uid, responses, isConfirmedByDefault, smsReminderNumber, organizerUser, rescheduleReason, eventType, bookerEmail, paymentAppData, changedOrganizer, }: { originalRescheduledBooking: OriginalRescheduledBooking; evt: CalendarEvent; eventType: NewBookingEventType; eventTypeId: EventTypeId; eventTypeSlug: AwaitedBookingData["eventTypeSlug"]; reqBodyUser: ReqBodyWithEnd["user"]; reqBodyMetadata: ReqBodyWithEnd["metadata"]; reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; isConfirmedByDefault: IsConfirmedByDefault; smsReminderNumber: AwaitedBookingData["smsReminderNumber"]; organizerUser: Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; }; rescheduleReason: Awaited>["rescheduleReason"]; bookerEmail: Awaited>["email"]; paymentAppData: ReturnType; changedOrganizer: boolean; }) { if (originalRescheduledBooking) { evt.title = originalRescheduledBooking?.title || evt.title; evt.description = originalRescheduledBooking?.description || evt.description; evt.location = originalRescheduledBooking?.location || evt.location; evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location; } const eventTypeRel = !eventTypeId ? {} : { connect: { id: eventTypeId, }, }; const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; const dynamicGroupSlugRef = !eventTypeId ? (reqBodyUser as string).toLowerCase() : null; const attendeesData = evt.attendees.map((attendee) => { //if attendee is team member, it should fetch their locale not booker's locale //perhaps make email fetch request to see if his locale is stored, else return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone, locale: attendee.language.locale, }; }); if (evt.team?.members) { attendeesData.push( ...evt.team.members.map((member) => ({ email: member.email, name: member.name, timeZone: member.timeZone, locale: member.language.locale, })) ); } const newBookingData: Prisma.BookingCreateInput = { uid, userPrimaryEmail: evt.organizer.email, responses: responses === null || evt.seatsPerTimeSlot ? Prisma.JsonNull : responses, title: evt.title, startTime: dayjs.utc(evt.startTime).toDate(), endTime: dayjs.utc(evt.endTime).toDate(), description: evt.seatsPerTimeSlot ? null : evt.additionalNotes, customInputs: isPrismaObjOrUndefined(evt.customInputs), status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING, location: evt.location, eventType: eventTypeRel, smsReminderNumber, metadata: reqBodyMetadata, attendees: { createMany: { data: attendeesData, }, }, dynamicEventSlugRef, dynamicGroupSlugRef, iCalUID: evt.iCalUID ?? "", user: { connect: { id: organizerUser.id, }, }, destinationCalendar: evt.destinationCalendar && evt.destinationCalendar.length > 0 ? { connect: { id: evt.destinationCalendar[0].id }, } : undefined, }; if (reqBodyRecurringEventId) { newBookingData.recurringEventId = reqBodyRecurringEventId; } if (originalRescheduledBooking) { newBookingData.metadata = { ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), }; newBookingData["paid"] = originalRescheduledBooking.paid; newBookingData["fromReschedule"] = originalRescheduledBooking.uid; if (originalRescheduledBooking.uid) { newBookingData.cancellationReason = rescheduleReason; } if (newBookingData.attendees?.createMany?.data) { // Reschedule logic with booking with seats if (eventType?.seatsPerTimeSlot && bookerEmail) { newBookingData.attendees.createMany.data = attendeesData.filter( (attendee) => attendee.email === bookerEmail ); } } if (originalRescheduledBooking.recurringEventId) { newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; } } const createBookingObj = { include: { user: { select: { email: true, name: true, timeZone: true, username: true }, }, attendees: true, payment: true, references: true, }, data: newBookingData, }; if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) { const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success); if (bookingPayment) { createBookingObj.data.payment = { connect: { id: bookingPayment.id }, }; } } if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) { /* Validate if there is any payment app credential for this user */ await prisma.credential.findFirstOrThrow({ where: { appId: paymentAppData.appId, ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), }, select: { id: true, }, }); } return prisma.booking.create(createBookingObj); } export function getCustomInputsResponses( reqBody: { responses?: Record; customInputs?: z.infer["customInputs"]; }, eventTypeCustomInputs: Awaited>["customInputs"] ) { const customInputsResponses = {} as NonNullable; if (reqBody.customInputs && (reqBody.customInputs.length || 0) > 0) { reqBody.customInputs.forEach(({ label, value }) => { customInputsResponses[label] = value; }); } else { const responses = reqBody.responses || {}; // Backward Compatibility: Map new `responses` to old `customInputs` format so that webhooks can still receive same values. for (const [fieldName, fieldValue] of Object.entries(responses)) { const foundACustomInputForTheResponse = eventTypeCustomInputs.find( (input) => slugify(input.label) === fieldName ); if (foundACustomInputForTheResponse) { customInputsResponses[foundACustomInputForTheResponse.label] = fieldValue; } } } return customInputsResponses; } /** Updates the evt object with video call data found from booking references * * @param bookingReferences * @param evt * * @returns updated evt with video call data */ export const addVideoCallDataToEvent = (bookingReferences: BookingReference[], evt: CalendarEvent) => { const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); if (videoCallReference) { evt.videoCallData = { type: videoCallReference.type, id: videoCallReference.meetingId, password: videoCallReference?.meetingPassword, url: videoCallReference.meetingUrl, }; } return evt; }; export const createLoggerWithEventDetails = ( eventTypeId: number, reqBodyUser: string | string[] | undefined, eventTypeSlug: string | undefined ) => { return logger.getSubLogger({ prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`], }); }; export function handleAppsStatus( results: EventResult[], booking: (Booking & { appsStatus?: AppsStatus[] }) | null, reqAppsStatus: ReqAppsStatus ) { // Taking care of apps status const resultStatus: AppsStatus[] = results.map((app) => ({ appName: app.appName, type: app.type, success: app.success ? 1 : 0, failures: !app.success ? 1 : 0, errors: app.calError ? [app.calError] : [], warnings: app.calWarnings, })); if (reqAppsStatus === undefined) { if (booking !== null) { booking.appsStatus = resultStatus; } return resultStatus; } // From down here we can assume reqAppsStatus is not undefined anymore // Other status exist, so this is the last booking of a series, // proceeding to prepare the info for the event const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => { if (prev[curr.type]) { prev[curr.type].success += curr.success; prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors); prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []); } else { prev[curr.type] = curr; } return prev; }, {} as { [key: string]: AppsStatus }); return Object.values(calcAppsStatus); } function getICalSequence(originalRescheduledBooking: BookingType | null) { // If new booking set the sequence to 0 if (!originalRescheduledBooking) { return 0; } // If rescheduling and there is no sequence set, assume sequence should be 1 if (!originalRescheduledBooking.iCalSequence) { return 1; } // If rescheduling then increment sequence by 1 return originalRescheduledBooking.iCalSequence + 1; } export const findBookingQuery = async (bookingId: number) => { const foundBooking = await prisma.booking.findUnique({ where: { id: bookingId, }, select: { uid: true, location: true, startTime: true, endTime: true, title: true, description: true, status: true, responses: true, metadata: true, user: { select: { name: true, email: true, timeZone: true, username: true, }, }, eventType: { select: { title: true, description: true, currency: true, length: true, lockTimeZoneToggleOnBookingPage: true, requiresConfirmation: true, requiresBookerEmailVerification: true, price: true, }, }, }, }); // This should never happen but it's just typescript safe if (!foundBooking) { throw new Error("Internal Error. Couldn't find booking"); } // Don't leak any sensitive data return foundBooking; }; type BookingDataSchemaGetter = | typeof getBookingDataSchema | typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default; async function handler( req: NextApiRequest & { userId?: number | undefined; platformClientId?: string; platformRescheduleUrl?: string; platformCancelUrl?: string; platformBookingUrl?: string; platformBookingLocation?: string; }, bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema ) { const { userId, platformClientId, platformCancelUrl, platformBookingUrl, platformRescheduleUrl, platformBookingLocation, } = req; // handle dynamic user let eventType = !req.body.eventTypeId && !!req.body.eventTypeSlug ? getDefaultEvent(req.body.eventTypeSlug) : await getEventTypesFromDB(req.body.eventTypeId); eventType = { ...eventType, bookingFields: getBookingFieldsWithSystemFields(eventType), }; const bookingDataSchema = bookingDataSchemaGetter({ view: req.body?.rescheduleUid ? "reschedule" : "booking", bookingFields: eventType.bookingFields, }); const bookingData = await getBookingData({ req, eventType, schema: bookingDataSchema, }); const { recurringCount, noEmail, eventTypeId, eventTypeSlug, hasHashedBookingLink, language, appsStatus: reqAppsStatus, name: bookerName, email: bookerEmail, guests: reqGuests, location, notes: additionalNotes, smsReminderNumber, rescheduleReason, luckyUsers, ...reqBody } = bookingData; const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { logger.settings.minLevel = 0; } const fullName = getFullName(bookerName); // Why are we only using "en" locale const tGuests = await getTranslation("en", "common"); const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user); if (!eventType) throw new HttpError({ statusCode: 404, message: "event_type_not_found" }); const isTeamEventType = !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); const paymentAppData = getPaymentAppData(eventType); loggerWithEventDetails.info( `Booking eventType ${eventTypeId} started`, safeStringify({ reqBody: { user: reqBody.user, eventTypeId, eventTypeSlug, startTime: reqBody.start, endTime: reqBody.end, rescheduleUid: reqBody.rescheduleUid, location: location, timeZone: reqBody.timeZone, }, isTeamEventType, eventType: getPiiFreeEventType(eventType), dynamicUserList, paymentAppData: { enabled: paymentAppData.enabled, price: paymentAppData.price, paymentOption: paymentAppData.paymentOption, currency: paymentAppData.currency, appId: paymentAppData.appId, }, }) ); let timeOutOfBounds = false; try { timeOutOfBounds = isOutOfBounds( reqBody.start, { periodType: eventType.periodType, periodDays: eventType.periodDays, periodEndDate: eventType.periodEndDate, periodStartDate: eventType.periodStartDate, periodCountCalendarDays: eventType.periodCountCalendarDays, }, eventType.minimumBookingNotice ); } catch (error) { loggerWithEventDetails.warn({ message: "NewBooking: Unable set timeOutOfBounds. Using false. ", }); if (error instanceof BookingDateInPastError) { // TODO: HttpError should not bleed through to the console. loggerWithEventDetails.info(`Booking eventType ${eventTypeId} failed`, JSON.stringify({ error })); throw new HttpError({ statusCode: 400, message: error.message }); } } if (timeOutOfBounds) { const error = { errorCode: "BookingTimeOutOfBounds", message: `EventType '${eventType.eventName}' cannot be booked at this time.`, }; loggerWithEventDetails.warn({ message: `NewBooking: EventType '${eventType.eventName}' cannot be booked at this time.`, }); throw new HttpError({ statusCode: 400, message: error.message }); } const reqEventLength = dayjs(reqBody.end).diff(dayjs(reqBody.start), "minutes"); const validEventLengths = eventType.metadata?.multipleDuration?.length ? eventType.metadata.multipleDuration : [eventType.length]; if (!validEventLengths.includes(reqEventLength)) { loggerWithEventDetails.warn({ message: "NewBooking: Invalid event length" }); throw new HttpError({ statusCode: 400, message: "Invalid event length" }); } // loadUsers allows type inferring let users: (Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; })[] = await loadUsers(eventType, dynamicUserList, req); const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); if (!isDynamicAllowed && !eventTypeId) { loggerWithEventDetails.warn({ message: "NewBooking: Some of the users in this group do not allow dynamic booking", }); throw new HttpError({ message: "Some of the users in this group do not allow dynamic booking", statusCode: 400, }); } // If this event was pre-relationship migration // TODO: Establish whether this is dead code. if (!users.length && eventType.userId) { const eventTypeUser = await prisma.user.findUnique({ where: { id: eventType.userId, }, select: { credentials: { select: credentialForCalendarServiceSelect, }, // Don't leak to client ...userSelect.select, }, }); if (!eventTypeUser) { loggerWithEventDetails.warn({ message: "NewBooking: eventTypeUser.notFound" }); throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); } users.push(eventTypeUser); } if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); users = users.map((user) => ({ ...user, isFixed: user.isFixed === false ? false : user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN, })); loggerWithEventDetails.debug( "Concerned users", safeStringify({ users: users.map(getPiiFreeUser), }) ); let locationBodyString = location; // TODO: It's definition should be moved to getLocationValueForDb let organizerOrFirstDynamicGroupMemberDefaultLocationUrl = undefined; if (dynamicUserList.length > 1) { users = users.sort((a, b) => { const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0; const bIndex = (b.username && dynamicUserList.indexOf(b.username)) || 0; return aIndex - bIndex; }); const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata); locationBodyString = firstUsersMetadata?.defaultConferencingApp?.appLink || locationBodyString; organizerOrFirstDynamicGroupMemberDefaultLocationUrl = firstUsersMetadata?.defaultConferencingApp?.appLink; } let rescheduleUid = reqBody.rescheduleUid; if ( Object.prototype.hasOwnProperty.call(eventType, "bookingLimits") || Object.prototype.hasOwnProperty.call(eventType, "durationLimits") ) { const startAsDate = dayjs(reqBody.start).toDate(); if ( eventType.bookingLimits && /* Empty object is truthy */ Object.keys(eventType.bookingLimits).length > 0 ) { await checkBookingLimits( eventType.bookingLimits as IntervalLimit, startAsDate, eventType.id, rescheduleUid, eventType.schedule?.timeZone ); } if (eventType.durationLimits) { await checkDurationLimits(eventType.durationLimits as IntervalLimit, startAsDate, eventType.id); } } let bookingSeat: BookingSeat = null; let originalRescheduledBooking: BookingType = null; //this gets the original rescheduled booking if (rescheduleUid) { // rescheduleUid can be bookingUid and bookingSeatUid bookingSeat = await prisma.bookingSeat.findUnique({ where: { referenceUid: rescheduleUid, }, include: { booking: true, attendee: true, }, }); if (bookingSeat) { rescheduleUid = bookingSeat.booking.uid; } originalRescheduledBooking = await getOriginalRescheduledBooking( rescheduleUid, !!eventType.seatsPerTimeSlot ); if (!originalRescheduledBooking) { throw new HttpError({ statusCode: 404, message: "Could not find original booking" }); } if ( originalRescheduledBooking.status === BookingStatus.CANCELLED && !originalRescheduledBooking.rescheduled ) { throw new HttpError({ statusCode: 403, message: ErrorCode.CancelledBookingsCannotBeRescheduled }); } } let luckyUserResponse; //checks what users are available if (!eventType.seatsPerTimeSlot) { const eventTypeWithUsers: Awaited> & { users: IsFixedAwareUser[]; } = { ...eventType, users: users as IsFixedAwareUser[], ...(eventType.recurringEvent && { recurringEvent: { ...eventType.recurringEvent, count: recurringCount || eventType.recurringEvent.count, }, }), }; if (req.body.allRecurringDates && req.body.isFirstRecurringSlot) { const isTeamEvent = eventType.schedulingType === SchedulingType.COLLECTIVE || eventType.schedulingType === SchedulingType.ROUND_ROBIN; const fixedUsers = isTeamEvent ? eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed) : []; for ( let i = 0; i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; i++ ) { const start = req.body.allRecurringDates[i].start; const end = req.body.allRecurringDates[i].end; if (isTeamEvent) { // each fixed user must be available for (const key in fixedUsers) { await ensureAvailableUsers( { ...eventTypeWithUsers, users: [fixedUsers[key]] }, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails ); } } else { await ensureAvailableUsers( eventTypeWithUsers, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails ); } } } if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) { const availableUsers = await ensureAvailableUsers( eventTypeWithUsers, { dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails ); const luckyUsers: typeof users = []; const luckyUserPool = availableUsers.filter((user) => !user.isFixed); const notAvailableLuckyUsers: typeof users = []; loggerWithEventDetails.debug( "Computed available users", safeStringify({ availableUsers: availableUsers.map((user) => user.id), luckyUserPool: luckyUserPool.map((user) => user.id), }) ); // loop through all non-fixed hosts and get the lucky users while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { // find a lucky user that is not already in the luckyUsers array availableUsers: luckyUserPool.filter( (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) ), eventTypeId: eventType.id, }); if (!newLuckyUser) { break; // prevent infinite loop } if (req.body.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // for recurring round robin events check if lucky user is available for next slots try { for ( let i = 0; i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; i++ ) { const start = req.body.allRecurringDates[i].start; const end = req.body.allRecurringDates[i].end; await ensureAvailableUsers( { ...eventTypeWithUsers, users: [newLuckyUser] }, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails ); } // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); } catch { notAvailableLuckyUsers.push(newLuckyUser); loggerWithEventDetails.info( `Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.` ); } } else { luckyUsers.push(newLuckyUser); } } // ALL fixed users must be available if ( availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length ) { throw new Error(ErrorCode.HostsUnavailableForBooking); } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) }; } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // all recurring slots except the first one const luckyUsersFromFirstBooking = luckyUsers ? eventTypeWithUsers.users.filter((user) => luckyUsers.find((luckyUserId) => luckyUserId === user.id)) : []; const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed); users = [...fixedHosts, ...luckyUsersFromFirstBooking]; } } if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { loggerWithEventDetails.error(`No available users found for round robin event.`); throw new Error(ErrorCode.NoAvailableUsersFound); } const [organizerUser] = users; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); const allCredentials = await getAllCredentials(organizerUser, eventType); const { userReschedulingIsOwner, isConfirmedByDefault } = getRequiresConfirmationFlags({ eventType, bookingStartTime: reqBody.start, userId, originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, paymentAppData, }); // If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale. const attendeeInfoOnReschedule = userReschedulingIsOwner && originalRescheduledBooking ? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail) : null; const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language; const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); const isManagedEventType = !!eventType.parentId; // use host default if ((isManagedEventType || isTeamEventType) && locationBodyString === OrganizerDefaultConferencingAppType) { const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata); const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; if (organizerMetadata?.defaultConferencingApp?.appSlug) { const app = getAppFromSlug(organizerMetadata?.defaultConferencingApp?.appSlug); locationBodyString = app?.appData?.location?.type || locationBodyString; organizerOrFirstDynamicGroupMemberDefaultLocationUrl = organizerMetadata?.defaultConferencingApp?.appLink; } else { locationBodyString = "integrations:daily"; } } const invitee: Invitee = [ { email: bookerEmail, name: fullName, firstName: (typeof bookerName === "object" && bookerName.firstName) || "", lastName: (typeof bookerName === "object" && bookerName.lastName) || "", timeZone: attendeeTimezone, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, }, ]; const guests = (reqGuests || []).reduce((guestArray, guest) => { // If it's a team event, remove the team member from guests if (isTeamEventType && users.some((user) => user.email === guest)) { return guestArray; } guestArray.push({ email: guest, name: "", firstName: "", lastName: "", timeZone: attendeeTimezone, language: { translate: tGuests, locale: "en" }, }); return guestArray; }, [] as Invitee); const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); // For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video) // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, conferenceCredentialId: undefined, } : getLocationValueForDB(locationBodyString, eventType.locations); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); const teamDestinationCalendars: DestinationCalendar[] = []; // Organizer or user owner of this event type it's not listed as a team member. const teamMemberPromises = users.slice(1).map(async (user) => { // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 // push to teamDestinationCalendars if it's a team event but collective only if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) { teamDestinationCalendars.push({ ...user.destinationCalendar, externalId: processExternalId(user.destinationCalendar), }); } return { id: user.id, email: user.email ?? "", name: user.name ?? "", firstName: "", lastName: "", timeZone: user.timeZone, language: { translate: await getTranslation(user.locale ?? "en", "common"), locale: user.locale ?? "en", }, }; }); const teamMembers = await Promise.all(teamMemberPromises); const attendeesList = [...invitee, ...guests]; const responses = reqBody.responses || null; const evtName = !eventType?.isDynamic ? eventType.eventName : responses?.title; const eventNameObject = { //TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here. attendeeName: fullName || "Nameless", eventType: eventType.title, eventName: evtName, // we send on behalf of team if >1 round robin attendee | collective teamName: eventType.schedulingType === "COLLECTIVE" || users.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: organizerUser.name || "Nameless", location: bookingLocation, bookingFields: { ...responses }, t: tOrganizer, }; const iCalUID = getICalUID({ event: { iCalUID: originalRescheduledBooking?.iCalUID, uid: originalRescheduledBooking?.uid }, uid, }); // For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0. const iCalSequence = getICalSequence(originalRescheduledBooking); const organizerOrganizationProfile = await prisma.profile.findFirst({ where: { userId: organizerUser.id, username: dynamicUserList[0], }, }); const organizerOrganizationId = organizerOrganizationProfile?.organizationId; const bookerUrl = eventType.team ? await getBookerBaseUrl(eventType.team.parentId) : await getBookerBaseUrl(organizerOrganizationId ?? null); const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar ? [organizerUser.destinationCalendar] : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { organizerEmail = destinationCalendar[0].primaryEmail; } else if (eventType.secondaryEmailId && eventType.secondaryEmail?.email) { organizerEmail = eventType.secondaryEmail.email; } let evt: CalendarEvent = { bookerUrl, type: eventType.slug, title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately description: eventType.description, additionalNotes, customInputs, startTime: dayjs(reqBody.start).utc().format(), endTime: dayjs(reqBody.end).utc().format(), organizer: { id: organizerUser.id, name: organizerUser.name || "Nameless", email: organizerEmail, username: organizerUser.username || undefined, timeZone: organizerUser.timeZone, language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), }, responses: reqBody.calEventResponses || null, userFieldsResponses: reqBody.calEventUserFieldsResponses || null, attendees: attendeesList, location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later. conferenceCredentialId, destinationCalendar, hideCalendarNotes: eventType.hideCalendarNotes, requiresConfirmation: !isConfirmedByDefault, eventTypeId: eventType.id, // if seats are not enabled we should default true seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true, seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true, schedulingType: eventType.schedulingType, iCalUID, iCalSequence, platformClientId, platformRescheduleUrl, platformCancelUrl, platformBookingUrl, }; if (req.body.thirdPartyRecurringEventId) { evt.existingRecurringEvent = { recurringEventId: req.body.thirdPartyRecurringEventId, }; } if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") { evt.destinationCalendar?.push(...teamDestinationCalendars); } // data needed for triggering webhooks const eventTypeInfo: EventTypeInfo = { eventTitle: eventType.title, eventDescription: eventType.description, price: paymentAppData.price, currency: eventType.currency, length: reqEventLength, }; const teamId = await getTeamIdFromEventType({ eventType }); const triggerForUser = !teamId || (teamId && eventType.parentId); const subscriberOptions: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, teamId, }; const eventTrigger: WebhookTriggerEvents = rescheduleUid ? WebhookTriggerEvents.BOOKING_RESCHEDULED : WebhookTriggerEvents.BOOKING_CREATED; subscriberOptions.triggerEvent = eventTrigger; const subscriberOptionsMeetingEnded = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.MEETING_ENDED, teamId, }; const subscriberOptionsMeetingStarted = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.MEETING_STARTED, teamId, }; // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ rescheduleUid, reqBookingUid: reqBody.bookingUid, eventType, evt, invitee, allCredentials, organizerUser, originalRescheduledBooking, bookerEmail, tAttendees, bookingSeat, reqUserId: req.userId, rescheduleReason, reqBodyUser: reqBody.user, noEmail, isConfirmedByDefault, additionalNotes, reqAppsStatus, attendeeLanguage, paymentAppData, fullName, smsReminderNumber, eventTypeInfo, uid, eventTypeId, reqBodyMetadata: reqBody.metadata, subscriberOptions, eventTrigger, responses, }); if (newBooking) { req.statusCode = 201; const bookingResponse = { ...newBooking, user: { ...newBooking.user, email: null, }, }; return { ...bookingResponse, ...luckyUserResponse, }; } } if (isTeamEventType) { evt.team = { members: teamMembers, name: eventType.team?.name || "Nameless", id: eventType.team?.id ?? 0, }; } if (reqBody.recurringEventId && eventType.recurringEvent) { // Overriding the recurring event configuration count to be the actual number of events booked for // the recurring event (equal or less than recurring event configuration count) eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount }); evt.recurringEvent = eventType.recurringEvent; } const changedOrganizer = !!originalRescheduledBooking && eventType.schedulingType === SchedulingType.ROUND_ROBIN && originalRescheduledBooking.userId !== evt.organizer.id; let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; let booking: (Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }) | null = null; loggerWithEventDetails.debug( "Going to create booking in DB now", safeStringify({ organizerUser: organizerUser.id, attendeesList: attendeesList.map((guest) => ({ timeZone: guest.timeZone })), requiresConfirmation: evt.requiresConfirmation, isConfirmedByDefault, userReschedulingIsOwner, }) ); // update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) { await prisma.booking.update({ where: { id: originalRescheduledBooking.id, }, data: { rescheduled: true, status: BookingStatus.CANCELLED, }, }); } try { booking = await createBooking({ originalRescheduledBooking, evt, eventTypeId, eventTypeSlug, reqBodyUser: reqBody.user, reqBodyMetadata: reqBody.metadata, reqBodyRecurringEventId: reqBody.recurringEventId, uid, responses, isConfirmedByDefault, smsReminderNumber, organizerUser, rescheduleReason, eventType, bookerEmail, paymentAppData, changedOrganizer, }); // @NOTE: Add specific try catch for all subsequent async calls to avoid error // Sync Services await syncServicesUpdateWebUser( await prisma.user.findFirst({ where: { id: userId }, select: { id: true, email: true, name: true, username: true, createdDate: true }, }) ); evt.uid = booking?.uid ?? null; if (booking && booking.id && eventType.seatsPerTimeSlot) { const currentAttendee = booking.attendees.find( (attendee) => attendee.email === req.body.responses.email ); // Save description to bookingSeat const uniqueAttendeeId = uuid(); await prisma.bookingSeat.create({ data: { referenceUid: uniqueAttendeeId, data: { description: additionalNotes, responses, }, booking: { connect: { id: booking.id, }, }, attendee: { connect: { id: currentAttendee?.id, }, }, }, }); evt.attendeeSeatId = uniqueAttendeeId; } } catch (_err) { const err = getErrorFromUnknown(_err); loggerWithEventDetails.error( `Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message ); if (err.code === "P2002") { throw new HttpError({ statusCode: 409, message: "booking_conflict" }); } throw err; } // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. const credentials = await refreshCredentials(allCredentials); const eventManager = new EventManager({ ...organizerUser, credentials }); let videoCallUrl; //this is the actual rescheduling logic if (originalRescheduledBooking?.uid) { log.silly("Rescheduling booking", originalRescheduledBooking.uid); try { // cancel workflow reminders from previous rescheduled booking await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders); } catch (error) { loggerWithEventDetails.error( "Error while canceling scheduled workflow reminders", JSON.stringify({ error }) ); } evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); // If organizer is changed in RR event then we need to delete the previous host destination calendar events const previousHostDestinationCalendar = originalRescheduledBooking?.destinationCalendar ? [originalRescheduledBooking?.destinationCalendar] : []; if (changedOrganizer) { evt.title = getEventName(eventNameObject); // 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; } else { // In case of rescheduling, we need to keep the previous host destination calendar evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar ? [originalRescheduledBooking?.destinationCalendar] : evt.destinationCalendar; } const updateManager = await eventManager.reschedule( evt, originalRescheduledBooking.uid, undefined, changedOrganizer, previousHostDestinationCalendar ); // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; results = updateManager.results; referencesToCreate = updateManager.referencesToCreate; videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; // This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ results, }); let metadata: AdditionalInformation = {}; metadata = videoMetadata; videoCallUrl = _videoCallUrl; const isThereAnIntegrationError = results && results.some((res) => !res.success); if (isThereAnIntegrationError) { const error = { errorCode: "BookingReschedulingMeetingFailed", message: "Booking Rescheduling failed", }; loggerWithEventDetails.error( `EventManager.reschedule failure in some of the integrations ${organizerUser.username}`, safeStringify({ error, results }) ); } else { if (results.length) { // Handle Google Meet results // We use the original booking location since the evt location changes to daily if (bookingLocation === MeetLocationType) { const googleMeetResult = { appName: GoogleMeetMetadata.name, type: "conferencing", uid: results[0].uid, originalEvent: results[0].originalEvent, }; // Find index of google_calendar inside createManager.referencesToCreate const googleCalIndex = updateManager.referencesToCreate.findIndex( (ref) => ref.type === "google_calendar" ); const googleCalResult = results[googleCalIndex]; if (!googleCalResult) { loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); results.push({ ...googleMeetResult, success: false, calWarnings: [tOrganizer("google_meet_warning")], }); } if (googleCalResult?.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: true, }); // Add google_meet to referencesToCreate in the same index as google_calendar updateManager.referencesToCreate[googleCalIndex] = { ...updateManager.referencesToCreate[googleCalIndex], meetingUrl: googleCalResult.createdEvent.hangoutLink, }; // Also create a new referenceToCreate with type video for google_meet updateManager.referencesToCreate.push({ type: "google_meet_video", meetingUrl: googleCalResult.createdEvent.hangoutLink, uid: googleCalResult.uid, credentialId: updateManager.referencesToCreate[googleCalIndex].credentialId, }); } else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: false, }); } } metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = metadata.hangoutLink || results[0].createdEvent?.url || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || getVideoCallUrlFromCalEvent(evt) || videoCallUrl; } const calendarResult = results.find((result) => result.type.includes("_calendar")); evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) ? calendarResult?.updatedEvent[0]?.iCalUID : calendarResult?.updatedEvent?.iCalUID || undefined; } evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); // If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) { const copyEvent = cloneDeep(evt); const copyEventAdditionalInfo = { ...copyEvent, additionalInformation: metadata, additionalNotes, // Resets back to the additionalNote input and not the override value cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email }; loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); /* handle emails for round robin - if booked rr host is the same, then rescheduling email - if new rr host is booked, then cancellation email to old host and confirmation email to new host */ if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { const originalBookingMemberEmails: Person[] = []; for (const user of originalRescheduledBooking.attendees) { const translate = await getTranslation(user.locale ?? "en", "common"); originalBookingMemberEmails.push({ name: user.name, email: user.email, timeZone: user.timeZone, language: { translate, locale: user.locale ?? "en" }, }); } if (originalRescheduledBooking.user) { const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); originalBookingMemberEmails.push({ ...originalRescheduledBooking.user, name: originalRescheduledBooking.user.name || "", language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, }); } const newBookingMemberEmails: Person[] = copyEvent.team?.members .map((member) => member) .concat(copyEvent.organizer) .concat(copyEvent.attendees) || []; // scheduled Emails const newBookedMembers = newBookingMemberEmails.filter( (member) => !originalBookingMemberEmails.find((originalMember) => originalMember.email === member.email) ); // cancelled Emails const cancelledMembers = originalBookingMemberEmails.filter( (member) => !newBookingMemberEmails.find((newMember) => newMember.email === member.email) ); // rescheduled Emails const rescheduledMembers = newBookingMemberEmails.filter((member) => originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) ); sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers); sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers); sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers); } else { // send normal rescheduled emails (non round robin event, where organizers stay the same) await sendRescheduledEmails({ ...copyEvent, additionalInformation: metadata, additionalNotes, // Resets back to the additionalNote input and not the override value cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email }); } } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking } else if (isConfirmedByDefault) { // Use EventManager to conditionally use all needed integrations. const createManager = await eventManager.create(evt); // This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; results = createManager.results; referencesToCreate = createManager.referencesToCreate; videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; if (results.length > 0 && results.every((res) => !res.success)) { const error = { errorCode: "BookingCreatingMeetingFailed", message: "Booking failed", }; loggerWithEventDetails.error( `EventManager.create failure in some of the integrations ${organizerUser.username}`, safeStringify({ error, results }) ); } else { const metadata: AdditionalInformation = {}; if (results.length) { // Handle Google Meet results // We use the original booking location since the evt location changes to daily if (bookingLocation === MeetLocationType) { const googleMeetResult = { appName: GoogleMeetMetadata.name, type: "conferencing", uid: results[0].uid, originalEvent: results[0].originalEvent, }; // Find index of google_calendar inside createManager.referencesToCreate const googleCalIndex = createManager.referencesToCreate.findIndex( (ref) => ref.type === "google_calendar" ); const googleCalResult = results[googleCalIndex]; if (!googleCalResult) { loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); results.push({ ...googleMeetResult, success: false, calWarnings: [tOrganizer("google_meet_warning")], }); } if (googleCalResult?.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: true, }); // Add google_meet to referencesToCreate in the same index as google_calendar createManager.referencesToCreate[googleCalIndex] = { ...createManager.referencesToCreate[googleCalIndex], meetingUrl: googleCalResult.createdEvent.hangoutLink, }; // Also create a new referenceToCreate with type video for google_meet createManager.referencesToCreate.push({ type: "google_meet_video", meetingUrl: googleCalResult.createdEvent.hangoutLink, uid: googleCalResult.uid, credentialId: createManager.referencesToCreate[googleCalIndex].credentialId, }); } else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: false, }); } } // TODO: Handle created event metadata more elegantly metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; if (evt.iCalUID !== booking.iCalUID) { // The eventManager could change the iCalUID. At this point we can update the DB record await prisma.booking.update({ where: { id: booking.id, }, data: { iCalUID: evt.iCalUID || booking.iCalUID, }, }); } } if (noEmail !== true) { let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; const workflows = eventType.workflows.map((workflow) => workflow.workflow); if (eventType.workflows) { isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false; isAttendeeConfirmationEmailDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; if (isHostConfirmationEmailsDisabled) { isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); } if (isAttendeeConfirmationEmailDisabled) { isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); } } loggerWithEventDetails.debug( "Emails: Sending scheduled emails for booking confirmation", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), }) ); await sendScheduledEmails( { ...evt, additionalInformation: metadata, additionalNotes, customInputs, }, eventNameObject, isHostConfirmationEmailsDisabled, isAttendeeConfirmationEmailDisabled ); } } } else { // If isConfirmedByDefault is false, then booking can't be considered ACCEPTED and thus EventManager has no role to play. Booking is created as PENDING loggerWithEventDetails.debug( `EventManager doesn't need to create or reschedule event for booking ${organizerUser.username}`, safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), isConfirmedByDefault, paymentValue: paymentAppData.price, }) ); } const bookingRequiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !originalRescheduledBooking?.paid && !!booking; if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) { loggerWithEventDetails.debug( `Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`, safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), }) ); await sendOrganizerRequestEmail({ ...evt, additionalNotes }); await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); } if (booking.location?.startsWith("http")) { videoCallUrl = booking.location; } const metadata = videoCallUrl ? { videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, } : undefined; const webhookData = { ...evt, ...eventTypeInfo, bookingId: booking?.id, rescheduleId: originalRescheduledBooking?.id || undefined, rescheduleUid, rescheduleStartTime: originalRescheduledBooking?.startTime ? dayjs(originalRescheduledBooking?.startTime).utc().format() : undefined, rescheduleEndTime: originalRescheduledBooking?.endTime ? dayjs(originalRescheduledBooking?.endTime).utc().format() : undefined, metadata: { ...metadata, ...reqBody.metadata }, eventTypeId, status: "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, }; if (bookingRequiresPayment) { loggerWithEventDetails.debug(`Booking ${organizerUser.username} requires payment`); // Load credentials.app.categories const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), app: { categories: { hasSome: ["payment"], }, }, }, select: { key: true, appId: true, app: { select: { categories: true, dirName: true, }, }, }, }); const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => { return credential.appId === paymentAppData.appId; }); if (!eventTypePaymentAppCredential) { throw new HttpError({ statusCode: 400, message: "Missing payment credentials" }); } // Convert type of eventTypePaymentAppCredential to appId: EventTypeAppList if (!booking.user) booking.user = organizerUser; const payment = await handlePayment( evt, eventType, eventTypePaymentAppCredential as IEventTypePaymentCredentialType, booking, fullName, bookerEmail ); const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, teamId, }; await handleWebhookTrigger({ subscriberOptions: subscriberOptionsPaymentInitiated, eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, webhookData: { ...webhookData, paymentId: payment?.id, }, }); req.statusCode = 201; // TODO: Refactor better so this booking object is not passed // all around and instead the individual fields are sent as args. const bookingReponse = { ...booking, user: { ...booking.user, email: null, }, }; return { ...bookingReponse, ...luckyUserResponse, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id, }; } loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`); // We are here so, booking doesn't require payment and booking is also created in DB already, through createBooking call if (isConfirmedByDefault) { try { const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); const subscribersMeetingStarted = await getWebhooks(subscriberOptionsMeetingStarted); subscribersMeetingEnded.forEach((subscriber) => { if (rescheduleUid && originalRescheduledBooking) { cancelScheduledJobs(originalRescheduledBooking, undefined, true); } if (booking && booking.status === BookingStatus.ACCEPTED) { scheduleTrigger(booking, subscriber.subscriberUrl, subscriber, WebhookTriggerEvents.MEETING_ENDED); } }); subscribersMeetingStarted.forEach((subscriber) => { if (rescheduleUid && originalRescheduledBooking) { cancelScheduledJobs(originalRescheduledBooking, undefined, true); } if (booking && booking.status === BookingStatus.ACCEPTED) { scheduleTrigger( booking, subscriber.subscriberUrl, subscriber, WebhookTriggerEvents.MEETING_STARTED ); } }); } catch (error) { loggerWithEventDetails.error( "Error while running scheduledJobs for booking", JSON.stringify({ error }) ); } // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); } else { // if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED; subscriberOptions.triggerEvent = eventTrigger; webhookData.status = "PENDING"; await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); } // Avoid passing referencesToCreate with id unique constrain values // refresh hashed link if used const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`; const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL)); try { if (hasHashedBookingLink) { await prisma.hashedLink.update({ where: { link: reqBody.hashedLink as string, }, data: { link: hashedUid, }, }); } } catch (error) { loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error })); } if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); try { await prisma.booking.update({ where: { uid: booking.uid, }, data: { metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata }, references: { createMany: { data: referencesToCreate, }, }, }, }); } catch (error) { loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error })); } const evtWithMetadata = { ...evt, metadata, eventType: { slug: eventType.slug } }; await scheduleMandatoryReminder( evtWithMetadata, eventType.workflows || [], !isConfirmedByDefault, !!eventType.owner?.hideBranding, evt.attendeeSeatId ); try { await scheduleWorkflowReminders({ workflows: eventType.workflows, smsReminderNumber: smsReminderNumber || null, calendarEvent: evtWithMetadata, isNotConfirmed: rescheduleUid ? false : !isConfirmedByDefault, isRescheduleEvent: !!rescheduleUid, isFirstRecurringEvent: true, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); } // booking successful req.statusCode = 201; // TODO: Refactor better so this booking object is not passed // all around and instead the individual fields are sent as args. const bookingResponse = { ...booking, user: { ...booking.user, email: null, }, }; return { ...bookingResponse, ...luckyUserResponse, references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, }; } export default handler; function getVideoCallDetails({ results, }: { results: EventResult[]; }) { const firstVideoResult = results.find((result) => result.type.includes("_video")); const metadata: AdditionalInformation = {}; let updatedVideoEvent = null; if (firstVideoResult && firstVideoResult.success) { updatedVideoEvent = Array.isArray(firstVideoResult.updatedEvent) ? firstVideoResult.updatedEvent[0] : firstVideoResult.updatedEvent; if (updatedVideoEvent) { metadata.hangoutLink = updatedVideoEvent.hangoutLink; metadata.conferenceData = updatedVideoEvent.conferenceData; metadata.entryPoints = updatedVideoEvent.entryPoints; } } const videoCallUrl = metadata.hangoutLink || updatedVideoEvent?.url; return { videoCallUrl, metadata, updatedVideoEvent }; } function getRequiresConfirmationFlags({ eventType, bookingStartTime, userId, paymentAppData, originalRescheduledBookingOrganizerId, }: { eventType: Pick>, "metadata" | "requiresConfirmation">; bookingStartTime: string; userId: number | undefined; paymentAppData: { price: number }; originalRescheduledBookingOrganizerId: number | undefined; }) { let requiresConfirmation = eventType?.requiresConfirmation; const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold; if (rcThreshold) { if (dayjs(dayjs(bookingStartTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) { requiresConfirmation = false; } } // If the user is not the owner of the event, new booking should be always pending. // Otherwise, an owner rescheduling should be always accepted. // Before comparing make sure that userId is set, otherwise undefined === undefined const userReschedulingIsOwner = !!(userId && originalRescheduledBookingOrganizerId === userId); const isConfirmedByDefault = (!requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner; return { /** * Organizer of the booking is rescheduling */ userReschedulingIsOwner, /** * Booking won't need confirmation to be ACCEPTED */ isConfirmedByDefault, }; } function handleCustomInputs( eventTypeCustomInputs: EventTypeCustomInput[], reqCustomInputs: { value: string | boolean; label: string; }[] ) { eventTypeCustomInputs.forEach((etcInput) => { if (etcInput.required) { const input = reqCustomInputs.find((i) => i.label === etcInput.label); if (etcInput.type === "BOOL") { z.literal(true, { errorMap: () => ({ message: `Missing ${etcInput.type} customInput: '${etcInput.label}'` }), }).parse(input?.value); } else if (etcInput.type === "PHONE") { z.string({ errorMap: () => ({ message: `Missing ${etcInput.type} customInput: '${etcInput.label}'`, }), }) .refine((val) => isValidPhoneNumber(val), { message: "Phone number is invalid", }) .parse(input?.value); } else { // type: NUMBER are also passed as string z.string({ errorMap: () => ({ message: `Missing ${etcInput.type} customInput: '${etcInput.label}'` }), }) .min(1) .parse(input?.value); } } }); } /** * This method is mostly same as the one in UserRepository but it includes a lot more relations which are specific requirement here * TODO: Figure out how to keep it in UserRepository and use it here */ export const findUsersByUsername = async ({ usernameList, orgSlug, }: { orgSlug: string | null; usernameList: string[]; }) => { log.debug("findUsersByUsername", { usernameList, orgSlug }); const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({ orgSlug, usernameList, }); return ( await prisma.user.findMany({ where, select: { ...userSelect.select, credentials: { select: credentialForCalendarServiceSelect, }, metadata: true, }, }) ).map((user) => { const profile = profiles?.find((profile) => profile.user.id === user.id) ?? null; return { ...user, organizationId: profile?.organizationId ?? null, profile, }; }); };