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 da84422dbc..e0136dd3f0 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 @@ -267,7 +267,14 @@ export class BookingsController_2024_08_13 { @ApiOperation({ summary: "Cancel a booking", description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences. - For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call + + \nCancelling seated bookings: + It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host. + \n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking. + \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees. + + \nCancelling recurring seated bookings: + For recurring seated bookings it is not possible to cancel all of them with 1 call like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`, }) @ApiBody({ 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 ef65e31c3c..b273bd2b8c 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 @@ -14,29 +14,27 @@ import { Test } from "@nestjs/testing"; import { User } from "@prisma/client"; import { DateTime } from "luxon"; import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture"; 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 { randomString } from "test/utils/randomString"; -import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; import { + CancelBookingInput_2024_08_13, 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 { CreateBookingInput_2024_08_13 } from "@calcom/platform-types"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; describe("Bookings Endpoints 2024-08-13", () => { @@ -51,11 +49,13 @@ describe("Bookings Endpoints 2024-08-13", () => { let schedulesService: SchedulesService_2024_04_15; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; - let oAuthClient: PlatformOAuthClient; let teamRepositoryFixture: TeamRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture; const userEmail = `seated-bookings-user-${randomString()}@api.com`; let user: User; + let apiKeyString: string; let seatedEventTypeId: number; const maxRecurrenceCount = 3; @@ -63,6 +63,7 @@ describe("Bookings Endpoints 2024-08-13", () => { const seatedEventSlug = `seated-bookings-event-type-${randomString()}`; let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13; + let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13; const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`; const nameAttendeeOne = `Attendee One ${randomString()}`; @@ -70,12 +71,9 @@ describe("Bookings Endpoints 2024-08-13", () => { const nameAttendeeTwo = `Attendee Two ${randomString()}`; beforeAll(async () => { - const moduleRef = await withApiAuth( - userEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], - }) - ) + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) .overrideGuard(PermissionsGuard) .useValue({ canActivate: () => true, @@ -87,22 +85,21 @@ describe("Bookings Endpoints 2024-08-13", () => { eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); + bookingSeatRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef); organization = await teamRepositoryFixture.create({ name: `seated-bookings-organization-${randomString()}`, }); - oAuthClient = await createOAuthClient(organization.id); user = await userRepositoryFixture.create({ email: userEmail, - platformOAuthClients: { - connect: { - id: oAuthClient.id, - }, - }, }); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = `cal_test_${keyString}`; + const userSchedule: CreateScheduleInput_2024_04_15 = { name: `seated-bookings-2024-08-13-schedule-${randomString()}`, timeZone: "Europe/Rome", @@ -118,6 +115,14 @@ describe("Bookings Endpoints 2024-08-13", () => { seatsShowAttendees: true, seatsShowAvailabilityCount: true, locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + metadata: { + disableStandardEmails: { + all: { + attendee: true, + host: true, + }, + }, + }, }, user.id ); @@ -129,19 +134,6 @@ describe("Bookings Endpoints 2024-08-13", () => { 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(), @@ -333,6 +325,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .get("/v2/bookings?sortCreated=asc") .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${apiKeyString}`) .expect(200) .then(async (response) => { const responseBody: GetBookingsOutput_2024_08_13 = response.body; @@ -409,67 +402,186 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); - it("should cancel seated booking", async () => { - const body: CancelSeatedBookingInput_2024_08_13 = { - seatUid: createdSeatedBooking.seatUid, - }; + describe("cancel seated booking", () => { + describe("cancel seated booking as attendee", () => { + 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); + 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: seatedEventSlug, + 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: seatedEventSlug, + }); + 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"); + } }); - 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"); - } }); + }); + + describe("cancel seated booking as host", () => { + it("should book an event type with seats", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + 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(); + 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: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking2 = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should not be able to cancel without cancellation reason", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${apiKeyString}`) + .expect(400); + + expect(response.body.message).toEqual("Cancellation reason is required when you are the host"); + }); + + it("should cancel seated booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "I will be travelling without internet", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${apiKeyString}`) + .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(createdSeatedBooking2.start); + expect(data.end).toEqual(createdSeatedBooking2.end); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(0); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + expect(data.cancellationReason).toEqual("I will be travelling without internet"); + + const seats = await bookingSeatRepositoryFixture.findAllByBookingId(data.id); + expect(seats.length).toEqual(0); + } 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); 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 2ce7fe5743..720b90b093 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 @@ -266,6 +266,7 @@ export class OutputBookingsService_2024_08_13 { description: databaseBooking.description, hosts: [this.getHost(databaseBooking.user)], status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, rescheduledFromUid: databaseBooking.fromReschedule || undefined, start: databaseBooking.startTime, end: databaseBooking.endTime, diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 44115dfa19..42734465b9 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -7653,7 +7653,7 @@ "post": { "operationId": "BookingsController_2024_08_13_cancelBooking", "summary": "Cancel a booking", - "description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.", + "description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.", "parameters": [ { "name": "cal-api-version", diff --git a/apps/api/v2/test/fixtures/repository/booking-seat.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/booking-seat.repository.fixture.ts index 776eb282d4..4f57de3422 100644 --- a/apps/api/v2/test/fixtures/repository/booking-seat.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/booking-seat.repository.fixture.ts @@ -16,4 +16,8 @@ export class BookingSeatRepositoryFixture { async create(bookingSeat: Prisma.BookingSeatCreateInput) { return this.prismaWriteClient.bookingSeat.create({ data: bookingSeat }); } + + async findAllByBookingId(bookingId: number) { + return this.prismaReadClient.bookingSeat.findMany({ where: { bookingId } }); + } } diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index c638b9dcc4..f1781d79ea 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -7321,7 +7321,7 @@ "post": { "operationId": "BookingsController_2024_08_13_cancelBooking", "summary": "Cancel a booking", - "description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.", + "description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.", "parameters": [ { "name": "cal-api-version",