diff --git a/apps/api/pages/api/bookings/_post.ts b/apps/api/pages/api/bookings/_post.ts index 062d3683d5..c94fe6c461 100644 --- a/apps/api/pages/api/bookings/_post.ts +++ b/apps/api/pages/api/bookings/_post.ts @@ -1,5 +1,6 @@ import type { NextApiRequest } from "next"; +import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi"; import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import { defaultResponder } from "@calcom/lib/server"; @@ -206,7 +207,7 @@ async function handler(req: NextApiRequest) { const { userId, isAdmin } = req; if (isAdmin) req.userId = req.body.userId || userId; - return await handleNewBooking(req); + return await handleNewBooking(req, getBookingDataSchemaForApi); } export default defaultResponder(handler); diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 1ca14e3a6b..36697330f5 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -17,9 +17,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe const session = await getServerSession({ req, res }); /* To mimic API behavior and comply with types */ req.userId = session?.user?.id || -1; - const booking = await handleNewBooking(req, { - isNotAnApiCall: true, - }); + const booking = await handleNewBooking(req); return booking; } diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 4fde0af2a8..d3c17c338d 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -52,9 +52,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe noEmail: false, }; - const firstBookingResult = await handleNewBooking(recurringEventReq, { - isNotAnApiCall: true, - }); + const firstBookingResult = await handleNewBooking(recurringEventReq); luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id); } @@ -92,12 +90,8 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe luckyUsers, }; - const promiseEachRecurringBooking: ReturnType = handleNewBooking( - recurringEventReq, - { - isNotAnApiCall: true, - } - ); + const promiseEachRecurringBooking: ReturnType = + handleNewBooking(recurringEventReq); const eachRecurringBooking = await promiseEachRecurringBooking; diff --git a/packages/features/bookings/Booker/components/hooks/useBookingForm.ts b/packages/features/bookings/Booker/components/hooks/useBookingForm.ts index ae67d73b20..e3cf47eec0 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookingForm.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookingForm.ts @@ -27,7 +27,7 @@ export const useBookingForm = ({ event }: IUseBookingForm) => { .object({ responses: event?.data ? getBookingResponsesSchema({ - eventType: event?.data, + bookingFields: event.data.bookingFields, view: rescheduleUid ? "reschedule" : "booking", }) : // Fallback until event is loaded. diff --git a/packages/features/bookings/Booker/components/hooks/useInitialFormValues.ts b/packages/features/bookings/Booker/components/hooks/useInitialFormValues.ts index 839f2c9720..954305b38e 100644 --- a/packages/features/bookings/Booker/components/hooks/useInitialFormValues.ts +++ b/packages/features/bookings/Booker/components/hooks/useInitialFormValues.ts @@ -37,9 +37,7 @@ export function useInitialFormValues({ return {}; } const querySchema = getBookingResponsesPartialSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, + bookingFields: eventType.bookingFields, view: rescheduleUid ? "reschedule" : "booking", }); diff --git a/packages/features/bookings/lib/getBookingDataSchema.ts b/packages/features/bookings/lib/getBookingDataSchema.ts index cef2a72dc6..cbd58d4232 100644 --- a/packages/features/bookings/lib/getBookingDataSchema.ts +++ b/packages/features/bookings/lib/getBookingDataSchema.ts @@ -1,80 +1,20 @@ import { z } from "zod"; -import { - bookingCreateSchemaLegacyPropsForApi, - bookingCreateBodySchemaForApi, - extendedBookingCreateBody, -} from "@calcom/prisma/zod-utils"; +import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; +import type { getBookingFieldsWithSystemFields } from "./getBookingFields"; import getBookingResponsesSchema from "./getBookingResponsesSchema"; -import type { getEventTypesFromDB } from "./handleNewBooking"; -const getBookingDataSchema = ( - rescheduleUid: string | undefined, - isNotAnApiCall: boolean, - eventType: Awaited> -) => { - const responsesSchema = getBookingResponsesSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, - view: 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; +const getBookingDataSchema = ({ + view = "booking", + bookingFields, +}: { + view: "booking" | "reschedule"; + bookingFields: Awaited>; +}) => { + return extendedBookingCreateBody.merge( + z.object({ responses: getBookingResponsesSchema({ bookingFields, view }) }) + ); }; export default getBookingDataSchema; diff --git a/packages/features/bookings/lib/getBookingDataSchemaForApi.ts b/packages/features/bookings/lib/getBookingDataSchemaForApi.ts new file mode 100644 index 0000000000..94c2aaf207 --- /dev/null +++ b/packages/features/bookings/lib/getBookingDataSchemaForApi.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { + bookingCreateBodySchemaForApi, + bookingCreateSchemaLegacyPropsForApi, +} from "@calcom/prisma/zod-utils"; + +import type { getBookingFieldsWithSystemFields } from "./getBookingFields"; +import getBookingResponsesSchema from "./getBookingResponsesSchema"; + +const getBookingDataSchemaForApi = ({ + view = "booking", + bookingFields, +}: { + view: "booking" | "reschedule"; + bookingFields: Awaited>; +}) => { + const responsesSchema = getBookingResponsesSchema({ bookingFields, view }); + return 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(",")}`, + }); + } + } + }); +}; + +export default getBookingDataSchemaForApi; diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts index 18ae817e7b..5022fea425 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts @@ -15,25 +15,23 @@ const ZOD_REQUIRED_FIELD_ERROR_MSG = "Required"; describe("getBookingResponsesSchema", () => { test(`should parse booking responses`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.parseAsync({ @@ -52,25 +50,23 @@ describe("getBookingResponsesSchema", () => { test(`should error if required fields are missing`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -92,25 +88,23 @@ describe("getBookingResponsesSchema", () => { describe(`'name' and 'email' must be considered as required fields`, () => { test(`'name' and 'email' must be considered as required fields `, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponsesWithJustName = await schema.safeParseAsync({ @@ -143,25 +137,23 @@ describe("getBookingResponsesSchema", () => { test(`'email' must be validated `, async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -183,26 +175,24 @@ describe("getBookingResponsesSchema", () => { test(`firstName is required and lastName is optional by default`, async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -226,25 +216,23 @@ describe("getBookingResponsesSchema", () => { test(`should reject empty fullname`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -268,26 +256,24 @@ describe("getBookingResponsesSchema", () => { test(`should reject empty firstName`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -312,26 +298,24 @@ describe("getBookingResponsesSchema", () => { test(`should accept empty lastname`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.parseAsync({ @@ -356,25 +340,23 @@ describe("getBookingResponsesSchema", () => { describe(`'name' can be transformed from one variant to other `, () => { test("`firstAndLastName` variant to `fullName`", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -400,26 +382,24 @@ describe("getBookingResponsesSchema", () => { test("`fullName` to `firstAndLastName` when there is a lastName(separated by space)", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -444,26 +424,24 @@ describe("getBookingResponsesSchema", () => { }); test("`fullName` to `firstAndLastName` when there is no lastName(separated by space)", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -493,25 +471,23 @@ describe("getBookingResponsesSchema", () => { describe("validate phone type field", () => { test(`should fail parsing if invalid phone provided`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testPhone", - type: "phone", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testPhone", + type: "phone", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -532,25 +508,23 @@ describe("getBookingResponsesSchema", () => { }); test(`should succesfull give responses if phone type field value is valid`, async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testPhone", - type: "phone", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testPhone", + type: "phone", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -571,25 +545,23 @@ describe("getBookingResponsesSchema", () => { test("should fail parsing if phone field value is empty", async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testPhone", - type: "phone", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testPhone", + type: "phone", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -612,25 +584,23 @@ describe("getBookingResponsesSchema", () => { test("should fail parsing if phone field value isn't provided", async ({}) => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testPhone", - type: "phone", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testPhone", + type: "phone", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -653,25 +623,23 @@ describe("getBookingResponsesSchema", () => { test("should fail parsing when invalid field type is provided", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "invalidField", - type: "unknown-field-type", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "invalidField", + type: "unknown-field-type", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -694,25 +662,23 @@ describe("getBookingResponsesSchema", () => { describe("multiemail field type", () => { test("should succesfully parse a multiemail type field", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testEmailsList", - type: "multiemail", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testEmailsList", + type: "multiemail", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -734,25 +700,23 @@ describe("getBookingResponsesSchema", () => { test("should fail parsing when one of the emails is invalid", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testEmailsList", - type: "multiemail", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testEmailsList", + type: "multiemail", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -775,25 +739,23 @@ describe("getBookingResponsesSchema", () => { test("should succesfully parse a multiemail type field response, even when the value is just a string[Prefill needs it]", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testEmailsList", - type: "multiemail", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testEmailsList", + type: "multiemail", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -816,25 +778,23 @@ describe("getBookingResponsesSchema", () => { describe("multiselect field type", () => { test("should succesfully parse a multiselect type field", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testMultiselect", - type: "multiselect", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testMultiselect", + type: "multiselect", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -855,25 +815,23 @@ describe("getBookingResponsesSchema", () => { }); test("should succesfully parse a multiselect type field", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testMultiselect", - type: "multiselect", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testMultiselect", + type: "multiselect", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -894,25 +852,23 @@ describe("getBookingResponsesSchema", () => { }); test("should fail parsing if selected options aren't strings", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testMultiselect", - type: "multiselect", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testMultiselect", + type: "multiselect", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -939,25 +895,23 @@ describe("getBookingResponsesSchema", () => { describe("multiselect field type", () => { test("should succesfully parse a multiselect type field", async () => { const schema = getBookingResponsesSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testMultiselect", - type: "multiselect", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testMultiselect", + type: "multiselect", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.safeParseAsync({ @@ -990,25 +944,23 @@ describe("getBookingResponsesSchema", () => { describe("getBookingResponsesPartialSchema - Prefill validation", () => { test(`should be able to get fields prefilled even when name is empty string`, async ({}) => { const schema = getBookingResponsesPartialSchema({ - eventType: { - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - }, + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); const parsedResponses = await schema.parseAsync({ diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index f8611c6bbf..1852e3450c 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -1,64 +1,55 @@ import z from "zod"; import type { ALL_VIEWS } from "@calcom/features/form-builder/schema"; -import { fieldTypesSchemaMap, dbReadResponseSchema } from "@calcom/features/form-builder/schema"; +import { dbReadResponseSchema, fieldTypesSchemaMap } from "@calcom/features/form-builder/schema"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils"; -type EventType = Parameters[0]["eventType"]; // eslint-disable-next-line @typescript-eslint/ban-types type View = ALL_VIEWS | (string & {}); +type BookingFields = (z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">) | null; +type CommonParams = { bookingFields: BookingFields; view: View }; export const bookingResponse = dbReadResponseSchema; export const bookingResponsesDbSchema = z.record(dbReadResponseSchema); const catchAllSchema = bookingResponsesDbSchema; -export const getBookingResponsesPartialSchema = ({ - eventType, - view, -}: { - eventType: EventType; - view: View; -}) => { +export const getBookingResponsesPartialSchema = ({ bookingFields, view }: CommonParams) => { const schema = bookingResponses.unwrap().partial().and(catchAllSchema); - return preprocess({ schema, eventType, isPartialSchema: true, view }); + return preprocess({ schema, bookingFields, isPartialSchema: true, view }); }; // Should be used when we know that not all fields responses are present // - Can happen when we are parsing the prefill query string // - Can happen when we are parsing a booking's responses (which was created before we added a new required field) -export default function getBookingResponsesSchema({ eventType, view }: { eventType: EventType; view: View }) { +export default function getBookingResponsesSchema({ bookingFields, view }: CommonParams) { const schema = bookingResponses.and(z.record(z.any())); - return preprocess({ schema, eventType, isPartialSchema: false, view }); + return preprocess({ schema, bookingFields, isPartialSchema: false, view }); } // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // It allows anyone using FormBuilder to get the same preprocessing automatically function preprocess({ schema, - eventType, + bookingFields, isPartialSchema, view: currentView, -}: { +}: CommonParams & { schema: T; // It is useful when we want to prefill the responses with the partial values. Partial can be in 2 ways // - Not all required fields are need to be provided for prefill. // - Even a field response itself can be partial so the content isn't validated e.g. a field with type="phone" can be given a partial phone number(e.g. Specifying the country code like +91) isPartialSchema: boolean; - eventType: { - bookingFields: (z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">) | null; - }; - view: View; }): z.ZodType, z.infer, z.infer> { const preprocessed = z.preprocess( (responses) => { const parsedResponses = z.record(z.any()).nullable().parse(responses) || {}; const newResponses = {} as typeof parsedResponses; // if eventType has been deleted, we won't have bookingFields and thus we can't preprocess or validate them. - if (!eventType.bookingFields) return parsedResponses; - eventType.bookingFields.forEach((field) => { + if (!bookingFields) return parsedResponses; + bookingFields.forEach((field) => { const value = parsedResponses[field.name]; if (value === undefined) { // If there is no response for the field, then we don't need to do any processing @@ -110,11 +101,11 @@ function preprocess({ }; }, schema.superRefine(async (responses, ctx) => { - if (!eventType.bookingFields) { + if (!bookingFields) { // if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses. return; } - for (const bookingField of eventType.bookingFields) { + for (const bookingField of bookingFields) { const value = responses[bookingField.name]; const stringSchema = z.string(); const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement); diff --git a/packages/features/bookings/lib/getCalEventResponses.ts b/packages/features/bookings/lib/getCalEventResponses.ts index 77d6bfedc7..94605f67ca 100644 --- a/packages/features/bookings/lib/getCalEventResponses.ts +++ b/packages/features/bookings/lib/getCalEventResponses.ts @@ -43,7 +43,7 @@ export const getCalEventResponses = ({ const label = field.label || field.defaultLabel; if (!label) { //TODO: This error must be thrown while saving event-type as well so that such an event-type can't be saved - throw new Error('Missing label for booking field "' + field.name + '"'); + throw new Error(`Missing label for booking field "${field.name}"`); } // If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 85d033484b..d61684655a 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -591,19 +591,16 @@ async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boole }); } -export async function getBookingData({ +export async function getBookingData({ req, - isNotAnApiCall, eventType, + schema, }: { req: NextApiRequest; - isNotAnApiCall: boolean; eventType: Awaited>; + schema: T; }) { - const bookingDataSchema = getBookingDataSchema(req.body?.rescheduleUid, isNotAnApiCall, eventType); - - const reqBody = await bookingDataSchema.parseAsync(req.body); - + 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")) { @@ -615,11 +612,9 @@ export async function getBookingData({ throw new Error(ErrorCode.RequestBodyWithouEnd); } // reqBody.end is no longer an optional property. - if ("customInputs" in reqBody) { - if (reqBody.customInputs) { - // Check if required custom inputs exist - handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs); - } + if (reqBody.customInputs) { + // Check if required custom inputs exist + handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs); const reqBodyWithLegacyProps = bookingCreateSchemaLegacyPropsForApi.parse(reqBody); return { ...reqBody, @@ -630,31 +625,36 @@ export async function getBookingData({ smsReminderNumber: reqBodyWithLegacyProps.smsReminderNumber, notes: reqBodyWithLegacyProps.notes, rescheduleReason: reqBodyWithLegacyProps.rescheduleReason, - }; - } else { - 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 + 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({ @@ -841,13 +841,10 @@ export function getCustomInputsResponses( eventTypeCustomInputs: Awaited>["customInputs"] ) { const customInputsResponses = {} as NonNullable; - if ("customInputs" in reqBody) { - const reqCustomInputsResponses = reqBody.customInputs || []; - if (reqCustomInputsResponses?.length > 0) { - reqCustomInputsResponses.forEach(({ label, value }) => { - customInputsResponses[label] = value; - }); - } + 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. @@ -994,15 +991,13 @@ export const findBookingQuery = async (bookingId: number) => { return foundBooking; }; +type BookingDataSchemaGetter = + | typeof getBookingDataSchema + | typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default; + async function handler( req: NextApiRequest & { userId?: number | undefined }, - { - isNotAnApiCall = false, - }: { - isNotAnApiCall?: boolean; - } = { - isNotAnApiCall: false, - } + bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema ) { const { userId } = req; @@ -1016,6 +1011,17 @@ async function handler( ...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, @@ -1033,11 +1039,7 @@ async function handler( rescheduleReason, luckyUsers, ...reqBody - } = await getBookingData({ - req, - isNotAnApiCall, - eventType, - }); + } = bookingData; const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); @@ -1199,7 +1201,10 @@ async function handler( Object.prototype.hasOwnProperty.call(eventType, "durationLimits") ) { const startAsDate = dayjs(reqBody.start).toDate(); - if (eventType.bookingLimits) { + if ( + eventType.bookingLimits && + /* Empty object is truthy */ Object.keys(eventType.bookingLimits).length > 0 + ) { await checkBookingLimits( eventType.bookingLimits as IntervalLimit, startAsDate, @@ -1507,7 +1512,7 @@ async function handler( const attendeesList = [...invitee, ...guests]; - const responses = "responses" in reqBody ? reqBody.responses : null; + const responses = reqBody.responses || null; const evtName = !eventType?.isDynamic ? eventType.eventName : responses?.title; const eventNameObject = { @@ -1524,9 +1529,6 @@ async function handler( t: tOrganizer, }; - const calEventUserFieldsResponses = - "calEventUserFieldsResponses" in reqBody ? reqBody.calEventUserFieldsResponses : null; - const iCalUID = getICalUID({ event: { iCalUID: originalRescheduledBooking?.iCalUID, uid: originalRescheduledBooking?.uid }, uid, @@ -1563,8 +1565,8 @@ async function handler( language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), }, - responses: "calEventResponses" in reqBody ? reqBody.calEventResponses : null, - userFieldsResponses: calEventUserFieldsResponses, + responses: reqBody.calEventResponses || null, + userFieldsResponses: reqBody.calEventUserFieldsResponses || null, attendees: attendeesList, location: bookingLocation, // Will be processed by the EventManager later. conferenceCredentialId, diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index 3476e27eac..8e0d78f08c 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -6,11 +6,12 @@ import { v5 as uuidv5 } from "uuid"; 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 { - getEventTypesFromDB, getBookingData, getCustomInputsResponses, + getEventTypesFromDB, } from "@calcom/features/bookings/lib/handleNewBooking"; import { getFullName } from "@calcom/features/form-builder/utils"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; @@ -83,10 +84,14 @@ async function handler(req: NextApiRequest) { throw new Error("Only Team Event Types are supported for Instant Meeting"); } + const schema = getBookingDataSchema({ + view: req.body?.rescheduleUid ? "reschedule" : "booking", + bookingFields: eventType.bookingFields, + }); const reqBody = await getBookingData({ req, - isNotAnApiCall: true, eventType, + schema, }); const { email: bookerEmail, name: bookerName } = reqBody; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 897f0f88da..56b9a87a16 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -2,8 +2,6 @@ import type { Prisma } from "@prisma/client"; import type { UnitTypeLongPlural } from "dayjs"; import type { TFunction } from "next-i18next"; import z, { ZodNullable, ZodObject, ZodOptional } from "zod"; - -/* eslint-disable no-underscore-dangle */ import type { AnyZodObject, objectInputType, @@ -274,6 +272,7 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( ) .optional(), luckyUsers: z.array(z.number()).optional(), + customInputs: z.undefined().optional(), }) ); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 70fe2276d9..6b48d53be3 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -35,8 +35,12 @@ const workspaces = packagedEmbedTestsOnly { test: { include: ["packages/**/*.{test,spec}.{ts,js}", "apps/**/*.{test,spec}.{ts,js}"], - // TODO: Ignore the api until tests are fixed - exclude: ["**/node_modules/**/*", "packages/embeds/**/*", "packages/lib/hooks/**/*"], + exclude: [ + "**/node_modules/**/*", + "**/.next/**/*", + "packages/embeds/**/*", + "packages/lib/hooks/**/*", + ], setupFiles: ["setupVitest.ts"], }, },