diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index 21737c4717..65c2c2abd3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -6,6 +6,7 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { BillingModule } from "@/modules/billing/billing.module"; +import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; @@ -16,7 +17,7 @@ import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule], + imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule, BookingSeatModule], providers: [ TokensRepository, OAuthFlowService, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts index 6e430a0776..2cc2dfb3bb 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts @@ -29,6 +29,25 @@ export class BookingsRepository_2024_08_13 { }); } + async getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids: number[]) { + return this.dbRead.prisma.booking.findMany({ + where: { + id: { + in: ids, + }, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + async getByUid(bookingUid: string) { return this.dbRead.prisma.booking.findUnique({ where: { @@ -50,6 +69,23 @@ export class BookingsRepository_2024_08_13 { }); } + async getByIdWithAttendeesWithBookingSeatAndUserAndEvent(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + async getByUidWithAttendeesAndUserAndEvent(uid: string) { return this.dbRead.prisma.booking.findUnique({ where: { @@ -63,6 +99,31 @@ export class BookingsRepository_2024_08_13 { }); } + async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + + async getRecurringByUid(uid: string) { + return this.dbRead.prisma.booking.findMany({ + where: { + recurringEventId: uid, + }, + }); + } + async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) { return this.dbRead.prisma.booking.findMany({ where: { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index a967f9d5ad..5e9b79d460 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -34,7 +34,14 @@ import { User } from "@prisma/client"; import { Request } from "express"; import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; -import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types"; +import { + CancelBookingInput, + CancelBookingInputPipe, + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + RescheduleBookingInput, + RescheduleBookingInputPipe, +} from "@calcom/platform-types"; import { CreateBookingInputPipe, CreateBookingInput, @@ -166,7 +173,8 @@ export class BookingsController_2024_08_13 { }) async rescheduleBooking( @Param("bookingUid") bookingUid: string, - @Body() body: RescheduleBookingInput_2024_08_13, + @Body(new RescheduleBookingInputPipe()) + body: RescheduleBookingInput, @Req() request: Request ): Promise { const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body); @@ -185,7 +193,8 @@ export class BookingsController_2024_08_13 { async cancelBooking( @Req() request: Request, @Param("bookingUid") bookingUid: string, - @Body() body: CancelBookingInput_2024_08_13 + @Body(new CancelBookingInputPipe()) + body: CancelBookingInput ): Promise { const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/seated-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/seated-bookings.e2e-spec.ts new file mode 100644 index 0000000000..c952dbee66 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/seated-bookings.e2e-spec.ts @@ -0,0 +1,645 @@ +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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import { DateTime } from "luxon"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +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 { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CancelSeatedBookingInput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RescheduleSeatedBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { + CreateBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Seated bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = "seated-bookings-controller-e2e@api.com"; + let user: User; + + const seatedTventTypeSlug = "peer-coding-seated"; + const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated"; + let seatedEventTypeId: number; + let recurringSeatedEventTypeId: number; + const maxRecurrenceCount = 3; + + let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13; + let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[]; + + const emailAttendeeOne = "mr_proper_seated@gmail.com"; + const nameAttendeeOne = "Mr Proper Seated"; + const emailAttendeeTwo = "mr_proper_friend_seated@gmail.com"; + const nameAttendeeTwo = "Mr Proper Friend Seated"; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ name: "organization bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const seatedEvent = await eventTypesRepositoryFixture.create( + { + title: "peer coding", + slug: seatedTventTypeSlug, + length: 60, + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }, + user.id + ); + seatedEventTypeId = seatedEvent.id; + + const recurringSeatedEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: "peer coding recurring", + slug: recurringSeatedTventTypeSlug, + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }, + user.id + ); + recurringSeatedEventTypeId = recurringSeatedEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should book an event type with seats for the first time", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + }; + + 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(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedTventTypeSlug, + }); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should book an event type with seats for the second time", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: nameAttendeeTwo, + email: emailAttendeeTwo, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "Rust", + }, + }; + + 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(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedTventTypeSlug, + }); + expect(data.attendees.length).toEqual(2); + // note(Lauris): first attendee is from previous test request + const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne); + expect(firstAttendee).toEqual({ + name: createdSeatedBooking.attendees[0].name, + timeZone: createdSeatedBooking.attendees[0].timeZone, + language: createdSeatedBooking.attendees[0].language, + absent: false, + seatUid: createdSeatedBooking.seatUid, + bookingFieldsResponses: { + name: createdSeatedBooking.attendees[0].name, + ...createdSeatedBooking.attendees[0].bookingFieldsResponses, + }, + }); + const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo); + expect(secondAttendee).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should book a recurring event type with seats", async () => { + const recurrenceCount = 2; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(), + eventTypeId: recurringSeatedEventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + recurrenceCount, + }; + + 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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) { + const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(recurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.seatUid).toBeDefined(); + const seatUid = firstBooking.seatUid; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(body.start); + expect(firstBooking.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(firstBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedTventTypeSlug, + }); + expect(firstBooking.attendees.length).toEqual(1); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.seatUid).toBeDefined(); + const secondSeatUid = secondBooking.seatUid; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO(); + expect(secondBooking.start).toEqual(expectedStart); + expect(secondBooking.end).toEqual( + DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(secondBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedTventTypeSlug, + }); + expect(secondBooking.attendees.length).toEqual(1); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: secondSeatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + createdRecurringSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a booking for an event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdSeatedBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + const expected = structuredClone(createdSeatedBooking); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete expected.seatUid; + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a booking for a recurring event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) { + const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + const expected = structuredClone(createdRecurringSeatedBooking); + for (const booking of expected) { + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete booking.seatUid; + } + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a specific recurrence of a booking for a recurring event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + const expected = structuredClone(createdRecurringSeatedBooking[0]); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete expected.seatUid; + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get all seated bookings", async () => { + return request(app.getHttpServer()) + .get("/v2/bookings?sortCreated=asc") + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(3); + + const seatedBooking = responseBody.data[0]; + const seatedBookingExpected = structuredClone(createdSeatedBooking); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete seatedBookingExpected.seatUid; + expect(seatedBooking).toEqual(seatedBookingExpected); + + const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]]; + const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking); + for (const booking of recurringSeatedBookingsExpected) { + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete booking.seatUid; + } + expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected); + }); + }); + + it("should reschedule seated booking", async () => { + const body: RescheduleSeatedBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), + seatUid: createdSeatedBooking.seatUid, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedTventTypeSlug, + }); + expect(data.attendees.length).toEqual(1); + const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid); + expect(data.attendees[0]).toEqual({ + name: attendee?.name, + timeZone: attendee?.timeZone, + language: attendee?.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: attendee?.name, + ...attendee?.bookingFieldsResponses, + }, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error("Invalid response data - expected booking but received array response"); + } + }); + }); + + it("should cancel seated booking", async () => { + const body: CancelSeatedBookingInput_2024_08_13 = { + seatUid: createdSeatedBooking.seatUid, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("cancelled"); + expect(data.start).toEqual(createdSeatedBooking.start); + expect(data.end).toEqual(createdSeatedBooking.end); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedTventTypeSlug, + }); + expect(data.attendees.length).toEqual(0); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + } else { + throw new Error("Invalid response data - expected booking but received array response"); + } + }); + }); + + function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 { + return data.hasOwnProperty("seatUid"); + } + + function responseDataIsCreateRecurringSeatedBooking( + data: any + ): data is CreateRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 { + return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid")); + } + + function responseDataIsGetRecurringSeatedBooking( + data: any + ): data is GetRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts index 6a6cf93032..17c9b343d5 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts @@ -29,6 +29,7 @@ import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, } from "@calcom/platform-types"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; @@ -369,7 +370,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(1); expect(data[0].eventTypeId).toEqual(team1EventTypeId); }); @@ -384,7 +389,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(1); expect(data[0].eventTypeId).toEqual(team2EventTypeId); }); @@ -399,7 +408,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined(); expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined(); @@ -428,6 +441,7 @@ describe("Bookings Endpoints 2024-08-13", () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); await userRepositoryFixture.deleteByEmail(teamUser.email); + await userRepositoryFixture.deleteByEmail(teamUserEmail2); await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); await app.close(); }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts index 66611391e6..201a963415 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts @@ -28,7 +28,11 @@ import { VERSION_2024_08_13, X_CAL_CLIENT_ID, } from "@calcom/platform-constants"; -import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types"; +import { + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13, @@ -478,7 +482,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(5); }); }); @@ -492,7 +500,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(3); }); }); @@ -506,7 +518,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(3); }); }); @@ -520,7 +536,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(4); }); }); @@ -534,7 +554,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(1); }); }); @@ -548,7 +572,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(5); }); }); @@ -562,7 +590,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(3); }); }); @@ -576,7 +608,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(1); }); }); @@ -590,7 +626,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(3); }); }); @@ -604,7 +644,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); }); }); @@ -618,7 +662,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(5); }); }); @@ -632,7 +680,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); }); }); @@ -646,7 +698,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(3); }); }); @@ -660,7 +716,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(createdBooking.start); expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); @@ -676,7 +736,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); expect(data[1].start).toEqual(createdBooking.start); @@ -692,7 +756,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(createdBooking.start); expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); @@ -708,7 +776,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); expect(data[1].start).toEqual(createdBooking.start); @@ -724,7 +796,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(createdBooking.start); expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); @@ -740,7 +816,11 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; expect(data.length).toEqual(2); expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); expect(data[1].start).toEqual(createdBooking.start); @@ -764,6 +844,8 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const data: BookingOutput_2024_08_13 = responseBody.data; expect(data.reschedulingReason).toEqual(body.reschedulingReason); expect(data.start).toEqual(body.start); @@ -792,6 +874,8 @@ describe("Bookings Endpoints 2024-08-13", () => { const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const data: BookingOutput_2024_08_13 = responseBody.data; expect(data.status).toEqual("cancelled"); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts index f463f69861..a2dc6a7b03 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts @@ -3,7 +3,12 @@ import { Type } from "class-transformer"; import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; @ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) export class CancelBookingOutput_2024_08_13 { @@ -17,10 +22,19 @@ export class CancelBookingOutput_2024_08_13 { { $ref: getSchemaPath(BookingOutput_2024_08_13) }, { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + { $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } }, ], description: "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects", }) @Type(() => Object) - data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13[]; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts index deab5e452a..1eb72319bb 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts @@ -3,7 +3,12 @@ import { Type } from "class-transformer"; import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; @ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) export class CreateBookingOutput_2024_08_13 { @@ -21,5 +26,9 @@ export class CreateBookingOutput_2024_08_13 { "Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects", }) @Type(() => Object) - data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | CreateSeatedBookingOutput_2024_08_13 + | CreateRecurringSeatedBookingOutput_2024_08_13[]; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts index 2c45b9dd3c..61a2f70ef2 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts @@ -3,7 +3,14 @@ import { Type } from "class-transformer"; import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; @ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) export class RescheduleBookingOutput_2024_08_13 { @@ -15,11 +22,17 @@ export class RescheduleBookingOutput_2024_08_13 { oneOf: [ { $ref: getSchemaPath(BookingOutput_2024_08_13) }, { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) }, ], description: "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object", }) @ValidateNested() @Type(() => Object) - data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13; + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | CreateSeatedBookingOutput_2024_08_13 + | CreateRecurringSeatedBookingOutput_2024_08_13; } 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 2ba8af9c27..166ca6f698 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 @@ -18,7 +18,6 @@ import { } from "@calcom/platform-libraries"; import { CreateBookingInput_2024_08_13, - RescheduleBookingInput_2024_08_13, CreateBookingInput, CreateRecurringBookingInput_2024_08_13, GetBookingsInput_2024_08_13, @@ -27,6 +26,10 @@ import { MarkAbsentBookingInput_2024_08_13, BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + RescheduleBookingInput, + CancelBookingInput, } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -54,9 +57,19 @@ export class BookingsService_2024_08_13 { return await this.createInstantBooking(request, body); } - if (await this.isRecurring(body)) { + const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId); + const isRecurring = !!eventType?.recurringEvent; + const isSeated = !!eventType?.seatsPerTimeSlot; + + if (isRecurring && isSeated) { + return await this.createRecurringSeatedBooking(request, body); + } + if (isRecurring && !isSeated) { return await this.createRecurringBooking(request, body); } + if (isSeated) { + return await this.createSeatedBooking(request, body); + } return await this.createRegularBooking(request, body); } catch (error) { @@ -83,47 +96,71 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputBooking(databaseBooking); } - async isRecurring(body: CreateBookingInput) { - const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId); - return !!eventType?.recurringEvent; - } - async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); const bookings = await handleNewRecurringBooking(bookingRequest); - const uid = bookings[0].recurringEventId; - if (!uid) { - throw new Error("Recurring booking was not created"); - } + const ids = bookings.map((booking) => booking.id || 0); + return this.outputService.getOutputRecurringBookings(ids); + } - const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid); - return this.outputService.getOutputRecurringBookings(recurringBooking); + async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + const bookings = await handleNewRecurringBooking(bookingRequest); + return this.outputService.getOutputCreateRecurringSeatedBookings( + bookings.map((booking) => ({ id: booking.id || 0, seatUid: booking.seatReferenceUid || "" })) + ); } async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) { const bookingRequest = await this.inputService.createBookingRequest(request, body); const booking = await handleNewBooking(bookingRequest); - if (!booking.id) { - throw new Error("Booking was not created"); + if (!booking.uid) { + throw new Error("Booking missing uid"); } - const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id); + const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(booking.uid); if (!databaseBooking) { - throw new Error(`Booking with id=${booking.id} was not found in the database`); + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); } return this.outputService.getOutputBooking(databaseBooking); } + async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleNewBooking(bookingRequest); + + if (!booking.uid) { + throw new Error("Booking missing uid"); + } + + const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent( + booking.uid + ); + if (!databaseBooking) { + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); + } + + return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || ""); + } + async getBooking(uid: string) { - const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid); + const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid); if (booking) { const isRecurring = !!booking.recurringEventId; - if (isRecurring) { + const isSeated = !!booking.eventType?.seatsPerTimeSlot; + + if (isRecurring && !isSeated) { return this.outputService.getOutputRecurringBooking(booking); } + if (isRecurring && isSeated) { + return this.outputService.getOutputRecurringSeatedBooking(booking); + } + if (isSeated) { + return this.outputService.getOutputSeatedBooking(booking); + } return this.outputService.getOutputBooking(booking); } @@ -131,8 +168,13 @@ export class BookingsService_2024_08_13 { if (!recurringBooking.length) { throw new NotFoundException(`Booking with uid=${uid} was not found in the database`); } + const ids = recurringBooking.map((booking) => booking.id); + const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot; + if (isRecurringSeated) { + return this.outputService.getOutputRecurringSeatedBookings(ids); + } - return this.outputService.getOutputRecurringBookings(recurringBooking); + return this.outputService.getOutputRecurringBookings(ids); } async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) { @@ -151,12 +193,17 @@ export class BookingsService_2024_08_13 { // note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query, // because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids. const ids = fetchedBookings.bookings.map((booking) => booking.id); - const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids); + const bookings = await this.bookingsRepository.getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids); const bookingMap = new Map(bookings.map((booking) => [booking.id, booking])); const orderedBookings = ids.map((id) => bookingMap.get(id)); - const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = []; + const formattedBookings: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + )[] = []; for (const booking of orderedBookings) { if (!booking) { continue; @@ -172,8 +219,13 @@ export class BookingsService_2024_08_13 { }; const isRecurring = !!formatted.recurringEventId; - if (isRecurring) { + const isSeated = !!formatted.eventType?.seatsPerTimeSlot; + if (isRecurring && !isSeated) { formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted)); + } else if (isRecurring && isSeated) { + formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted)); + } else if (isSeated) { + formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted)); } else { formattedBookings.push(this.outputService.getOutputBooking(formatted)); } @@ -182,7 +234,7 @@ export class BookingsService_2024_08_13 { return formattedBookings; } - async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) { + async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) { try { const bookingRequest = await this.inputService.createRescheduleBookingRequest( request, @@ -190,18 +242,34 @@ export class BookingsService_2024_08_13 { body ); const booking = await handleNewBooking(bookingRequest); - if (!booking.id) { - throw new Error("Booking was not created"); + if (!booking.uid) { + throw new Error("Booking missing uid"); } - const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id); + const databaseBooking = + await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid); if (!databaseBooking) { - throw new Error(`Booking with id=${booking.id} was not found in the database`); + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); } - if (databaseBooking.recurringEventId) { + const isRecurring = !!databaseBooking.recurringEventId; + const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot; + + if (isRecurring && !isSeated) { return this.outputService.getOutputRecurringBooking(databaseBooking); } + if (isRecurring && isSeated) { + return this.outputService.getOutputCreateRecurringSeatedBooking( + databaseBooking, + booking?.seatReferenceUid || "" + ); + } + if (isSeated) { + return this.outputService.getOutputCreateSeatedBooking( + databaseBooking, + booking.seatReferenceUid || "" + ); + } return this.outputService.getOutputBooking(databaseBooking); } catch (error) { if (error instanceof Error) { @@ -213,7 +281,7 @@ export class BookingsService_2024_08_13 { } } - async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) { + async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) { const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body); await handleCancelBooking(bookingRequest); return this.getBooking(bookingUid); @@ -249,7 +317,7 @@ export class BookingsService_2024_08_13 { } async billBooking(booking: CreatedBooking) { - const hostId = booking.hosts[0].id; + const hostId = booking.hosts?.[0]?.id; if (!hostId) { this.logger.error(`Booking with uid=${booking.uid} has no host`); return; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index 2504b5fd43..71745021be 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -1,8 +1,12 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; -import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service"; +import { + bookingResponsesSchema, + seatedBookingResponsesSchema, +} from "@/ee/bookings/2024-08-13/services/output.service"; import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; @@ -16,13 +20,17 @@ import { z } from "zod"; import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; import { + CancelBookingInput, CancelBookingInput_2024_08_13, + CancelSeatedBookingInput_2024_08_13, CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, CreateRecurringBookingInput_2024_08_13, GetBookingsInput_2024_08_13, MarkAbsentBookingInput_2024_08_13, + RescheduleBookingInput, RescheduleBookingInput_2024_08_13, + RescheduleSeatedBookingInput_2024_08_13, } from "@calcom/platform-types"; type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams; @@ -73,7 +81,8 @@ export class InputBookingsService_2024_08_13 { private readonly eventTypesRepository: EventTypesRepository_2024_06_14, private readonly bookingsRepository: BookingsRepository_2024_08_13, private readonly config: ConfigService, - private readonly apiKeyRepository: ApiKeyRepository + private readonly apiKeyRepository: ApiKeyRepository, + private readonly bookingSeatRepository: BookingSeatRepository ) {} async createBookingRequest( @@ -239,9 +248,11 @@ export class InputBookingsService_2024_08_13 { async createRescheduleBookingRequest( request: Request, bookingUid: string, - body: RescheduleBookingInput_2024_08_13 + body: RescheduleBookingInput ): Promise { - const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body); + const bodyTransformed = this.isRescheduleSeatedBody(body) + ? await this.transformInputRescheduleSeatedBooking(bookingUid, body) + : await this.transformInputRescheduleBooking(bookingUid, body); const oAuthClientId = request.get(X_CAL_CLIENT_ID); const newRequest = { ...request }; @@ -258,6 +269,60 @@ export class InputBookingsService_2024_08_13 { return newRequest as unknown as BookingRequest; } + isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 { + return body.hasOwnProperty("seatUid"); + } + + async transformInputRescheduleSeatedBooking( + bookingUid: string, + inputBooking: RescheduleSeatedBookingInput_2024_08_13 + ) { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); + // todo create booking seat module, repository and fetch the seat to get info + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found`); + } + if (!booking.eventTypeId) { + throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`); + } + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId); + if (!eventType) { + throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); + } + + const seat = await this.bookingSeatRepository.getByReferenceUid(inputBooking.seatUid); + if (!seat) { + throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`); + } + + const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data); + const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email); + + if (!attendee) { + throw new NotFoundException( + `Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} and seatUid=${inputBooking.seatUid} not found` + ); + } + + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); + const endTime = startTime.plus({ minutes: eventType.length }); + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: eventType.id, + timeZone: attendee.timeZone, + language: attendee.locale, + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // metadata: booking.metadata || {}, + metadata: {}, + hasHashedBookingLink: false, + guests: [], + responses: { ...bookingResponses }, + rescheduleUid: inputBooking.seatUid, + }; + } + async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) { const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); if (!booking) { @@ -371,9 +436,11 @@ export class InputBookingsService_2024_08_13 { async createCancelBookingRequest( request: Request, bookingUid: string, - body: CancelBookingInput_2024_08_13 + body: CancelBookingInput ): Promise { - const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body); + const bodyTransformed = this.isCancelSeatedBody(body) + ? await this.transformInputCancelSeatedBooking(bookingUid, body) + : await this.transformInputCancelBooking(bookingUid, body); const oAuthClientId = request.get(X_CAL_CLIENT_ID); const newRequest = { ...request }; @@ -389,6 +456,10 @@ export class InputBookingsService_2024_08_13 { return newRequest as unknown as BookingRequest; } + isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 { + return body.hasOwnProperty("seatUid"); + } + async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) { let allRemainingBookings = false; let uid = bookingUid; @@ -410,6 +481,31 @@ export class InputBookingsService_2024_08_13 { }; } + async transformInputCancelSeatedBooking( + bookingUid: string, + inputBooking: CancelSeatedBookingInput_2024_08_13 + ) { + let allRemainingBookings = false; + let uid = bookingUid; + const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent( + bookingUid + ); + + if (recurringBooking.length) { + // note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event + allRemainingBookings = true; + // note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id + uid = recurringBooking[0].uid; + } + + return { + uid, + cancellationReason: "", + allRemainingBookings, + seatReferenceUid: inputBooking.seatUid, + }; + } + transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) { return { noShowHost: inputBooking.host, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts index 967f3a3133..cb6bbd957f 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -4,15 +4,34 @@ import { plainToClass } from "class-transformer"; import { DateTime } from "luxon"; import { z } from "zod"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; -import { Booking } from "@calcom/prisma/client"; +import { + BookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + SeatedAttendee, +} from "@calcom/platform-types"; +import { Booking, BookingSeat } from "@calcom/prisma/client"; export const bookingResponsesSchema = z .object({ email: z.string(), name: z.string(), guests: z.array(z.string()).optional(), - rescheduledReason: z.string().optional(), + rescheduleReason: z.string().optional(), + }) + .passthrough(); + +export const seatedBookingResponsesSchema = z + .object({ + responses: z + .object({ + email: z.string(), + name: z.string(), + }) + .passthrough(), }) .passthrough(); @@ -27,6 +46,7 @@ type DatabaseBooking = Booking & { timeZone: string; locale: string | null; noShow: boolean | null; + bookingSeat?: BookingSeat | null; }[]; } & { user: { id: number; name: string | null; email: string } | null }; @@ -72,17 +92,18 @@ export class OutputBookingsService_2024_08_13 { }; const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + // note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually bookingTransformed.bookingFieldsResponses = bookingResponses; return bookingTransformed; } - async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) { + async getOutputRecurringBookings(bookingsIds: number[]) { const transformed = []; - for (const booking of databaseBookings) { - const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id); + for (const bookingId of bookingsIds) { + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(bookingId); if (!databaseBooking) { - throw new Error(`Booking with id=${booking.id} was not found in the database`); + throw new Error(`Booking with id=${bookingId} was not found in the database`); } transformed.push(this.getOutputRecurringBooking(databaseBooking)); @@ -132,4 +153,159 @@ export class OutputBookingsService_2024_08_13 { return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); } + + getOutputCreateSeatedBooking( + databaseBooking: DatabaseBooking, + seatUid: string + ): CreateSeatedBookingOutput_2024_08_13 { + const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking); + return { ...getSeatedBookingOutput, seatUid }; + } + + getOutputSeatedBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [databaseBooking.user], + status: databaseBooking.status.toLowerCase(), + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: [], + location: databaseBooking.location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: databaseBooking.location, + absentHost: !!databaseBooking.noShowHost, + }; + + const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + + // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually + parsed.attendees = databaseBooking.attendees.map((attendee) => { + const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data); + + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; + + return attendeeParsed; + }); + + return parsed; + } + + async getOutputRecurringSeatedBookings(bookingsIds: number[]) { + const transformed = []; + + for (const bookingId of bookingsIds) { + const databaseBooking = + await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(bookingId); + if (!databaseBooking) { + throw new Error(`Booking with id=${bookingId} was not found in the database`); + } + + transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) { + const transformed = []; + + for (const booking of bookings) { + const databaseBooking = + await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.id} was not found in the database`); + } + transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + getOutputCreateRecurringSeatedBooking( + databaseBooking: DatabaseBooking, + seatUid: string + ): CreateRecurringSeatedBookingOutput_2024_08_13 { + const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking); + return { ...getRecurringSeatedBookingOutput, seatUid }; + } + + getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [databaseBooking.user], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: [], + location: databaseBooking.location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: databaseBooking.location, + recurringBookingUid: databaseBooking.recurringEventId, + absentHost: !!databaseBooking.noShowHost, + }; + + const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, { + strategy: "excludeAll", + }); + + // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually + parsed.attendees = databaseBooking.attendees.map((attendee) => { + const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data); + + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; + + return attendeeParsed; + }); + + return parsed; + } } diff --git a/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts b/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts new file mode 100644 index 0000000000..15c4c6f0b2 --- /dev/null +++ b/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts @@ -0,0 +1,10 @@ +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [BookingSeatRepository], + exports: [BookingSeatRepository], +}) +export class BookingSeatModule {} diff --git a/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts b/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts new file mode 100644 index 0000000000..d07855c205 --- /dev/null +++ b/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts @@ -0,0 +1,17 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class BookingSeatRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getByReferenceUid(referenceUid: string) { + return this.dbRead.prisma.bookingSeat.findUnique({ + where: { + referenceUid, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts index 5a8e4c950b..e0031542bc 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts @@ -387,15 +387,18 @@ describe("Organizations Event Types Endpoints", () => { 1 ); - const responseTeamEvent = responseBody.data[0]; - expect(responseTeamEvent?.teamId).toEqual(team.id); + const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id); + expect(responseTeamEvent).toBeDefined(); + if (!responseTeamEvent) { + throw new Error("Team event not found"); + } - const responseTeammate1Event = responseBody.data[1]; - expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id); + const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teammate1.id); + expect(responseTeammate1Event).toBeDefined(); expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); - const responseTeammate2Event = responseBody.data[2]; - expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id); + const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teammate2.id); + expect(responseTeammate2Event).toBeDefined(); expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); managedEventType = responseTeamEvent; diff --git a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts index 6ee0613300..848a677193 100644 --- a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts @@ -33,7 +33,8 @@ export class CreateManagedUserInput { @ApiProperty({ example: "America/New_York", description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have - a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.`, + a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked. + It will default to Europe/London if not passed.`, }) timeZone?: string; diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index e57f98f736..a30ea36b8d 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3020,7 +3020,7 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", + "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { "enum": [ "asc", @@ -3141,16 +3141,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RescheduleBookingInput_2024_08_13" - } - } - } - }, "responses": { "201": { "description": "", @@ -3191,16 +3181,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelBookingInput_2024_08_13" - } - } - } - }, "responses": { "200": { "description": "", @@ -5413,7 +5393,7 @@ "timeZone": { "type": "string", "example": "America/New_York", - "description": "Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have\n a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked." + "description": "Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have\n a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.\n It will default to Europe/London if not passed." }, "locale": { "enum": [ @@ -12595,8 +12575,7 @@ "cancelled", "accepted", "rejected", - "pending", - "rescheduled" + "pending" ], "example": "accepted" }, @@ -12633,6 +12612,20 @@ "eventType": { "$ref": "#/components/schemas/EventType" }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, "attendees": { "type": "array", "items": { @@ -12649,20 +12642,6 @@ "type": "string" } }, - "meetingUrl": { - "type": "string", - "description": "Deprecated - rely on 'location' field instead.", - "example": "https://example.com/recurring-meeting", - "deprecated": true - }, - "location": { - "type": "string", - "example": "https://example.com/meeting" - }, - "absentHost": { - "type": "boolean", - "example": true - }, "bookingFieldsResponses": { "type": "object", "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", @@ -12683,8 +12662,8 @@ "duration", "eventTypeId", "eventType", - "attendees", - "absentHost" + "absentHost", + "attendees" ] }, "RecurringBookingOutput_2024_08_13": { @@ -12692,15 +12671,15 @@ "properties": { "id": { "type": "number", - "example": 456 + "example": 123 }, "uid": { "type": "string", - "example": "recurring_uid_123" + "example": "booking_uid_123" }, "title": { "type": "string", - "example": "Recurring meeting" + "example": "Consultation" }, "description": { "type": "string", @@ -12720,19 +12699,19 @@ "rejected", "pending" ], - "example": "pending" + "example": "accepted" }, "cancellationReason": { "type": "string", - "example": "Event was cancelled" + "example": "User requested cancellation" }, "reschedulingReason": { "type": "string", - "example": "Event was rescheduled" + "example": "User rescheduled the event" }, "rescheduledFromUid": { "type": "string", - "example": "previous_recurring_uid_123" + "example": "previous_uid_123" }, "start": { "type": "string", @@ -12744,7 +12723,7 @@ }, "duration": { "type": "number", - "example": 30 + "example": 60 }, "eventTypeId": { "type": "number", @@ -12755,9 +12734,19 @@ "eventType": { "$ref": "#/components/schemas/EventType" }, - "recurringBookingUid": { + "meetingUrl": { "type": "string", - "example": "recurring_uid_987" + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true }, "attendees": { "type": "array", @@ -12767,34 +12756,24 @@ }, "guests": { "example": [ - "guest3@example.com", - "guest4@example.com" + "guest1@example.com", + "guest2@example.com" ], "type": "array", "items": { "type": "string" } }, - "meetingUrl": { - "type": "string", - "description": "Deprecated - rely on 'location' field instead.", - "example": "https://example.com/recurring-meeting", - "deprecated": true - }, - "location": { - "type": "string", - "example": "https://example.com/recurring-meeting" - }, - "absentHost": { - "type": "boolean", - "example": false - }, "bookingFieldsResponses": { "type": "object", "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", "example": { "customField": "customValue" } + }, + "recurringBookingUid": { + "type": "string", + "example": "recurring_uid_987" } }, "required": [ @@ -12809,9 +12788,9 @@ "duration", "eventTypeId", "eventType", - "recurringBookingUid", + "absentHost", "attendees", - "absentHost" + "recurringBookingUid" ] }, "CreateBookingOutput_2024_08_13": { @@ -12869,6 +12848,18 @@ "items": { "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" } + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + } } ], "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" @@ -12902,6 +12893,12 @@ }, { "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" } ] }, @@ -12916,24 +12913,6 @@ "data" ] }, - "RescheduleBookingInput_2024_08_13": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Start time in ISO 8601 format for the new booking", - "example": "2024-08-13T10:00:00Z" - }, - "reschedulingReason": { - "type": "string", - "example": "User requested reschedule", - "description": "Reason for rescheduling the booking" - } - }, - "required": [ - "start" - ] - }, "RescheduleBookingOutput_2024_08_13": { "type": "object", "properties": { @@ -12952,6 +12931,12 @@ }, { "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateRecurringSeatedBookingOutput_2024_08_13" } ], "description": "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object" @@ -12962,18 +12947,6 @@ "data" ] }, - "CancelBookingInput_2024_08_13": { - "type": "object", - "properties": { - "cancellationReason": { - "type": "string", - "example": "User requested cancellation" - } - }, - "required": [ - "cancellationReason" - ] - }, "CancelBookingOutput_2024_08_13": { "type": "object", "properties": { @@ -12998,6 +12971,18 @@ "items": { "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" } + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + } } ], "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" diff --git a/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking-input.pipe.ts b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking-input.pipe.ts new file mode 100644 index 0000000000..baad69ed01 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking-input.pipe.ts @@ -0,0 +1,82 @@ +import type { PipeTransform } from "@nestjs/common"; +import { Injectable, BadRequestException } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import type { ValidationError } from "class-validator"; +import { validateSync } from "class-validator"; + +import { CancelBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13 } from "./cancel-booking.input"; + +export type CancelBookingInput = CancelBookingInput_2024_08_13 | CancelSeatedBookingInput_2024_08_13; + +@Injectable() +export class CancelBookingInputPipe implements PipeTransform { + // note(Lauris): we need empty constructor otherwise v2 can't be started due to error: + // CancelBookingInputPipe is not a constructor + + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + transform(value: CancelBookingInput): CancelBookingInput { + if (!value) { + throw new BadRequestException("Body is required"); + } + if (typeof value !== "object") { + throw new BadRequestException("Body should be an object"); + } + + if (this.isCancelSeatedBookingInput(value)) { + return this.validateCancelBookingSeated(value); + } + + return this.validateCancelBooking(value); + } + + validateCancelBooking(value: CancelBookingInput_2024_08_13) { + const object = plainToClass(CancelBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + validateCancelBookingSeated(value: CancelSeatedBookingInput_2024_08_13) { + const object = plainToClass(CancelSeatedBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + private formatErrors(errors: ValidationError[]): string { + return errors + .map((err) => { + const constraints = err.constraints ? Object.values(err.constraints).join(", ") : ""; + const childrenErrors = + err.children && err.children.length > 0 ? `${this.formatErrors(err.children)}` : ""; + return `${err.property} property is wrong,${constraints} ${childrenErrors}`; + }) + .join(", "); + } + + private isCancelSeatedBookingInput( + value: CancelBookingInput + ): value is CancelSeatedBookingInput_2024_08_13 { + return value.hasOwnProperty("seatUid"); + } +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts index 1fa5b5f51e..66f92af8d7 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts @@ -7,3 +7,13 @@ export class CancelBookingInput_2024_08_13 { @ApiProperty({ example: "User requested cancellation" }) cancellationReason?: string; } + +export class CancelSeatedBookingInput_2024_08_13 { + @ApiProperty({ + type: String, + example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1", + description: "Uid of the specific seat withing booking.", + }) + @IsString() + seatUid!: string; +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts index 1a7307cf89..4037e64507 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts @@ -185,7 +185,7 @@ export class GetBookingsInput_2024_08_13 { required: false, description: "Sort results by their creation time (when booking was made) in ascending or descending order.", - example: "?sortEnd=asc OR ?sortEnd=desc", + example: "?sortCreated=asc OR ?sortCreated=desc", enum: SortOrder, }) sortCreated?: SortOrderType; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/index.ts b/packages/platform/types/bookings/2024-08-13/inputs/index.ts index 58b3a49b0f..0f1e66b8d8 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/index.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/index.ts @@ -4,3 +4,5 @@ export * from "./get-bookings.input"; export * from "./reschedule-booking.input"; export * from "./cancel-booking.input"; export * from "./mark-absent.input"; +export * from "./reschedule-booking-input.pipe"; +export * from "./cancel-booking-input.pipe"; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking-input.pipe.ts b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking-input.pipe.ts new file mode 100644 index 0000000000..1e7e3eb34a --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking-input.pipe.ts @@ -0,0 +1,87 @@ +import type { PipeTransform } from "@nestjs/common"; +import { Injectable, BadRequestException } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import type { ValidationError } from "class-validator"; +import { validateSync } from "class-validator"; + +import { + RescheduleBookingInput_2024_08_13, + RescheduleSeatedBookingInput_2024_08_13, +} from "./reschedule-booking.input"; + +export type RescheduleBookingInput = + | RescheduleBookingInput_2024_08_13 + | RescheduleSeatedBookingInput_2024_08_13; + +@Injectable() +export class RescheduleBookingInputPipe implements PipeTransform { + // note(Lauris): we need empty constructor otherwise v2 can't be started due to error: + // RescheduleBookingInputPipe is not a constructor + + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + transform(value: RescheduleBookingInput): RescheduleBookingInput { + if (!value) { + throw new BadRequestException("Body is required"); + } + if (typeof value !== "object") { + throw new BadRequestException("Body should be an object"); + } + + if (this.isSeatedRescheduleInput(value)) { + return this.validateSeatedReschedule(value); + } + + return this.validateReschedule(value); + } + + validateReschedule(value: RescheduleBookingInput_2024_08_13) { + const object = plainToClass(RescheduleBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + validateSeatedReschedule(value: RescheduleSeatedBookingInput_2024_08_13) { + const object = plainToClass(RescheduleSeatedBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + private formatErrors(errors: ValidationError[]): string { + return errors + .map((err) => { + const constraints = err.constraints ? Object.values(err.constraints).join(", ") : ""; + const childrenErrors = + err.children && err.children.length > 0 ? `${this.formatErrors(err.children)}` : ""; + return `${err.property} property is wrong,${constraints} ${childrenErrors}`; + }) + .join(", "); + } + + private isSeatedRescheduleInput( + value: RescheduleBookingInput + ): value is RescheduleSeatedBookingInput_2024_08_13 { + return "seatUid" in value; + } +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts index 998d7b3950..c249a6d5f8 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts @@ -18,3 +18,20 @@ export class RescheduleBookingInput_2024_08_13 { }) reschedulingReason?: string; } + +export class RescheduleSeatedBookingInput_2024_08_13 { + @IsDateString() + @ApiProperty({ + description: "Start time in ISO 8601 format for the new booking", + example: "2024-08-13T10:00:00Z", + }) + start!: string; + + @ApiProperty({ + type: String, + example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1", + description: "Uid of the specific seat withing booking.", + }) + @IsString() + seatUid!: string; +} diff --git a/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts index e4a0769258..e759dd7b1d 100644 --- a/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts +++ b/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts @@ -40,6 +40,24 @@ class Attendee { absent!: boolean; } +export class SeatedAttendee extends Attendee { + @ApiProperty({ type: String, example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" }) + @IsString() + @Expose() + seatUid!: string; + + @ApiProperty({ + type: Object, + description: + "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + example: { customField: "customValue" }, + required: false, + }) + @IsObject() + @Expose() + bookingFieldsResponses!: Record; +} + class Host { @ApiProperty({ type: Number, example: 1 }) @IsInt() @@ -69,7 +87,7 @@ class EventType { slug!: string; } -export class BookingOutput_2024_08_13 { +class BaseBookingOutput_2024_08_13 { @ApiProperty({ type: Number, example: 123 }) @IsInt() @Expose() @@ -96,10 +114,10 @@ export class BookingOutput_2024_08_13 { @Expose() hosts!: Host[]; - @ApiProperty({ enum: ["cancelled", "accepted", "rejected", "pending", "rescheduled"], example: "accepted" }) - @IsEnum(["cancelled", "accepted", "rejected", "pending", "rescheduled"]) + @ApiProperty({ enum: ["cancelled", "accepted", "rejected", "pending"], example: "accepted" }) + @IsEnum(["cancelled", "accepted", "rejected", "pending"]) @Expose() - status!: "cancelled" | "accepted" | "rejected" | "pending" | "rescheduled"; + status!: "cancelled" | "accepted" | "rejected" | "pending"; @ApiProperty({ type: String, required: false, example: "User requested cancellation" }) @IsString() @@ -149,19 +167,6 @@ export class BookingOutput_2024_08_13 { @Expose() eventType!: EventType; - @ApiProperty({ type: [Attendee] }) - @ValidateNested({ each: true }) - @Type(() => Attendee) - @Expose() - attendees!: Attendee[]; - - @ApiProperty({ type: [String], required: false, example: ["guest1@example.com", "guest2@example.com"] }) - @IsArray() - @IsString({ each: true }) - @IsOptional() - @Expose() - guests?: string[]; - @ApiProperty({ type: String, required: false, @@ -183,111 +188,16 @@ export class BookingOutput_2024_08_13 { @IsBoolean() @Expose() absentHost!: boolean; - - @ApiProperty({ - type: Object, - description: - "Booking field responses consisting of an object with booking field slug as keys and user response as values.", - example: { customField: "customValue" }, - required: false, - }) - @IsObject() - @Expose() - bookingFieldsResponses!: Record; } -export class RecurringBookingOutput_2024_08_13 { - @ApiProperty({ type: Number, example: 456 }) - @IsInt() - @Expose() - id!: number; - - @ApiProperty({ type: String, example: "recurring_uid_123" }) - @IsString() - @Expose() - uid!: string; - - @ApiProperty({ type: String, example: "Recurring meeting" }) - @IsString() - @Expose() - title!: string; - - @ApiProperty({ type: String, example: "Learn how to integrate scheduling into marketplace." }) - @IsString() - @Expose() - description!: string; - - @ApiProperty({ type: [Host] }) - @ValidateNested({ each: true }) - @Type(() => Host) - @Expose() - hosts!: Host[]; - - @ApiProperty({ enum: ["cancelled", "accepted", "rejected", "pending"], example: "pending" }) - @IsEnum(["cancelled", "accepted", "rejected", "pending"]) - @Expose() - status!: "cancelled" | "accepted" | "rejected" | "pending"; - - @ApiProperty({ type: String, required: false, example: "Event was cancelled" }) - @IsString() - @IsOptional() - @Expose() - cancellationReason?: string; - - @ApiProperty({ type: String, required: false, example: "Event was rescheduled" }) - @IsString() - @IsOptional() - @Expose() - reschedulingReason?: string; - - @ApiProperty({ type: String, required: false, example: "previous_recurring_uid_123" }) - @IsString() - @IsOptional() - @Expose() - rescheduledFromUid?: string; - - @ApiProperty({ type: String, example: "2024-08-13T15:30:00Z" }) - @IsDateString() - @Expose() - start!: string; - - @ApiProperty({ type: String, example: "2024-08-13T16:30:00Z" }) - @IsDateString() - @Expose() - end!: string; - - @ApiProperty({ type: Number, example: 30 }) - @IsInt() - @Expose() - duration!: number; - - @ApiProperty({ - type: Number, - example: 50, - deprecated: true, - description: "Deprecated - rely on 'eventType' object containing the id instead.", - }) - @IsInt() - @Expose() - eventTypeId!: number; - - @ApiProperty({ type: EventType }) - @Type(() => EventType) - @Expose() - eventType!: EventType; - - @ApiProperty({ type: String, example: "recurring_uid_987" }) - @IsString() - @Expose() - recurringBookingUid!: string; - +export class BookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { @ApiProperty({ type: [Attendee] }) @ValidateNested({ each: true }) @Type(() => Attendee) @Expose() attendees!: Attendee[]; - @ApiProperty({ type: [String], required: false, example: ["guest3@example.com", "guest4@example.com"] }) + @ApiProperty({ type: [String], required: false, example: ["guest1@example.com", "guest2@example.com"] }) @IsArray() @IsString({ each: true }) @IsOptional() @@ -295,26 +205,22 @@ export class RecurringBookingOutput_2024_08_13 { guests?: string[]; @ApiProperty({ - type: String, + type: Object, + description: + "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + example: { customField: "customValue" }, required: false, - description: "Deprecated - rely on 'location' field instead.", - example: "https://example.com/recurring-meeting", - deprecated: true, }) - @IsUrl() - @IsOptional() + @IsObject() @Expose() - meetingUrl?: string; + bookingFieldsResponses!: Record; +} - @ApiProperty({ type: String, required: false, example: "https://example.com/recurring-meeting" }) - @IsOptional() +export class RecurringBookingOutput_2024_08_13 extends BookingOutput_2024_08_13 { + @ApiProperty({ type: String, example: "recurring_uid_987" }) + @IsString() @Expose() - location!: string; - - @ApiProperty({ type: Boolean, example: false }) - @IsBoolean() - @Expose() - absentHost!: boolean; + recurringBookingUid!: string; @ApiProperty({ type: Object, @@ -327,3 +233,61 @@ export class RecurringBookingOutput_2024_08_13 { @Expose() bookingFieldsResponses!: Record; } + +export class GetSeatedBookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { + @ApiProperty({ type: [SeatedAttendee] }) + @ValidateNested({ each: true }) + @Type(() => SeatedAttendee) + @Expose() + attendees!: SeatedAttendee[]; +} + +export class GetRecurringSeatedBookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { + @ApiProperty({ type: [SeatedAttendee] }) + @ValidateNested({ each: true }) + @Type(() => SeatedAttendee) + @Expose() + attendees!: SeatedAttendee[]; + + @ApiProperty({ type: String, example: "recurring_uid_987" }) + @IsString() + @Expose() + recurringBookingUid!: string; +} + +// note(Lauris): CreateSeatedBookingOutput_2024_08_13 is the same as GetSeatedBookingOutput_2024_08_13 except it has seatUid, so instead of extending BaseBookingOutput_2024_08_13 +// we could extend GetSeatedBookingOutput_2024_08_13 but the problem then is that attendees end up at the top of the response even above id +// or uid keys making it harder to read and understand the response, so i decided to duplicate the fields here and the response is as expected - with seatUid and attendees at the bottom. +export class CreateSeatedBookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { + @ApiProperty({ type: String, example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" }) + @IsString() + @Expose() + seatUid!: string; + + @ApiProperty({ type: [SeatedAttendee] }) + @ValidateNested({ each: true }) + @Type(() => SeatedAttendee) + @Expose() + attendees!: SeatedAttendee[]; +} + +// note(Lauris): CreateRecurringSeatedBookingOutput_2024_08_13 is the same as GetRecurringSeatedBookingOutput_2024_08_13 except it has seatUid, so instead of extending BaseBookingOutput_2024_08_13 +// we could extend GetRecurringSeatedBookingOutput_2024_08_13 but the problem then is that attendees and recurringBookingUid end up at the top of the response even above id +// or uid keys making it harder to read and understand the response, so i decided to duplicate the fields here and the response is as expected - with seatUid, attendees and recurringBookingUid at the bottom. +export class CreateRecurringSeatedBookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { + @ApiProperty({ type: String, example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" }) + @IsString() + @Expose() + seatUid!: string; + + @ApiProperty({ type: [SeatedAttendee] }) + @ValidateNested({ each: true }) + @Type(() => SeatedAttendee) + @Expose() + attendees!: SeatedAttendee[]; + + @ApiProperty({ type: String, example: "recurring_uid_987" }) + @IsString() + @Expose() + recurringBookingUid!: string; +} diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts index 407428f4a0..c0b1c45fd5 100644 --- a/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts +++ b/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts @@ -3,7 +3,12 @@ import { Type } from "class-transformer"; import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; @ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) export class GetBookingOutput_2024_08_13 { @@ -17,12 +22,21 @@ export class GetBookingOutput_2024_08_13 { { $ref: getSchemaPath(BookingOutput_2024_08_13) }, { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + { $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } }, ], description: "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects", }) @Type(() => Object) - data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13[]; error?: Error; } diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts index 7eaac49732..7a11224732 100644 --- a/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts +++ b/packages/platform/types/bookings/2024-08-13/outputs/get-bookings.output.ts @@ -2,7 +2,12 @@ import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; import { IsEnum, ValidateNested } from "class-validator"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; @ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) export class GetBookingsOutput_2024_08_13 { @@ -16,13 +21,20 @@ export class GetBookingsOutput_2024_08_13 { oneOf: [ { $ref: getSchemaPath(BookingOutput_2024_08_13) }, { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) }, ], }, description: "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects", }) @ValidateNested({ each: true }) - data!: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[]; + data!: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + )[]; error?: Error; }