Compare commits

...

8 Commits

Author SHA1 Message Date
Joe Au-Yeung 776519e262
Merge branch 'main' into refactor/create-booking 2023-10-30 12:12:17 -04:00
Joe Au-Yeung 4cf5e30059
Merge branch 'main' into refactor/create-booking 2023-10-26 09:53:21 -04:00
Joe Au-Yeung 593ac641b1
Merge branch 'main' into refactor/create-booking 2023-10-24 10:50:02 -04:00
Joe Au-Yeung d4e7ae0858
Merge branch 'main' into refactor/create-booking 2023-10-23 16:23:55 -04:00
Joe Au-Yeung ad76c74b91
Merge branch 'main' into refactor/create-booking 2023-10-19 10:29:32 -04:00
Joe Au-Yeung 010fd62c8e
Merge branch 'main' into refactor/create-booking 2023-10-18 09:56:51 -04:00
Joe Au-Yeung 1e1ceb9bf6 Type fix 2023-10-18 09:52:36 -04:00
Joe Au-Yeung 251146a61c Refactor createBooking 2023-10-17 21:53:04 -04:00
2 changed files with 327 additions and 251 deletions

View File

@ -0,0 +1,81 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import {
bookingCreateSchemaLegacyPropsForApi,
bookingCreateBodySchemaForApi,
extendedBookingCreateBody,
} from "@calcom/prisma/zod-utils";
import getBookingResponsesSchema from "./getBookingResponsesSchema";
import type { getEventTypesFromDB } from "./handleNewBooking";
const getBookingDataSchema = (
req: NextApiRequest,
isNotAnApiCall: boolean,
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>
) => {
const responsesSchema = getBookingResponsesSchema({
eventType: {
bookingFields: eventType.bookingFields,
},
view: req.body.rescheduleUid ? "reschedule" : "booking",
});
const bookingDataSchema = isNotAnApiCall
? extendedBookingCreateBody.merge(
z.object({
responses: responsesSchema,
})
)
: bookingCreateBodySchemaForApi
.merge(
z.object({
responses: responsesSchema.optional(),
})
)
.superRefine((val, ctx) => {
if (val.responses && val.customInputs) {
ctx.addIssue({
code: "custom",
message:
"Don't use both customInputs and responses. `customInputs` is only there for legacy support.",
});
return;
}
const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape);
if (val.responses) {
const unwantedProps: string[] = [];
legacyProps.forEach((legacyProp) => {
if (typeof val[legacyProp as keyof typeof val] !== "undefined") {
console.error(
`Deprecated: Unexpected falsy value for: ${unwantedProps.join(
","
)}. They can't be used with \`responses\`. This will become a 400 error in the future.`
);
}
if (val[legacyProp as keyof typeof val]) {
unwantedProps.push(legacyProp);
}
});
if (unwantedProps.length) {
ctx.addIssue({
code: "custom",
message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``,
});
return;
}
} else if (val.customInputs) {
const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val);
if (!success) {
ctx.addIssue({
code: "custom",
message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`,
});
}
}
});
return bookingDataSchema;
};
export default getBookingDataSchema;

View File

@ -74,11 +74,9 @@ import type { BookingReference } from "@calcom/prisma/client";
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import {
bookingCreateBodySchemaForApi,
bookingCreateSchemaLegacyPropsForApi,
customInputSchema,
EventTypeMetaDataSchema,
extendedBookingCreateBody,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
@ -93,7 +91,7 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import getBookingResponsesSchema from "./getBookingResponsesSchema";
import getBookingDataSchema from "./getBookingDataSchema";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@ -101,6 +99,14 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type NewBookingEventType =
| Awaited<ReturnType<typeof getDefaultEvent>>
| Awaited<ReturnType<typeof getEventTypesFromDB>>;
// Work with Typescript to require reqBody.end
type ReqBodyWithoutEnd = z.infer<ReturnType<typeof getBookingDataSchema>>;
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
@ -241,7 +247,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType,
return false;
}
const getEventTypesFromDB = async (eventTypeId: number) => {
export const getEventTypesFromDB = async (eventTypeId: number) => {
const eventType = await prisma.eventType.findUniqueOrThrow({
where: {
id: eventTypeId,
@ -360,6 +366,50 @@ type IsFixedAwareUser = User & {
organization: { slug: string };
};
const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string[], req: NextApiRequest) => {
try {
if (!eventType.id) {
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
throw new Error("dynamicUserList is not properly defined or empty.");
}
const users = await prisma.user.findMany({
where: {
username: { in: dynamicUserList },
organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""),
},
select: {
...userSelect.select,
credentials: {
select: credentialForCalendarServiceSelect,
},
metadata: true,
},
});
return users;
} else {
const hosts = eventType.hosts || [];
if (!Array.isArray(hosts)) {
throw new Error("eventType.hosts is not properly defined.");
}
const users = hosts.map(({ user, isFixed }) => ({
...user,
isFixed,
}));
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" });
}
};
async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
users: IsFixedAwareUser[];
@ -483,73 +533,10 @@ async function getBookingData({
isNotAnApiCall: boolean;
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
}) {
const responsesSchema = getBookingResponsesSchema({
eventType: {
bookingFields: eventType.bookingFields,
},
view: req.body.rescheduleUid ? "reschedule" : "booking",
});
const bookingDataSchema = isNotAnApiCall
? extendedBookingCreateBody.merge(
z.object({
responses: responsesSchema,
})
)
: bookingCreateBodySchemaForApi
.merge(
z.object({
responses: responsesSchema.optional(),
})
)
.superRefine((val, ctx) => {
if (val.responses && val.customInputs) {
ctx.addIssue({
code: "custom",
message:
"Don't use both customInputs and responses. `customInputs` is only there for legacy support.",
});
return;
}
const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape);
if (val.responses) {
const unwantedProps: string[] = [];
legacyProps.forEach((legacyProp) => {
if (typeof val[legacyProp as keyof typeof val] !== "undefined") {
console.error(
`Deprecated: Unexpected falsy value for: ${unwantedProps.join(
","
)}. They can't be used with \`responses\`. This will become a 400 error in the future.`
);
}
if (val[legacyProp as keyof typeof val]) {
unwantedProps.push(legacyProp);
}
});
if (unwantedProps.length) {
ctx.addIssue({
code: "custom",
message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``,
});
return;
}
} else if (val.customInputs) {
const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val);
if (!success) {
ctx.addIssue({
code: "custom",
message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`,
});
}
}
});
const bookingDataSchema = getBookingDataSchema(req, isNotAnApiCall, eventType);
const reqBody = await bookingDataSchema.parseAsync(req.body);
// Work with Typescript to require reqBody.end
type ReqBodyWithoutEnd = z.infer<typeof bookingDataSchema>;
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
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")) {
@ -603,6 +590,178 @@ async function getBookingData({
}
}
async function createBooking({
originalRescheduledBooking,
evt,
eventTypeId,
eventTypeSlug,
reqBodyUser,
reqBodyMetadata,
reqBodyRecurringEventId,
uid,
responses,
isConfirmedByDefault,
smsReminderNumber,
organizerUser,
rescheduleReason,
eventType,
bookerEmail,
paymentAppData,
}: {
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
evt: CalendarEvent;
eventType: NewBookingEventType;
eventTypeId: Awaited<ReturnType<typeof getBookingData>>["eventTypeId"];
eventTypeSlug: Awaited<ReturnType<typeof getBookingData>>["eventTypeSlug"];
reqBodyUser: ReqBodyWithEnd["user"];
reqBodyMetadata: ReqBodyWithEnd["metadata"];
reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"];
uid: short.SUUID;
responses: ReqBodyWithEnd["responses"] | null;
isConfirmedByDefault: ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
smsReminderNumber: Awaited<ReturnType<typeof getBookingData>>["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>;
}) {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.description;
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,
responses: responses === null ? Prisma.JsonNull : responses,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: 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,
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);
}
function getCustomInputsResponses(
reqBody: {
responses?: Record<string, object>;
@ -761,54 +920,11 @@ async function handler(
throw new HttpError({ statusCode: 400, message: error.message });
}
const loadUsers = async () => {
try {
if (!eventTypeId) {
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
throw new Error("dynamicUserList is not properly defined or empty.");
}
const users = await prisma.user.findMany({
where: {
username: { in: dynamicUserList },
organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""),
},
select: {
...userSelect.select,
credentials: {
select: credentialForCalendarServiceSelect,
},
metadata: true,
},
});
return users;
} else {
const hosts = eventType.hosts || [];
if (!Array.isArray(hosts)) {
throw new Error("eventType.hosts is not properly defined.");
}
const users = hosts.map(({ user, isFixed }) => ({
...user,
isFixed,
}));
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" });
}
};
// loadUsers allows type inferring
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
})[] = await loadUsers();
})[] = await loadUsers(eventType, dynamicUserList, req);
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
@ -1892,147 +2008,9 @@ async function handler(
evt.recurringEvent = eventType.recurringEvent;
}
async function createBooking() {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.description;
evt.location = originalRescheduledBooking?.location || evt.location;
}
const eventTypeRel = !eventTypeId
? {}
: {
connect: {
id: eventTypeId,
},
};
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user 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,
responses: responses === null ? Prisma.JsonNull : responses,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber,
metadata: reqBody.metadata,
attendees: {
createMany: {
data: attendeesData,
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: organizerUser.id,
},
},
destinationCalendar:
evt.destinationCalendar && evt.destinationCalendar.length > 0
? {
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,
};
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
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);
}
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
let referencesToCreate: PartialReference[] = [];
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null;
loggerWithEventDetails.debug(
"Going to create booking in DB now",
@ -2046,7 +2024,24 @@ async function handler(
);
try {
booking = await createBooking();
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,
});
// @NOTE: Add specific try catch for all subsequent async calls to avoid error
// Sync Services