diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts index 417e33ec31..f9f697909e 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts @@ -1,5 +1,6 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; @@ -15,13 +16,20 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomNumber } from "test/utils/randomNumber"; import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; -import { GetBookingOutput_2024_08_13, GetSeatedBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + CreateBookingInput_2024_08_13, + GetBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; import { BookingOutput_2024_08_13 } from "@calcom/platform-types"; import { Booking, PlatformOAuthClient, Team, User } from "@calcom/prisma/client"; +const suffix = randomNumber(); + describe("Bookings Endpoints 2024-08-13", () => { describe("Booking fields", () => { let app: INestApplication; @@ -35,7 +43,7 @@ describe("Bookings Endpoints 2024-08-13", () => { let oAuthClient: PlatformOAuthClient; let teamRepositoryFixture: TeamRepositoryFixture; - const userEmail = "bookings-controller-e2e@api.com"; + const userEmail = `alice-${suffix}@api.com`; let user: User; let bookingWithSplitName: Booking; @@ -46,6 +54,8 @@ describe("Bookings Endpoints 2024-08-13", () => { let seatedBookingWithSplitName: Booking; + let eventTypeWithBookingFieldsId: number; + beforeAll(async () => { const moduleRef = await withApiAuth( userEmail, @@ -66,7 +76,7 @@ describe("Bookings Endpoints 2024-08-13", () => { teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); - organization = await teamRepositoryFixture.create({ name: "organization bookings" }); + organization = await teamRepositoryFixture.create({ name: `booking fields ${suffix}` }); oAuthClient = await createOAuthClient(organization.id); user = await userRepositoryFixture.create({ @@ -85,7 +95,7 @@ describe("Bookings Endpoints 2024-08-13", () => { }; await schedulesService.createUserSchedule(user.id, userSchedule); const event = await eventTypesRepositoryFixture.create( - { title: "peer coding", slug: "peer-coding-100", length: 60 }, + { title: "peer coding", slug: `normal-booking-${randomNumber()}`, length: 60 }, user.id ); @@ -122,7 +132,7 @@ describe("Bookings Endpoints 2024-08-13", () => { }); const seatedEvent = await eventTypesRepositoryFixture.create( - { title: "peer coding", slug: "seated-peer-coding-100", length: 60, seatsPerTimeSlot: 3 }, + { title: "peer coding", slug: `seated-${randomNumber()}`, length: 60, seatsPerTimeSlot: 3 }, user.id ); @@ -178,6 +188,201 @@ describe("Bookings Endpoints 2024-08-13", () => { }, }); + const eventTypeWithBookingFields = await eventTypesRepositoryFixture.create( + { + title: "peer coding with booking fields", + slug: `with-custom-booking-fields-${randomNumber()}`, + length: 60, + bookingFields: [ + { + name: "name", + type: "name", + label: "", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + variant: "fullName", + editable: "system", + required: true, + placeholder: "", + defaultLabel: "your_name", + variantsConfig: { + variants: { + fullName: { + fields: [ + { + name: "fullName", + type: "text", + label: "your_name", + required: true, + placeholder: "", + }, + ], + }, + firstAndLastName: { + fields: [ + { + name: "firstName", + type: "text", + label: "name", + required: true, + placeholder: "lauris", + }, + { + name: "lastName", + type: "text", + label: "surname", + required: true, + placeholder: "skraucis", + }, + ], + }, + }, + }, + disableOnPrefill: false, + }, + { + name: "email", + type: "email", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system", + required: true, + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system", + required: false, + defaultLabel: "location", + getOptionsAt: "locations", + optionsInputs: { + phone: { + type: "phone", + required: true, + placeholder: "", + }, + somewhereElse: { + type: "text", + required: true, + placeholder: "", + }, + attendeeInPerson: { + type: "address", + required: true, + placeholder: "", + }, + }, + hideWhenJustOneOption: true, + }, + { + name: "title", + type: "text", + hidden: true, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: true, + defaultLabel: "what_is_this_meeting_about", + defaultPlaceholder: "", + }, + { + name: "notes", + type: "textarea", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_notes", + defaultPlaceholder: "share_additional_notes", + }, + { + name: "guests", + type: "multiemail", + hidden: false, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_guests", + defaultPlaceholder: "email", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [ + { + id: "reschedule", + label: "Reschedule View", + }, + ], + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + { + name: "favorite-movie", + type: "text", + label: "favorite movie", + sources: [ + { + id: "user", + type: "user", + label: "User", + fieldRequired: true, + }, + ], + editable: "user", + required: true, + placeholder: "matrix", + disableOnPrefill: false, + }, + ], + }, + user.id + ); + eventTypeWithBookingFieldsId = eventTypeWithBookingFields.id; + app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -248,6 +453,64 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + describe("make booking", () => { + it("should not be able to book an event type with custom required booking fields if they are missing in bookingFieldsResponses", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: eventTypeWithBookingFieldsId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + return request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should be able to book an event type with custom required booking fields", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: eventTypeWithBookingFieldsId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + "favorite-movie": "lord of the rings", + }, + }; + return request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.bookingFieldsResponses["favorite-movie"]).toEqual("lord of the rings"); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 757d6c849c..02d426497e 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -10,6 +10,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { BadRequestException } from "@nestjs/common"; import { Request } from "express"; +import { z } from "zod"; import { handleNewBooking, @@ -38,6 +39,7 @@ import { CancelBookingInput, } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; +import { EventType } from "@calcom/prisma/client"; type CreatedBooking = { hosts: { id: number }[]; @@ -45,6 +47,14 @@ type CreatedBooking = { start: string; }; +const eventTypeBookingFieldSchema = z.object({ + name: z.string(), + required: z.boolean(), + editable: z.string(), +}); + +const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema); + @Injectable() export class BookingsService_2024_08_13 { private readonly logger = new Logger("BookingsService"); @@ -70,6 +80,8 @@ export class BookingsService_2024_08_13 { const isRecurring = !!eventType?.recurringEvent; const isSeated = !!eventType?.seatsPerTimeSlot; + await this.hasRequiredBookingFieldsResponses(body, eventType); + if (isRecurring && isSeated) { return await this.createRecurringSeatedBooking(request, body); } @@ -91,6 +103,28 @@ export class BookingsService_2024_08_13 { } } + async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) { + const bookingFields = body.bookingFieldsResponses; + if (!bookingFields || !eventType || !eventType.bookingFields) { + return true; + } + + // note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee + const eventTypeBookingFields = eventTypeBookingFieldsSchema + .parse(eventType.bookingFields) + .filter((field) => !field.editable.startsWith("system")); + + for (const field of eventTypeBookingFields) { + if (field.required && !(field.name in bookingFields)) { + throw new BadRequestException(` + Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. + You can fetch the event type with ID ${eventType.id} to see the required fields.`); + } + } + + return true; + } + async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) { const bookingRequest = await this.inputService.createBookingRequest(request, body); const booking = await handleInstantMeeting(bookingRequest); diff --git a/apps/api/v2/test/utils/randomNumber.ts b/apps/api/v2/test/utils/randomNumber.ts new file mode 100644 index 0000000000..6607ace569 --- /dev/null +++ b/apps/api/v2/test/utils/randomNumber.ts @@ -0,0 +1,3 @@ +export function randomNumber(): number { + return Math.floor(Math.random() * 100000); +}