diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 45c9c037a3..90deb8ae7c 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -29,7 +29,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", 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 ea2f93f713..bdb02974ad 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 @@ -191,7 +191,12 @@ export class BookingsController_2024_08_13 { @Post("/:bookingUid/cancel") @UseGuards(BookingUidGuard) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Cancel a booking" }) + @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 + like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`, + }) async cancelBooking( @Req() request: Request, @Param("bookingUid") bookingUid: string, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/reassign-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/reassign-bookings.e2e-spec.ts index adf6ea69b7..5312df5ff0 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/reassign-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/reassign-bookings.e2e-spec.ts @@ -25,13 +25,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; -import { - CreateBookingInput_2024_08_13, - BookingOutput_2024_08_13, - RecurringBookingOutput_2024_08_13, - GetBookingsOutput_2024_08_13, - GetSeatedBookingOutput_2024_08_13, -} from "@calcom/platform-types"; +import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; describe("Bookings Endpoints 2024-08-13", () => { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/recurring-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/recurring-bookings.e2e-spec.ts new file mode 100644 index 0000000000..bdece2e420 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/recurring-bookings.e2e-spec.ts @@ -0,0 +1,691 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +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 * 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 { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries"; +import { + CreateRecurringBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Creating recurring 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 = "bookings-controller-e2e@api.com"; + let user: User; + + const maxRecurrenceCount = 3; + let recurringEventTypeId: number; + + 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 recurringEvent = 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: "peer-coding-recurring", + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.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 not create recurring booking with recurrenceCount larger than event type recurrence count", async () => { + const recurrenceCount = 1000; + + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount - 1; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + 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(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount - 1); + + const firstBooking = data[0]; + 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(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + 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(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + + const firstBooking = data[0]; + 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(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.meetingUrl).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toBeDefined(); + expect(thirdBooking.uid).toBeDefined(); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual("accepted"); + expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); + expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); + expect(thirdBooking.duration).toEqual(60); + expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(thirdBooking.recurringBookingUid).toBeDefined(); + expect(thirdBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(thirdBooking.location).toEqual(body.location); + expect(thirdBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterEach(async () => { + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + }); + + 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(); + }); + }); + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + describe("Recurring bookings cancel all subsequent 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 = "bookings-controller-e2e@api.com"; + let user: User; + + const maxRecurrenceCount = 4; + let recurringEventTypeId: number; + + let recurringBooking: RecurringBookingOutput_2024_08_13[]; + + beforeAll(async () => { + jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + 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 recurringEvent = 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: "peer-coding-recurring", + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.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 create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + recurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should cancel all recurrences after a specific recurrence", async () => { + const recurringBookingUid = recurringBooking[2].recurringBookingUid; + const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid); + for (const booking of bookings) { + expect(booking.status).toEqual("ACCEPTED"); + } + + // note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences + // aka cancel 3rd and 4th recurrences. + const thirdRecurrenceUid = recurringBooking[2].uid; + const fourthRecurringUid = recurringBooking[3].uid; + const body: CancelBookingInput_2024_08_13 = { + cancelSubsequentBookings: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${thirdRecurrenceUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + + const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid); + expect(firstRecurrence).toBeDefined(); + expect(firstRecurrence?.status).toEqual("accepted"); + + const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid); + expect(secondRecurrence).toBeDefined(); + expect(secondRecurrence?.status).toEqual("accepted"); + + const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid); + expect(thirdRecurrence).toBeDefined(); + expect(thirdRecurrence?.status).toEqual("cancelled"); + + const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid); + expect(fourthRecurrence).toBeDefined(); + expect(fourthRecurrence?.status).toEqual("cancelled"); + + const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid); + + const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid); + expect(bookingFirst?.status).toEqual("ACCEPTED"); + const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid); + expect(bookingSecond?.status).toEqual("ACCEPTED"); + const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid); + expect(bookingThird?.status).toEqual("CANCELLED"); + const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid); + expect(bookingFourth?.status).toEqual("CANCELLED"); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + 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(); + }); + }); + + describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => { + 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 = "bookings-controller-e2e@api.com"; + let user: User; + + const maxRecurrenceCount = 4; + let recurringEventTypeId: number; + + let recurringBooking: RecurringBookingOutput_2024_08_13[]; + + beforeAll(async () => { + jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + 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 recurringEvent = 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: "peer-coding-recurring", + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.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 create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + recurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should cancel all remaning recurrences", async () => { + const recurringBookingUid = recurringBooking[0].recurringBookingUid; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${recurringBookingUid}/cancel`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + for (const booking of data) { + expect(booking.status).toEqual("cancelled"); + } + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + 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/user-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts index 201a963415..415f294640 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 @@ -1127,293 +1127,4 @@ describe("Bookings Endpoints 2024-08-13", () => { function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { return Array.isArray(data); } - - describe("Recurring 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 = "bookings-controller-e2e@api.com"; - let user: User; - - const maxRecurrenceCount = 3; - let recurringEventTypeId: number; - - 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 recurringEvent = 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: "peer-coding-recurring", - length: 60, - recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, - }, - user.id - ); - recurringEventTypeId = recurringEvent.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 not create recurring booking with recurrenceCount larger than event type recurrence count", async () => { - const recurrenceCount = 1000; - - const body: CreateRecurringBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), - eventTypeId: recurringEventTypeId, - attendee: { - name: "Mr Proper Recurring", - email: "mr_proper_recurring@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - recurrenceCount, - }; - - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(400); - }); - - it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => { - const recurrenceCount = maxRecurrenceCount - 1; - const body: CreateRecurringBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), - eventTypeId: recurringEventTypeId, - attendee: { - name: "Mr Proper Recurring", - email: "mr_proper_recurring@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - 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(responseDataIsRecurringBooking(responseBody.data)).toBe(true); - - if (responseDataIsRecurringBooking(responseBody.data)) { - const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; - expect(data.length).toEqual(maxRecurrenceCount - 1); - - const firstBooking = data[0]; - 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(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); - expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); - expect(firstBooking.duration).toEqual(60); - expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); - expect(firstBooking.attendees[0]).toEqual({ - name: body.attendee.name, - timeZone: body.attendee.timeZone, - language: body.attendee.language, - absent: false, - }); - expect(firstBooking.location).toEqual(body.location); - expect(firstBooking.recurringBookingUid).toBeDefined(); - expect(firstBooking.absentHost).toEqual(false); - - const secondBooking = data[1]; - expect(secondBooking.id).toBeDefined(); - expect(secondBooking.uid).toBeDefined(); - expect(secondBooking.hosts[0].id).toEqual(user.id); - expect(secondBooking.status).toEqual("accepted"); - expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); - expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); - expect(secondBooking.duration).toEqual(60); - expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); - expect(secondBooking.recurringBookingUid).toBeDefined(); - expect(secondBooking.attendees[0]).toEqual({ - name: body.attendee.name, - timeZone: body.attendee.timeZone, - language: body.attendee.language, - absent: false, - }); - expect(secondBooking.location).toEqual(body.location); - expect(secondBooking.absentHost).toEqual(false); - } else { - throw new Error( - "Invalid response data - expected recurring booking but received non array response" - ); - } - }); - }); - - it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => { - const recurrenceCount = maxRecurrenceCount; - const body: CreateRecurringBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), - eventTypeId: recurringEventTypeId, - attendee: { - name: "Mr Proper Recurring", - email: "mr_proper_recurring@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - 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(responseDataIsRecurringBooking(responseBody.data)).toBe(true); - - if (responseDataIsRecurringBooking(responseBody.data)) { - const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; - expect(data.length).toEqual(maxRecurrenceCount); - - const firstBooking = data[0]; - 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(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); - expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); - expect(firstBooking.duration).toEqual(60); - expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); - expect(firstBooking.attendees[0]).toEqual({ - name: body.attendee.name, - timeZone: body.attendee.timeZone, - language: body.attendee.language, - absent: false, - }); - expect(firstBooking.location).toEqual(body.location); - expect(firstBooking.meetingUrl).toEqual(body.location); - expect(firstBooking.recurringBookingUid).toBeDefined(); - expect(firstBooking.absentHost).toEqual(false); - - const secondBooking = data[1]; - expect(secondBooking.id).toBeDefined(); - expect(secondBooking.uid).toBeDefined(); - expect(secondBooking.hosts[0].id).toEqual(user.id); - expect(secondBooking.status).toEqual("accepted"); - expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); - expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); - expect(secondBooking.duration).toEqual(60); - expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); - expect(secondBooking.recurringBookingUid).toBeDefined(); - expect(secondBooking.attendees[0]).toEqual({ - name: body.attendee.name, - timeZone: body.attendee.timeZone, - language: body.attendee.language, - absent: false, - }); - expect(secondBooking.location).toEqual(body.location); - expect(secondBooking.absentHost).toEqual(false); - - const thirdBooking = data[2]; - expect(thirdBooking.id).toBeDefined(); - expect(thirdBooking.uid).toBeDefined(); - expect(thirdBooking.hosts[0].id).toEqual(user.id); - expect(thirdBooking.status).toEqual("accepted"); - expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); - expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); - expect(thirdBooking.duration).toEqual(60); - expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); - expect(thirdBooking.recurringBookingUid).toBeDefined(); - expect(thirdBooking.attendees[0]).toEqual({ - name: body.attendee.name, - timeZone: body.attendee.timeZone, - language: body.attendee.language, - absent: false, - }); - expect(thirdBooking.location).toEqual(body.location); - expect(thirdBooking.absentHost).toEqual(false); - } else { - throw new Error( - "Invalid response data - expected recurring booking but received non array response" - ); - } - }); - }); - - afterEach(async () => { - await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); - }); - - 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/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 245036ca8a..976a53ace0 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 @@ -307,9 +307,26 @@ export class BookingsService_2024_08_13 { const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body); await handleCancelBooking(bookingRequest); + + if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) { + return this.getAllRecurringBookingsByIndividualUid(bookingUid); + } + return this.getBooking(bookingUid); } + private async getAllRecurringBookingsByIndividualUid(bookingUid: string) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + const recurringBookingUid = booking?.recurringEventId; + if (!recurringBookingUid) { + throw new BadRequestException( + `Booking with bookingUid=${bookingUid} is not part of a recurring booking.` + ); + } + + return await this.getBooking(recurringBookingUid); + } + async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) { const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); 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 5a4a5f7e04..1cd441a6a5 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 @@ -493,23 +493,38 @@ export class InputBookingsService_2024_08_13 { } async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) { - let allRemainingBookings = false; - let uid = bookingUid; - const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent( - bookingUid - ); + const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid); + // note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings. + // That is what recurringEventId refers to. + const isRecurringUid = !!recurringBooking.length; - 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; + if (isRecurringUid && inputBooking.cancelSubsequentBookings) { + throw new BadRequestException( + "Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking." + ); + } + + if (isRecurringUid) { + return { + // note(Lauris): set uid as one of the oldest individual recurring ids + uid: recurringBooking[0].uid, + cancellationReason: inputBooking.cancellationReason, + allRemainingBookings: true, + }; + } + + if (inputBooking.cancelSubsequentBookings) { + return { + uid: bookingUid, + cancellationReason: inputBooking.cancellationReason, + cancelSubsequentBookings: true, + }; } return { - uid, + uid: bookingUid, cancellationReason: inputBooking.cancellationReason, - allRemainingBookings, + allRemainingBookings: false, }; } @@ -517,23 +532,12 @@ export class InputBookingsService_2024_08_13 { 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; - } - + // note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings + // for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it. return { - uid, + uid: bookingUid, cancellationReason: "", - allRemainingBookings, + allRemainingBookings: false, seatReferenceUid: inputBooking.seatUid, }; } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index ff15248b72..f0311e77ad 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3162,6 +3162,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.", "parameters": [ { "name": "cal-api-version", diff --git a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts index 5bc6ccacb5..9bfbd7abd2 100644 --- a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -22,6 +22,14 @@ export class BookingsRepositoryFixture { return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } }); } + async getByRecurringBookingUid(recurringBookingUid: string) { + return this.prismaReadClient.booking.findMany({ + where: { + recurringEventId: recurringBookingUid, + }, + }); + } + async create(booking: Prisma.BookingCreateInput) { return this.prismaWriteClient.booking.create({ data: booking }); } diff --git a/packages/emails/templates/attendee-cancelled-email.ts b/packages/emails/templates/attendee-cancelled-email.ts index 6433fdeae7..d39384017f 100644 --- a/packages/emails/templates/attendee-cancelled-email.ts +++ b/packages/emails/templates/attendee-cancelled-email.ts @@ -1,3 +1,5 @@ +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + import { renderEmail } from "../"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile"; import AttendeeScheduledEmail from "./attendee-scheduled-email"; @@ -17,11 +19,15 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail { title: this.calEvent.title, date: this.getFormattedDate(), })}`, - html: await renderEmail("AttendeeCancelledEmail", { - calEvent: this.calEvent, - attendee: this.attendee, - }), + html: await this.getHtml(this.calEvent, this.attendee), text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"), }; } + + protected async getHtml(calEvent: CalendarEvent, attendee: Person) { + return await renderEmail("AttendeeCancelledEmail", { + calEvent, + attendee, + }); + } } diff --git a/packages/emails/templates/organizer-cancelled-email.ts b/packages/emails/templates/organizer-cancelled-email.ts index 2b981b4be5..19a1729938 100644 --- a/packages/emails/templates/organizer-cancelled-email.ts +++ b/packages/emails/templates/organizer-cancelled-email.ts @@ -1,4 +1,5 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { renderEmail } from "../"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile"; @@ -20,11 +21,15 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { title: this.calEvent.title, date: this.getFormattedDate(), })}`, - html: await renderEmail("OrganizerCancelledEmail", { - attendee: this.calEvent.organizer, - calEvent: this.calEvent, - }), + html: await this.getHtml(this.calEvent, this.calEvent.organizer), text: this.getTextBody("event_request_cancelled"), }; } + + protected async getHtml(calEvent: CalendarEvent, organizer: Person) { + return await renderEmail("OrganizerCancelledEmail", { + calEvent, + attendee: organizer, + }); + } } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 5ce0a4369c..8fa9bab878 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -155,8 +155,15 @@ export type HandleCancelBookingResponse = { }; async function handler(req: CustomRequest) { - const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid, cancelledBy } = - bookingCancelInput.parse(req.body); + const { + id, + uid, + allRemainingBookings, + cancellationReason, + seatReferenceUid, + cancelledBy, + cancelSubsequentBookings, + } = bookingCancelInput.parse(req.body); req.bookingToDelete = await getBookingToDelete(id, uid); const { bookingToDelete, @@ -406,14 +413,19 @@ async function handler(req: CustomRequest) { // by cancelling first, and blocking whilst doing so; we can ensure a cancel // action always succeeds even if subsequent integrations fail cancellation. - if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) { + if ( + bookingToDelete.eventType?.recurringEvent && + bookingToDelete.recurringEventId && + (allRemainingBookings || cancelSubsequentBookings) + ) { const recurringEventId = bookingToDelete.recurringEventId; + const gte = cancelSubsequentBookings ? bookingToDelete.startTime : new Date(); // Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now) await prisma.booking.updateMany({ where: { recurringEventId, startTime: { - gte: new Date(), + gte, }, }, data: { diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 2abc03dd01..90e31b6309 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -5,8 +5,10 @@ import { CalendarService as IcsFeedCalendarService } from "@calcom/app-store/ics import type { CredentialOwner } from "@calcom/app-store/types"; import { getAppFromSlug } from "@calcom/app-store/utils"; import type { CredentialDataWithTeamName, LocationOption } from "@calcom/app-store/utils"; +import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-email"; import AttendeeDeclinedEmail from "@calcom/emails/templates/attendee-declined-email"; import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email"; +import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email"; import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-email"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; @@ -194,3 +196,7 @@ export { AttendeeScheduledEmail }; export { OrganizerScheduledEmail }; export { AttendeeDeclinedEmail }; + +export { AttendeeCancelledEmail }; + +export { OrganizerCancelledEmail }; 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 66f92af8d7..22e87c2f1c 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 @@ -1,11 +1,19 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsOptional, IsString } from "class-validator"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; export class CancelBookingInput_2024_08_13 { @IsString() @IsOptional() @ApiProperty({ example: "User requested cancellation" }) cancellationReason?: string; + + @IsBoolean() + @IsOptional() + @ApiProperty({ + description: + "For recurring non-seated booking - if true, cancel booking with the bookingUid of the individual recorrence and all recurrences that come after it.", + }) + cancelSubsequentBookings?: boolean; } export class CancelSeatedBookingInput_2024_08_13 { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 82efb2ff33..c5b97a381d 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -327,7 +327,10 @@ export const bookingCreateBodySchemaForApi = extendedBookingCreateBody.merge( export const bookingCancelSchema = z.object({ id: z.number().optional(), uid: z.string().optional(), + // note(Lauris): allRemainingBookings will cancel all bookings that have start time greater than this moment. allRemainingBookings: z.boolean().optional(), + // note(Lauris): cancelSubsequentBookings will cancel all bookings after one specified by id or uid. + cancelSubsequentBookings: z.boolean().optional(), cancellationReason: z.string().optional(), seatReferenceUid: z.string().optional(), cancelledBy: z.string().email({ message: "Invalid email" }).optional(), diff --git a/yarn.lock b/yarn.lock index 02ec1d6862..42f8d64373 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3520,7 +3520,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61" "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" @@ -4502,14 +4502,14 @@ __metadata: languageName: node linkType: hard -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.60": - version: 0.0.60 - resolution: "@calcom/platform-libraries@npm:0.0.60" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.61": + version: 0.0.61 + resolution: "@calcom/platform-libraries@npm:0.0.61" dependencies: "@calcom/core": "*" "@calcom/features": "*" "@calcom/lib": "*" - checksum: 802ba5475a5f0e415e0e2fa639628cf4c3642e3de1f8cefb7716fa70d371de71088a329c149387ed5c466300fd83c695be0afadc68a305e8e8d85d40efb9b940 + checksum: dd04843e877d036476b35c1fed0f5ea6089fc4df7962fb8be63d3c5651ec9527b37890237923f6c6db1fa29eef220ac888cfd7defa845fa269a04cbb70c6cab8 languageName: node linkType: hard