refactor: handleNewBooking #3 (#15612)

* refactor: handleNewBooking #3

* fix: type error

* fix: type err

* fix: type err

* refactor: create booking factor

* chore: refactor availusers

* chore: type err

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Udit Takkar
2024-07-08 18:18:36 +00:00
committed by GitHub
co-authored by Hariom Balhara Keith Williams
parent 4a2a2d165b
commit 5f8d090d3c
20 changed files with 740 additions and 554 deletions
@@ -17,4 +17,6 @@ const getBookingDataSchema = ({
);
};
export type TgetBookingDataSchema = z.infer<ReturnType<typeof getBookingDataSchema>>;
export default getBookingDataSchema;
@@ -1,11 +1,9 @@
import type { App, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { DestinationCalendar } from "@prisma/client";
import type { Prisma } from "@prisma/client";
// 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 type z from "zod";
@@ -16,12 +14,9 @@ import {
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 { getUsersAvailability } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import {
@@ -35,7 +30,6 @@ import {
} 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 {
@@ -50,7 +44,6 @@ import {
deleteWebhookScheduledTriggers,
scheduleTrigger,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import { isPrismaObjOrUndefined, parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
@@ -76,10 +69,8 @@ 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 {
bookingCreateSchemaLegacyPropsForApi,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import type { bookingCreateSchemaLegacyPropsForApi } from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import {
deleteAllWorkflowReminders,
getAllWorkflowsFromEventType,
@@ -91,507 +82,35 @@ import type {
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 { checkIfBookerEmailIsBlocked } from "./handleNewBooking/checkIfBookerEmailIsBlocked";
import { createBooking } from "./handleNewBooking/createBooking";
import { ensureAvailableUsers } from "./handleNewBooking/ensureAvailableUsers";
import { getBookingData } from "./handleNewBooking/getBookingData";
import { getEventTypesFromDB } from "./handleNewBooking/getEventTypesFromDB";
import type { getEventTypeResponse } from "./handleNewBooking/getEventTypesFromDB";
import { getOriginalRescheduledBooking } from "./handleNewBooking/getOriginalRescheduledBooking";
import { getRequiresConfirmationFlags } from "./handleNewBooking/getRequiresConfirmationFlags";
import { handleAppsStatus } from "./handleNewBooking/handleAppsStatus";
import { handleCustomInputs } from "./handleNewBooking/handleCustomInputs";
import { loadUsers } from "./handleNewBooking/loadUsers";
import type {
Invitee,
IEventTypePaymentCredentialType,
IsFixedAwareUser,
BookingType,
Booking,
} from "./handleNewBooking/types";
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<typeof userSelect>;
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
export type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type NewBookingEventType = Awaited<ReturnType<typeof getDefaultEvent>> | getEventTypeResponse;
// Work with Typescript to require reqBody.end
type ReqBodyWithoutEnd = z.infer<ReturnType<typeof getBookingDataSchema>>;
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<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
export type OriginalRescheduledBooking = Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
type AwaitedBookingData = Awaited<ReturnType<typeof getBookingData>>;
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<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
export type PaymentAppData = ReturnType<typeof getPaymentAppData>;
export interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
app: {
categories: App["categories"];
dirName: string;
};
key: Prisma.JsonValue;
}
type IsFixedAwareUser = User & {
isFixed: boolean;
credentials: CredentialPayload[];
organization: { slug: string };
priority?: number;
};
export async function ensureAvailableUsers(
eventType: getEventTypeResponse & {
users: IsFixedAwareUser[];
},
input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType },
loggerWithEventDetails: Logger<unknown>
) {
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<ReturnType<typeof getBusyTimesForLimitChecks>> = [];
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,
});
}
(
await getUsersAvailability({
users: eventType.users,
query: {
...input,
eventTypeId: eventType.id,
duration: originalBookingDuration,
returnDateOverrides: false,
dateFrom: startDateTimeUtc.format(),
dateTo: endDateTimeUtc.format(),
},
initialData: {
eventType,
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers,
},
})
).forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => {
const user = eventType.users[index];
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,
})
);
return;
}
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,
})
);
return;
}
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<T extends z.ZodType>({
req,
eventType,
schema,
}: {
req: NextApiRequest;
eventType: getEventTypeResponse;
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<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
rescheduleReason: Awaited<ReturnType<typeof getBookingData>>["rescheduleReason"];
bookerEmail: Awaited<ReturnType<typeof getBookingData>>["email"];
paymentAppData: ReturnType<typeof getPaymentAppData>;
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<string, object>;
@@ -667,53 +186,6 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
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;
@@ -0,0 +1,267 @@
import { Prisma } from "@prisma/client";
import type short from "short-uuid";
import dayjs from "@calcom/dayjs";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { TgetBookingDataSchema } from "../getBookingDataSchema";
import type {
EventTypeId,
AwaitedBookingData,
NewBookingEventType,
IsConfirmedByDefault,
PaymentAppData,
OriginalRescheduledBooking,
AwaitedLoadUsers,
} from "./types";
type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
type CreateBookingParams = {
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: AwaitedLoadUsers[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
rescheduleReason: AwaitedBookingData["rescheduleReason"];
bookerEmail: AwaitedBookingData["email"];
paymentAppData: PaymentAppData;
changedOrganizer: boolean;
};
function updateEventDetails(
evt: CalendarEvent,
originalRescheduledBooking: OriginalRescheduledBooking | null,
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;
}
}
export async function createBooking({
originalRescheduledBooking,
evt,
eventTypeId,
eventTypeSlug,
reqBodyUser,
reqBodyMetadata,
reqBodyRecurringEventId,
uid,
responses,
isConfirmedByDefault,
smsReminderNumber,
organizerUser,
rescheduleReason,
eventType,
bookerEmail,
paymentAppData,
changedOrganizer,
}: CreateBookingParams) {
updateEventDetails(evt, originalRescheduledBooking, changedOrganizer);
const newBookingData = buildNewBookingData({
uid,
evt,
responses,
isConfirmedByDefault,
reqBodyMetadata,
smsReminderNumber,
eventTypeSlug,
organizerUser,
reqBodyRecurringEventId,
originalRescheduledBooking,
bookerEmail,
rescheduleReason,
eventType,
eventTypeId,
reqBodyUser,
});
return await saveBooking(newBookingData, originalRescheduledBooking, paymentAppData, organizerUser);
}
async function saveBooking(
newBookingData: Prisma.BookingCreateInput,
originalRescheduledBooking: OriginalRescheduledBooking,
paymentAppData: PaymentAppData,
organizerUser: CreateBookingParams["organizerUser"]
) {
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) {
await prisma.credential.findFirstOrThrow({
where: {
appId: paymentAppData.appId,
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
},
select: { id: true },
});
}
return prisma.booking.create(createBookingObj);
}
function getEventTypeRel(eventTypeId: EventTypeId) {
return eventTypeId ? { connect: { id: eventTypeId } } : {};
}
function getAttendeesData(evt: Pick<CalendarEvent, "attendees" | "team">) {
//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
const attendees = evt.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
}));
if (evt.team?.members) {
attendees.push(
...evt.team.members.map((member) => ({
email: member.email,
name: member.name,
timeZone: member.timeZone,
locale: member.language.locale,
}))
);
}
return attendees;
}
function buildNewBookingData(params: {
uid: short.SUUID;
evt: CalendarEvent;
responses: ReqBodyWithEnd["responses"] | null;
isConfirmedByDefault: IsConfirmedByDefault;
reqBodyMetadata: ReqBodyWithEnd["metadata"];
smsReminderNumber: AwaitedBookingData["smsReminderNumber"];
eventTypeSlug: AwaitedBookingData["eventTypeSlug"];
organizerUser: CreateBookingParams["organizerUser"];
reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"];
originalRescheduledBooking: OriginalRescheduledBooking | null;
bookerEmail: AwaitedBookingData["email"];
rescheduleReason: AwaitedBookingData["rescheduleReason"];
eventType: NewBookingEventType;
eventTypeId: EventTypeId;
reqBodyUser: ReqBodyWithEnd["user"];
}): Prisma.BookingCreateInput {
const {
uid,
evt,
responses,
isConfirmedByDefault,
reqBodyMetadata,
smsReminderNumber,
eventTypeSlug,
organizerUser,
reqBodyRecurringEventId,
originalRescheduledBooking,
bookerEmail,
rescheduleReason,
eventType,
eventTypeId,
reqBodyUser,
} = params;
const attendeesData = getAttendeesData(evt);
const eventTypeRel = getEventTypeRel(eventTypeId);
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: !eventTypeId ? eventTypeSlug : null,
dynamicGroupSlugRef: !eventTypeId ? (reqBodyUser as string).toLowerCase() : null,
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;
}
// Reschedule logic with booking with seats
if (newBookingData.attendees?.createMany?.data && eventType?.seatsPerTimeSlot && bookerEmail) {
newBookingData.attendees.createMany.data = attendeesData.filter(
(attendee) => attendee.email === bookerEmail
);
}
if (originalRescheduledBooking.recurringEventId) {
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
}
}
return newBookingData;
}
export type Booking = Prisma.PromiseReturnType<typeof createBooking>;
@@ -0,0 +1,155 @@
import type { Logger } from "tslog";
import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes";
import { getUsersAvailability } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { safeStringify } from "@calcom/lib/safeStringify";
import { checkForConflicts } from "../conflictChecker/checkForConflicts";
import type { getEventTypeResponse } from "./getEventTypesFromDB";
import type { IsFixedAwareUser, BookingType } from "./types";
type DateRange = {
start: Dayjs;
end: Dayjs;
};
const getDateTimeInUtc = (timeInput: string, timeZone?: string) => {
return timeZone === "Etc/GMT" ? dayjs.utc(timeInput) : dayjs(timeInput).tz(timeZone).utc();
};
const getOriginalBookingDuration = (originalBooking?: BookingType) => {
return originalBooking
? dayjs(originalBooking.endTime).diff(dayjs(originalBooking.startTime), "minutes")
: undefined;
};
const hasDateRangeForBooking = (
dateRanges: DateRange[],
startDateTimeUtc: dayjs.Dayjs,
endDateTimeUtc: dayjs.Dayjs
) => {
let dateRangeForBooking = false;
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;
}
}
return dateRangeForBooking;
};
export async function ensureAvailableUsers(
eventType: getEventTypeResponse & {
users: IsFixedAwareUser[];
},
input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType },
loggerWithEventDetails: Logger<unknown>
) {
const availableUsers: IsFixedAwareUser[] = [];
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
const endDateTimeUtc = getDateTimeInUtc(input.dateTo, input.timeZone);
const duration = dayjs(input.dateTo).diff(input.dateFrom, "minute");
const originalBookingDuration = getOriginalBookingDuration(input.originalRescheduledBooking);
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const busyTimesFromLimitsBookingsAllUsers: Awaited<ReturnType<typeof getBusyTimesForLimitChecks>> =
eventType && (bookingLimits || durationLimits)
? 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,
})
: [];
const usersAvailability = await getUsersAvailability({
users: eventType.users,
query: {
...input,
eventTypeId: eventType.id,
duration: originalBookingDuration,
returnDateOverrides: false,
dateFrom: startDateTimeUtc.format(),
dateTo: endDateTimeUtc.format(),
},
initialData: {
eventType,
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers,
},
});
usersAvailability.forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => {
const user = eventType.users[index];
loggerWithEventDetails.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,
})
);
return;
}
//check if event time is within the date range
if (!hasDateRangeForBooking(dateRanges, startDateTimeUtc, endDateTimeUtc)) {
loggerWithEventDetails.error(
`No date range for booking.`,
safeStringify({
startDateTimeUtc,
endDateTimeUtc,
input,
})
);
return;
}
try {
const foundConflict = checkForConflicts(bufferedBusyTimes, startDateTimeUtc, duration);
// no conflicts found, add to available users.
if (!foundConflict) {
availableUsers.push(user);
}
} catch (error) {
loggerWithEventDetails.error("Unable set isAvailableToBeBooked. Using true. ", error);
}
});
if (!availableUsers.length) {
loggerWithEventDetails.error(
`No available users found.`,
safeStringify({
startDateTimeUtc,
endDateTimeUtc,
input,
})
);
throw new Error(ErrorCode.NoAvailableUsersFound);
}
return availableUsers;
}
@@ -0,0 +1,48 @@
import prisma from "@calcom/prisma";
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;
};
@@ -0,0 +1,89 @@
import type { EventTypeCustomInput } from "@prisma/client";
import type { NextApiRequest } from "next";
import type z from "zod";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { bookingCreateSchemaLegacyPropsForApi } from "@calcom/prisma/zod-utils";
import type { TgetBookingDataSchema } from "../getBookingDataSchema";
import { handleCustomInputs } from "./handleCustomInputs";
import type { getEventTypeResponse } from "./types";
type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
export async function getBookingData<T extends z.ZodType>({
req,
eventType,
schema,
}: {
req: NextApiRequest;
eventType: getEventTypeResponse;
schema: T;
}) {
const reqBody = await schema.parseAsync(req.body);
const reqBodyWithEnd = (reqBody: TgetBookingDataSchema): 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,
};
}
export type AwaitedBookingData = Awaited<ReturnType<typeof getBookingData>>;
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"];
@@ -0,0 +1,60 @@
import type { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
export 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 type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
export type OriginalRescheduledBooking = Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
@@ -69,3 +69,5 @@ function determineIsConfirmedByDefault(
): boolean {
return (!requiresConfirmation && price === 0) || userReschedulingIsOwner;
}
export type IsConfirmedByDefault = ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
@@ -1,7 +1,7 @@
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
import type { EventResult } from "@calcom/types/EventManager";
import type { ReqAppsStatus, Booking } from "../handleNewBooking";
import type { ReqAppsStatus, Booking } from "./types";
export function handleAppsStatus(
results: EventResult<AdditionalInformation>[],
@@ -8,7 +8,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { NewBookingEventType } from "../handleNewBooking";
import type { NewBookingEventType } from "./types";
const log = logger.getSubLogger({ prefix: ["[loadUsers]:handleNewBooking "] });
@@ -85,3 +85,5 @@ export const findUsersByUsername = async ({
};
});
};
export type AwaitedLoadUsers = Awaited<ReturnType<typeof loadUsers>>;
@@ -0,0 +1,81 @@
import type { App } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { TFunction } from "next-i18next";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { AwaitedGetDefaultEvent } from "@calcom/lib/defaultEvents";
import type { PaymentAppData } from "@calcom/lib/getPaymentAppData";
import type { userSelect } from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Booking } from "./createBooking";
import type {
AwaitedBookingData,
RescheduleReason,
NoEmail,
AdditionalNotes,
ReqAppsStatus,
SmsReminderNumber,
EventTypeId,
ReqBodyMetadata,
} from "./getBookingData";
import type { getEventTypeResponse } from "./getEventTypesFromDB";
import type { BookingType, OriginalRescheduledBooking } from "./getOriginalRescheduledBooking";
import type { getRequiresConfirmationFlags } from "./getRequiresConfirmationFlags";
import type { AwaitedLoadUsers } from "./loadUsers";
type User = Prisma.UserGetPayload<typeof userSelect>;
export type OrganizerUser = AwaitedLoadUsers[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
export type Invitee = {
email: string;
name: string;
firstName: string;
lastName: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
}[];
export interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
app: {
categories: App["categories"];
dirName: string;
};
key: Prisma.JsonValue;
}
export type IsFixedAwareUser = User & {
isFixed: boolean;
credentials: CredentialPayload[];
organization: { slug: string };
priority?: number;
};
export type NewBookingEventType = AwaitedGetDefaultEvent | getEventTypeResponse;
export type IsConfirmedByDefault = ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
export type {
AwaitedBookingData,
RescheduleReason,
NoEmail,
AdditionalNotes,
ReqAppsStatus,
SmsReminderNumber,
EventTypeId,
ReqBodyMetadata,
PaymentAppData,
BookingType,
Booking,
OriginalRescheduledBooking,
AwaitedLoadUsers,
getEventTypeResponse,
};
@@ -15,8 +15,8 @@ import { handlePayment } from "@calcom/lib/payment/handlePayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking";
import { findBookingQuery } from "../../handleNewBooking";
import { findBookingQuery } from "../../handleNewBooking/findBookingQuery";
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking/types";
import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types";
const createNewSeat = async (
@@ -8,7 +8,7 @@ import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { OriginalRescheduledBooking } from "../../handleNewBooking";
import type { OriginalRescheduledBooking } from "../../handleNewBooking/types";
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
@@ -7,7 +7,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
import { findBookingQuery } from "../../../handleNewBooking";
import { findBookingQuery } from "../../../handleNewBooking/findBookingQuery";
import lastAttendeeDeleteBooking from "../../lib/lastAttendeeDeleteBooking";
import type { RescheduleSeatedBookingObject, SeatAttendee, NewTimeSlotBooking } from "../../types";
@@ -10,7 +10,8 @@ import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking";
import { addVideoCallDataToEvent } from "../../../handleNewBooking";
import { findBookingQuery } from "../../../handleNewBooking/findBookingQuery";
import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types";
const combineTwoSeatedBookings = async (
@@ -6,9 +6,11 @@ import { sendRescheduledEmails } from "@calcom/emails";
import prisma from "@calcom/prisma";
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking";
import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking";
import { addVideoCallDataToEvent } from "../../../handleNewBooking";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
import { findBookingQuery } from "../../../handleNewBooking/findBookingQuery";
import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus";
import type { Booking } from "../../../handleNewBooking/types";
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
const moveSeatedBookingToNewTimeSlot = async (
+1 -1
View File
@@ -4,7 +4,7 @@ import type z from "zod";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking";
import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking/types";
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
@@ -8,7 +8,8 @@ import { createInstantMeetingWithCalVideo } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getBookingData, getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNewBooking";
import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNewBooking";
import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { getFullName } from "@calcom/features/form-builder/utils";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
+2
View File
@@ -168,3 +168,5 @@ export const getUsernameList = (users: string | string[] | undefined): string[]
};
export default defaultEvents;
export type AwaitedGetDefaultEvent = Awaited<ReturnType<typeof getDefaultEvent>>;
+2
View File
@@ -53,3 +53,5 @@ export default function getPaymentAppData(
}
);
}
export type PaymentAppData = ReturnType<typeof getPaymentAppData>;