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 6f14185ca1..0153a514fe 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 @@ -1,3 +1,50 @@ +import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CancelBookingInput, + CancelBookingInput_2024_08_13, + CancelBookingInputPipe, + CancelSeatedBookingInput_2024_08_13, + CreateBookingInput, + CreateBookingInput_2024_08_13, + CreateBookingInputPipe, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + DeclineBookingInput_2024_08_13, + GetBookingOutput_2024_08_13, + GetBookingRecordingsOutput, + GetBookingsInput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetBookingTranscriptsOutput, + GetBookingVideoSessionsOutput, + MarkAbsentBookingInput_2024_08_13, + ReassignToUserBookingInput_2024_08_13, + RescheduleBookingInput, + RescheduleBookingInput_2024_08_13, + RescheduleBookingInputPipe, + RescheduleSeatedBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBody, + ApiExtraModels, + ApiHeader, + ApiOperation, + ApiTags as DocsTags, + getSchemaPath, +} from "@nestjs/swagger"; +import { Request } from "express"; import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard"; import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input"; @@ -11,7 +58,7 @@ import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/out import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service"; import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service"; -import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions"; +import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions"; import { API_KEY_OR_ACCESS_TOKEN_HEADER, OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER, @@ -31,56 +78,6 @@ import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/op import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { UsersService } from "@/modules/users/services/users.service"; -import { - Controller, - Post, - Logger, - Body, - UseGuards, - Req, - Get, - Param, - Query, - HttpCode, - HttpStatus, -} from "@nestjs/common"; -import { - ApiOperation, - ApiTags as DocsTags, - ApiHeader, - getSchemaPath, - ApiBody, - ApiExtraModels, -} from "@nestjs/swagger"; -import { Request } from "express"; - -import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; -import { - CancelBookingInput, - CancelBookingInput_2024_08_13, - CancelBookingInputPipe, - CancelSeatedBookingInput_2024_08_13, - GetBookingOutput_2024_08_13, - GetBookingsOutput_2024_08_13, - RescheduleBookingInput, - RescheduleBookingInput_2024_08_13, - RescheduleBookingInputPipe, - RescheduleSeatedBookingInput_2024_08_13, - GetBookingRecordingsOutput, - GetBookingTranscriptsOutput, - GetBookingVideoSessionsOutput, -} from "@calcom/platform-types"; -import { - CreateBookingInputPipe, - CreateBookingInput, - GetBookingsInput_2024_08_13, - ReassignToUserBookingInput_2024_08_13, - MarkAbsentBookingInput_2024_08_13, - CreateBookingInput_2024_08_13, - CreateInstantBookingInput_2024_08_13, - CreateRecurringBookingInput_2024_08_13, - DeclineBookingInput_2024_08_13, -} from "@calcom/platform-types"; @Controller({ path: "/v2/bookings", @@ -178,6 +175,33 @@ export class BookingsController_2024_08_13 { }; } + @Get("/by-seat/:seatUid") + @UseGuards(OptionalApiAuthGuard) + @ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) + @ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) + @ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get a booking by seat UID", + description: `Get a seated booking by its seat reference UID. This is useful when you have a seatUid from a seated booking and want to retrieve the full booking details. + + If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or + you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, + }) + async getBookingBySeatUid( + @Param("seatUid") seatUid: string, + @GetOptionalUser() user: AuthOptionalUser + ): Promise { + const booking = await this.bookingsService.getBookingBySeatUid(seatUid, user); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + @Get("/:bookingUid") @UseGuards(BookingUidGuard, OptionalApiAuthGuard) @ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts index dca23f60eb..6591c027b9 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts @@ -364,6 +364,185 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + it("should get a booking by seatUid", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/by-seat/${createdSeatedBooking.seatUid}`) + .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; + expect(data.id).toEqual(createdSeatedBooking.id); + expect(data.uid).toEqual(createdSeatedBooking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(2); + } else { + throw new Error( + "Invalid response data - expected seated booking but received non seated booking response" + ); + } + }); + }); + + it("should return 404 when getting booking by non-existent seatUid", async () => { + return request(app.getHttpServer()) + .get("/v2/bookings/by-seat/non-existent-seat-uid") + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(404) + .then(async (response) => { + expect(response.body.error.message).toEqual( + "Booking with seatUid=non-existent-seat-uid was not found in the database" + ); + }); + }); + + describe("get booking by seatUid with seatsShowAttendees disabled", () => { + let bookingWithAttendeesDisabled: CreateSeatedBookingOutput_2024_08_13; + let secondAttendeeSeatUid: string; + const emailAttendeeThree = `seated-bookings-attendee3-${randomString()}@api.com`; + const nameAttendeeThree = `Attendee Three ${randomString()}`; + + it("should book first seat on event type with seatsShowAttendees disabled", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeIdAttendeesDisabledId, + attendee: { + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + metadata: { + userId: "100", + }, + }; + + 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(); + bookingWithAttendeesDisabled = data; + } else { + throw new Error("Invalid response data - expected seated booking"); + } + }); + }); + + it("should book second seat on same event type with seatsShowAttendees disabled", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeIdAttendeesDisabledId, + attendee: { + name: nameAttendeeThree, + email: emailAttendeeThree, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "Rust", + }, + metadata: { + userId: "300", + }, + }; + + 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(); + secondAttendeeSeatUid = data.seatUid; + } else { + throw new Error("Invalid response data - expected seated booking"); + } + }); + }); + + it("should return only the attendee for the specific seatUid when seatsShowAttendees is false and no auth", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/by-seat/${secondAttendeeSeatUid}`) + .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; + expect(data.id).toEqual(bookingWithAttendeesDisabled.id); + expect(data.uid).toEqual(bookingWithAttendeesDisabled.uid); + expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0].email).toEqual(emailAttendeeThree); + expect(data.attendees[0].name).toEqual(nameAttendeeThree); + expect(data.attendees[0].seatUid).toEqual(secondAttendeeSeatUid); + } else { + throw new Error("Invalid response data - expected seated booking"); + } + }); + }); + + it("should return all attendees when admin auth is provided", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/by-seat/${secondAttendeeSeatUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${apiKeyString}`) + .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; + expect(data.id).toEqual(bookingWithAttendeesDisabled.id); + expect(data.uid).toEqual(bookingWithAttendeesDisabled.uid); + expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId); + expect(data.attendees.length).toEqual(2); + await bookingsRepositoryFixture.deleteById(data.id); + } else { + throw new Error("Invalid response data - expected seated booking"); + } + }); + }); + }); + it("should not be able to reschedule seated booking if seatUid is not provided", async () => { const body = { start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), @@ -924,11 +1103,13 @@ describe("Bookings Endpoints 2024-08-13", () => { }); function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 { - return Object.hasOwn(data, "seatUid"); + return Object.prototype.hasOwnProperty.call(data, "seatUid"); } function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 { - return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid")); + return data?.attendees?.every((attendee: any) => + Object.prototype.hasOwnProperty.call(attendee, "seatUid") + ); } afterAll(async () => { 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 acf2487c30..556eb9ffcd 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 @@ -615,6 +615,50 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputRecurringBookings(ids); } + async getBookingBySeatUid(seatUid: string, authUser: AuthOptionalUser) { + const bookingSeat = + await this.bookingSeatRepository.getByReferenceUidIncludeBookingWithAttendeesAndUserAndEvent(seatUid); + + if (!bookingSeat || !bookingSeat.booking) { + throw new NotFoundException(`Booking with seatUid=${seatUid} was not found in the database`); + } + + const booking = bookingSeat.booking; + const userIsEventTypeAdminOrOwner = + authUser && booking.eventType + ? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner( + authUser, + booking.eventType as EventType + ) + : false; + + const isRecurring = !!booking.recurringEventId; + const seatsShowAttendees = !!booking.eventType?.seatsShowAttendees; + const showAllAttendees = userIsEventTypeAdminOrOwner || seatsShowAttendees; + + // When user is not admin and seatsShowAttendees is false, show only the attendee for this seatUid + if (!showAllAttendees) { + const seatAttendee = booking.attendees.find( + (attendee) => attendee.bookingSeat?.referenceUid === seatUid + ); + const bookingWithFilteredAttendees = { + ...booking, + attendees: seatAttendee ? [seatAttendee] : [], + }; + + if (isRecurring) { + return this.outputService.getOutputRecurringSeatedBooking(bookingWithFilteredAttendees, true); + } + return this.outputService.getOutputSeatedBooking(bookingWithFilteredAttendees, true); + } + + if (isRecurring) { + return this.outputService.getOutputRecurringSeatedBooking(booking, true); + } + + return this.outputService.getOutputSeatedBooking(booking, true); + } + async getBookings( queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number; orgId?: number }, 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 index de8275112d..df850c743e 100644 --- a/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts +++ b/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts @@ -1,10 +1,13 @@ +import { Injectable } from "@nestjs/common"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; -import { Injectable } from "@nestjs/common"; @Injectable() export class BookingSeatRepository { - constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService + ) {} async getByReferenceUid(referenceUid: string) { return this.dbRead.prisma.bookingSeat.findUnique({ @@ -16,4 +19,101 @@ export class BookingSeatRepository { }, }); } + + async getByReferenceUidIncludeBookingWithAttendeesAndUserAndEvent(referenceUid: string) { + return this.dbRead.prisma.bookingSeat.findUnique({ + where: { + referenceUid, + }, + select: { + id: true, + referenceUid: true, + data: true, + metadata: true, + bookingId: true, + attendeeId: true, + booking: { + select: { + id: true, + uid: true, + userId: true, + idempotencyKey: true, + userPrimaryEmail: true, + title: true, + description: true, + customInputs: true, + responses: true, + startTime: true, + endTime: true, + status: true, + location: true, + metadata: true, + paid: true, + destinationCalendarId: true, + cancellationReason: true, + rejectionReason: true, + reassignReason: true, + reassignById: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + fromReschedule: true, + rescheduled: true, + recurringEventId: true, + smsReminderNumber: true, + scheduledJobs: true, + isRecorded: true, + iCalUID: true, + iCalSequence: true, + rating: true, + ratingFeedback: true, + noShowHost: true, + oneTimePassword: true, + cancelledBy: true, + rescheduledBy: true, + createdAt: true, + updatedAt: true, + eventTypeId: true, + creationSource: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + locale: true, + phoneNumber: true, + noShow: true, + bookingSeat: { + select: { + id: true, + referenceUid: true, + data: true, + metadata: true, + bookingId: true, + attendeeId: true, + }, + }, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + username: true, + }, + }, + eventType: { + select: { + id: true, + slug: true, + seatsShowAttendees: true, + teamId: true, + userId: true, + }, + }, + }, + }, + }, + }); + } }