From 3c57960b7d38d3c578cb6c31129ee8f8f9a5d3be Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Wed, 18 Mar 2026 16:55:53 +0530 Subject: [PATCH] feat: api v2 `DELETE` booking attendees endpoint (#27781) * feat: remove attendee endpoint * fix: remove attendee email from error logs to avoid logging PII Co-Authored-By: unknown <> * fix: add isBookingAuditEnabled to removeAttendee handler Align removeAttendee.handler.ts with the new onAttendeeRemoved interface that requires isBookingAuditEnabled, following the same pattern used in addGuests.handler.ts. Co-Authored-By: bot_apk * style: apply biome formatting to conflict-resolved files Co-Authored-By: bot_apk * chore: implement PR feedback * fixup * revert: biome formatting changes * chore: implement feedback part 1 * chore: implement feedback part 2 * fix: await cancellation email flow to prevent uncaught promise rejections The fire-and-forget .then() chain on prepareAttendeePerson() left rejections from that promise uncaught. Await both prepareAttendeePerson() and sendCancelledEmailToAttendee() so errors are properly handled. sendCancelledEmailToAttendee() already has an internal try/catch, so awaiting it will not cause the overall removeAttendee flow to fail on email errors. Addresses Cubic AI review (confidence 9/10). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: implement feedback part 3 * chore: implement devin feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk --- .../ee/bookings/2024-08-13/bookings.module.ts | 2 + .../booking-attendees.controller.ts | 69 ++- .../e2e/remove-attendee.e2e-spec.ts | 525 ++++++++++++++++++ .../outputs/remove-attendee.output.ts | 64 +++ .../services/booking-attendees.service.ts | 95 +++- .../lib/modules/booking-attendees.module.ts | 21 + .../booking-attendees-remove.service.ts | 18 + .../lib/services/booking-attendees.service.ts | 20 + .../repository/bookings.repository.fixture.ts | 7 + .../di/BookingAttendeeRepository.module.ts | 2 +- ...BookingAttendeesRemoveService.container.ts | 15 + .../BookingAttendeesRemoveService.module.ts | 29 + .../di/BookingAttendeesService.container.ts | 13 + .../di/BookingAttendeesService.module.ts | 31 ++ .../di/BookingReferenceRepository.module.ts | 2 +- packages/features/bookings/di/tokens.ts | 4 + .../lib/dto/IBookingAttendeeRepository.ts | 7 + .../PrismaBookingAttendeeRepository.ts | 21 +- ...AttendeesRemoveService.integration-test.ts | 374 +++++++++++++ .../services/BookingAttendeesRemoveService.ts | 170 ++++++ .../services/BookingAttendeesService.ts | 70 +-- packages/features/users/di/Profile.module.ts | 2 +- packages/platform/libraries/bookings.ts | 2 + 23 files changed, 1469 insertions(+), 94 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/remove-attendee.e2e-spec.ts create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/outputs/remove-attendee.output.ts create mode 100644 apps/api/v2/src/lib/modules/booking-attendees.module.ts create mode 100644 apps/api/v2/src/lib/services/booking-attendees-remove.service.ts create mode 100644 apps/api/v2/src/lib/services/booking-attendees.service.ts create mode 100644 packages/features/bookings/di/BookingAttendeesRemoveService.container.ts create mode 100644 packages/features/bookings/di/BookingAttendeesRemoveService.module.ts create mode 100644 packages/features/bookings/di/BookingAttendeesService.container.ts create mode 100644 packages/features/bookings/di/BookingAttendeesService.module.ts create mode 100644 packages/features/bookings/services/BookingAttendeesRemoveService.integration-test.ts create mode 100644 packages/features/bookings/services/BookingAttendeesRemoveService.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index 90c24560b1..4d7ef0cc33 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -30,6 +30,7 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0 import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { BookingAttendeesModule } from "@/lib/modules/booking-attendees.module"; import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module"; import { InstantBookingModule } from "@/lib/modules/instant-booking.module"; import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module"; @@ -80,6 +81,7 @@ import { UsersModule } from "@/modules/users/users.module"; RecurringBookingModule, InstantBookingModule, BookingEventHandlerModule, + BookingAttendeesModule, ], providers: [ TokensRepository, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts index 9a3e0cf553..29754ce194 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts @@ -1,33 +1,28 @@ -import { - BOOKING_WRITE, - BOOKING_READ, - SUCCESS_STATUS, -} from "@calcom/platform-constants"; +import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types"; import { Body, Controller, + Delete, + Get, HttpCode, HttpStatus, Param, + ParseIntPipe, Post, UseGuards, - Get, - ParseIntPipe, } from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; 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 { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output"; import { - GetBookingAttendeesOutput_2024_08_13, GetBookingAttendeeOutput_2024_08_13, + GetBookingAttendeesOutput_2024_08_13, } from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.output"; +import { RemoveAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/remove-attendee.output"; import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service"; -import { - VERSION_2024_08_13, - VERSION_2024_08_13_VALUE, -} 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 } from "@/lib/docs/headers"; import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -49,9 +44,7 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st required: true, }) export class BookingAttendeesController_2024_08_13 { - constructor( - private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13 - ) {} + constructor(private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13) {} @Get("/") @Permissions([BOOKING_READ]) @@ -68,9 +61,7 @@ export class BookingAttendeesController_2024_08_13 { @Param("bookingUid") bookingUid: string, @GetUser() user: ApiAuthGuardUser ): Promise { - const attendees = await this.bookingAttendeesService.getBookingAttendees( - bookingUid - ); + const attendees = await this.bookingAttendeesService.getBookingAttendees(bookingUid); return { status: SUCCESS_STATUS, @@ -93,10 +84,7 @@ export class BookingAttendeesController_2024_08_13 { @Param("bookingUid") bookingUid: string, @Param("attendeeId", ParseIntPipe) attendeeId: number ): Promise { - const attendee = await this.bookingAttendeesService.getBookingAttendee( - bookingUid, - attendeeId - ); + const attendee = await this.bookingAttendeesService.getBookingAttendee(bookingUid, attendeeId); return { status: SUCCESS_STATUS, @@ -135,15 +123,42 @@ export class BookingAttendeesController_2024_08_13 { @Body() body: AddAttendeeInput_2024_08_13, @GetUser() user: ApiAuthGuardUser ): Promise { - const attendee = await this.bookingAttendeesService.addAttendee( - bookingUid, - body, - user - ); + const attendee = await this.bookingAttendeesService.addAttendee(bookingUid, body, user); return { status: SUCCESS_STATUS, data: attendee, }; } + + @Delete("/:attendeeId") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @Throttle({ + limit: 5, + ttl: 60000, + blockDuration: 60000, + name: "booking_attendees_remove", + }) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Remove an attendee from a booking", + description: `Remove an attendee from an existing booking by their attendee ID. The primary attendee (first attendee) cannot be removed — to remove them, cancel the booking instead. The removed attendee will receive a cancellation email notification. + + The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error. + `, + }) + async removeAttendee( + @Param("bookingUid") bookingUid: string, + @Param("attendeeId", ParseIntPipe) attendeeId: number, + @GetUser() user: ApiAuthGuardUser + ): Promise { + const removedAttendee = await this.bookingAttendeesService.removeAttendee(bookingUid, attendeeId, user); + + return { + status: SUCCESS_STATUS, + data: removedAttendee, + }; + } } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/remove-attendee.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/remove-attendee.e2e-spec.ts new file mode 100644 index 0000000000..912769cece --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/remove-attendee.e2e-spec.ts @@ -0,0 +1,525 @@ +import { + CAL_API_VERSION_HEADER, + ERROR_STATUS, + SUCCESS_STATUS, + VERSION_2024_08_13, +} from "@calcom/platform-constants"; +import { AttendeeCancelledEmail } from "@calcom/platform-libraries/emails"; +import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types"; +import type { Team, User } from "@calcom/prisma/client"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import 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 { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RemoveAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/remove-attendee.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"; + +const attendeeCancelledEmailSpy = jest + .spyOn(AttendeeCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); + +type TestUser = { + user: User; + accessToken: string; + refreshToken: string; +}; + +type TestSetup = { + organizer: TestUser; + unrelatedUser: TestUser; + eventTypeId: number; + bookingUid: string; + primaryAttendeeId: number; + secondaryAttendeeId: number; +}; + +describe("Bookings Endpoints 2024-08-13 remove attendee", () => { + 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 teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + + let testSetup: TestSetup; + + beforeAll(async () => { + mockThrottlerGuard(); + + const moduleRef = await 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); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `remove-attendee-2024-08-13-organization-${randomString()}`, + }); + + await setupTestData(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function setupTestData() { + const oAuthClient = await createOAuthClient(organization.id, true); + + const organizerUser = await userRepositoryFixture.create({ + email: `remove-attendee-2024-08-13-organizer-${randomString()}@api.com`, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + const unrelatedUserData = await userRepositoryFixture.create({ + email: `remove-attendee-2024-08-13-unrelated-${randomString()}@api.com`, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id); + const unrelatedUserTokens = await tokensRepositoryFixture.createTokens( + unrelatedUserData.id, + oAuthClient.id + ); + + const schedule: CreateScheduleInput_2024_04_15 = { + name: `remove-attendee-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(organizerUser.id, schedule); + + const eventType = await eventTypesRepositoryFixture.create( + { + title: `remove-attendee-2024-08-13-event-type-${randomString()}`, + slug: `remove-attendee-2024-08-13-event-type-${randomString()}`, + length: 60, + }, + organizerUser.id + ); + + testSetup = { + organizer: { + user: organizerUser, + accessToken: organizerTokens.accessToken, + refreshToken: organizerTokens.refreshToken, + }, + unrelatedUser: { + user: unrelatedUserData, + accessToken: unrelatedUserTokens.accessToken, + refreshToken: unrelatedUserTokens.refreshToken, + }, + eventTypeId: eventType.id, + bookingUid: "", + primaryAttendeeId: 0, + secondaryAttendeeId: 0, + }; + } + + async function createOAuthClient(organizationId: number, emailsEnabled: boolean) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: emailsEnabled, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + beforeEach(async () => { + attendeeCancelledEmailSpy.mockClear(); + }); + + describe("DELETE /v2/bookings/:bookingUid/attendees/:attendeeId", () => { + beforeAll(async () => { + // Create a booking with primary attendee + const createBookingBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(), + eventTypeId: testSetup.eventTypeId, + attendee: { + name: "Primary Attendee", + email: "primary.attendee@gmail.com", + timeZone: "Europe/Rome", + language: "en", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + const createBookingResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body; + expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS); + + if (!responseDataIsBooking(createBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + testSetup.bookingUid = createBookingResponseBody.data.uid; + + // Get primary attendee ID + const booking = await bookingsRepositoryFixture.getByUid(testSetup.bookingUid); + if (!booking) { + throw new Error("Booking not found"); + } + const attendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking.id); + testSetup.primaryAttendeeId = attendees[0].id; + + // Add a secondary guest that we can remove + const addGuestsBody = { + guests: [ + { email: "secondary.guest@example.com", name: "Secondary Guest", timeZone: "America/New_York" }, + ], + }; + + const addGuestsResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${testSetup.bookingUid}/guests`) + .send(addGuestsBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body; + expect(addGuestsResponseBody.status).toEqual(SUCCESS_STATUS); + + // Get secondary attendee ID + const updatedAttendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking.id); + const secondaryAttendee = updatedAttendees.find((a) => a.email === "secondary.guest@example.com"); + if (!secondaryAttendee) { + throw new Error("Secondary attendee not found"); + } + testSetup.secondaryAttendeeId = secondaryAttendee.id; + }); + + async function getAttendeeCountByBookingUid(bookingUid: string): Promise { + const booking = await bookingsRepositoryFixture.getByUid(bookingUid); + if (!booking) throw new Error(`Booking ${bookingUid} not found`); + const attendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking.id); + return attendees.length; + } + + describe("Authentication", () => { + it("should return 401 when removing attendee without authentication", async () => { + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.secondaryAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(removeAttendeeResponse.status).toBe(401); + + const attendeeCount = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCount).toEqual(2); + }); + }); + + describe("Authorization", () => { + it("should return 403 when unrelated user tries to remove attendee", async () => { + await request(app.getHttpServer()) + .delete(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.secondaryAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`) + .expect(403); + + const attendeeCount = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCount).toEqual(2); + }); + }); + + describe("Validation", () => { + it("should return 400 when trying to remove primary attendee", async () => { + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.primaryAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`); + + expect(removeAttendeeResponse.status).toEqual(400); + expect(removeAttendeeResponse.body.status).toEqual(ERROR_STATUS); + + const attendeeCount = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCount).toEqual(2); + }); + + it("should return 404 when trying to remove non-existent attendee", async () => { + const nonExistentAttendeeId = 999999; + + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete(`/v2/bookings/${testSetup.bookingUid}/attendees/${nonExistentAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`); + + expect(removeAttendeeResponse.status).toEqual(404); + + const attendeeCount = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCount).toEqual(2); + }); + + it("should return 404 when booking does not exist", async () => { + const nonExistentBookingUid = "non-existent-booking-uid"; + + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete(`/v2/bookings/${nonExistentBookingUid}/attendees/${testSetup.secondaryAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`); + + expect(removeAttendeeResponse.status).toEqual(404); + + const attendeeCount = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCount).toEqual(2); + }); + }); + + describe("Success cases", () => { + it("should allow organizer to remove secondary attendee", async () => { + attendeeCancelledEmailSpy.mockClear(); + + const attendeeCountBefore = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCountBefore).toEqual(2); + + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.secondaryAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const removeAttendeeResponseBody: RemoveAttendeeOutput_2024_08_13 = removeAttendeeResponse.body; + + expect(removeAttendeeResponseBody.status).toEqual(SUCCESS_STATUS); + expect(removeAttendeeResponseBody.data.id).toEqual(testSetup.secondaryAttendeeId); + expect(removeAttendeeResponseBody.data.email).toEqual("secondary.guest@example.com"); + expect(removeAttendeeResponseBody.data.name).toEqual("Secondary Guest"); + expect(removeAttendeeResponseBody.data.timeZone).toEqual("America/New_York"); + + // Verify attendee was removed from DB + const attendeeCountAfter = await getAttendeeCountByBookingUid(testSetup.bookingUid); + expect(attendeeCountAfter).toEqual(1); + + // Verify the remaining attendee is the primary one + const booking = await bookingsRepositoryFixture.getByUid(testSetup.bookingUid); + const remainingAttendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking!.id); + expect(remainingAttendees[0].email).toEqual("primary.attendee@gmail.com"); + + // Verify email was sent + expect(attendeeCancelledEmailSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("DELETE /v2/bookings/:bookingUid/attendees/:attendeeId - Emails Disabled", () => { + let emailsDisabledSetup: { + organizer: TestUser; + eventTypeId: number; + bookingUid: string; + attendeeIdToRemove: number; + }; + + beforeAll(async () => { + const oAuthClientDisabled = await createOAuthClient(organization.id, false); + + const organizerUser = await userRepositoryFixture.create({ + email: `remove-attendee-no-emails-2024-08-13-organizer-${randomString()}@api.com`, + platformOAuthClients: { + connect: { id: oAuthClientDisabled.id }, + }, + }); + + const organizerTokens = await tokensRepositoryFixture.createTokens( + organizerUser.id, + oAuthClientDisabled.id + ); + + const schedule: CreateScheduleInput_2024_04_15 = { + name: `remove-attendee-no-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(organizerUser.id, schedule); + + const eventType = await eventTypesRepositoryFixture.create( + { + title: `remove-attendee-no-emails-2024-08-13-event-type-${randomString()}`, + slug: `remove-attendee-no-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + }, + organizerUser.id + ); + + // Create booking + const createBookingBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 11, 14, 0, 0)).toISOString(), + eventTypeId: eventType.id, + attendee: { + name: "No Email Primary", + email: "no.email.primary@gmail.com", + timeZone: "Europe/Rome", + language: "en", + }, + location: "https://meet.google.com/no-emails", + bookingFieldsResponses: { + customField: "noEmailValue", + }, + metadata: { + userId: "200", + }, + }; + + const createBookingResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body; + + if (!responseDataIsBooking(createBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + const bookingUid = createBookingResponseBody.data.uid; + + // Add a guest to remove + const addGuestsBody = { + guests: [ + { + email: "no-email.secondary@example.com", + name: "No Email Secondary", + timeZone: "America/New_York", + }, + ], + }; + + await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/guests`) + .send(addGuestsBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${organizerTokens.accessToken}`) + .expect(200); + + // Get secondary attendee ID + const booking = await bookingsRepositoryFixture.getByUid(bookingUid); + if (!booking) { + throw new Error("Booking not found"); + } + const attendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking.id); + const secondaryAttendee = attendees.find((a) => a.email === "no-email.secondary@example.com"); + if (!secondaryAttendee) { + throw new Error("Secondary attendee not found"); + } + + emailsDisabledSetup = { + organizer: { + user: organizerUser, + accessToken: organizerTokens.accessToken, + refreshToken: organizerTokens.refreshToken, + }, + eventTypeId: eventType.id, + bookingUid, + attendeeIdToRemove: secondaryAttendee.id, + }; + }); + + it("should NOT send emails when removing attendee (emails disabled)", async () => { + attendeeCancelledEmailSpy.mockClear(); + + const removeAttendeeResponse = await request(app.getHttpServer()) + .delete( + `/v2/bookings/${emailsDisabledSetup.bookingUid}/attendees/${emailsDisabledSetup.attendeeIdToRemove}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`) + .expect(200); + + const removeAttendeeResponseBody: RemoveAttendeeOutput_2024_08_13 = removeAttendeeResponse.body; + + expect(removeAttendeeResponseBody.status).toEqual(SUCCESS_STATUS); + expect(removeAttendeeResponseBody.data.email).toEqual("no-email.secondary@example.com"); + + // Verify attendee was removed from DB + const booking = await bookingsRepositoryFixture.getByUid(emailsDisabledSetup.bookingUid); + const remainingAttendees = await bookingsRepositoryFixture.getAttendeesByBookingId(booking!.id); + expect(remainingAttendees).toHaveLength(1); + expect(remainingAttendees[0].email).toEqual("no.email.primary@gmail.com"); + + // Verify email was NOT sent + expect(attendeeCancelledEmailSpy).not.toHaveBeenCalled(); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.organizer.user.email); + await bookingsRepositoryFixture.deleteAllBookings( + emailsDisabledSetup.organizer.user.id, + emailsDisabledSetup.organizer.user.email + ); + }); + }); + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + + await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email); + await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email); + + await bookingsRepositoryFixture.deleteAllBookings( + testSetup.organizer.user.id, + testSetup.organizer.user.email + ); + + await app.close(); + }); + + function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/remove-attendee.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/remove-attendee.output.ts new file mode 100644 index 0000000000..dc1f2d22b7 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/remove-attendee.output.ts @@ -0,0 +1,64 @@ +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +export class RemovedAttendeeOutput_2024_08_13 { + @ApiProperty({ + type: Number, + description: "The ID of the attendee.", + example: 123, + }) + @Expose() + id!: number; + + @ApiProperty({ + type: Number, + description: "The ID of the booking.", + example: 456, + }) + @Expose() + bookingId!: number; + + @ApiProperty({ + type: String, + description: "The name of the attendee.", + example: "John Doe", + }) + @Expose() + name!: string; + + @ApiProperty({ + type: String, + description: "The email of the attendee.", + example: "john.doe@example.com", + }) + @Expose() + email!: string; + + @ApiProperty({ + type: String, + description: "The time zone of the attendee.", + example: "America/New_York", + }) + @Expose() + timeZone!: string; +} + +export class RemoveAttendeeOutput_2024_08_13 { + @ApiProperty({ + type: String, + example: SUCCESS_STATUS, + enum: [SUCCESS_STATUS, ERROR_STATUS], + }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: RemovedAttendeeOutput_2024_08_13, + }) + @Expose() + @ValidateNested() + @Type(() => RemovedAttendeeOutput_2024_08_13) + data!: RemovedAttendeeOutput_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts index e6cede6327..d8dff6a010 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts @@ -1,33 +1,25 @@ -import { BookingAttendeesService } from "@calcom/platform-libraries/bookings"; import { ErrorCode, ErrorWithCode } from "@calcom/platform-libraries/errors"; import type { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { plainToClass } from "class-transformer"; import { BookingAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output"; -import { - BookingAttendeeWithId_2024_08_13, -} from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.output"; +import { BookingAttendeeWithId_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.output"; +import { RemovedAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/remove-attendee.output"; import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; +import { BookingAttendeesService } from "@/lib/services/booking-attendees.service"; import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; @Injectable() export class BookingAttendeesService_2024_08_13 { - private bookingAttendeesService: BookingAttendeesService; - constructor( private readonly bookingsRepository: BookingsRepository_2024_08_13, - private readonly platformBookingsService: PlatformBookingsService - ) { - this.bookingAttendeesService = new BookingAttendeesService(); - } + private readonly platformBookingsService: PlatformBookingsService, + private readonly bookingAttendeesService: BookingAttendeesService + ) {} - async getBookingAttendees( - bookingUid: string - ): Promise { - const attendees = await this.bookingAttendeesService.getBookingAttendees( - bookingUid - ); + async getBookingAttendees(bookingUid: string): Promise { + const attendees = await this.bookingAttendeesService.getBookingAttendees(bookingUid); return attendees.map((attendee) => plainToClass( @@ -52,10 +44,7 @@ export class BookingAttendeesService_2024_08_13 { attendeeId: number ): Promise { try { - const attendee = await this.bookingAttendeesService.getBookingAttendee( - bookingUid, - attendeeId - ); + const attendee = await this.bookingAttendeesService.getBookingAttendee(bookingUid, attendeeId); return plainToClass( BookingAttendeeWithId_2024_08_13, @@ -84,22 +73,16 @@ export class BookingAttendeesService_2024_08_13 { input: AddAttendeeInput_2024_08_13, user: ApiAuthGuardUser ): Promise { - const booking = await this.bookingsRepository.getByUidWithEventType( - bookingUid - ); + const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid); if (!booking) { throw new NotFoundException(`Booking with uid ${bookingUid} not found`); } const platformClientParams = booking.eventTypeId - ? await this.platformBookingsService.getOAuthClientParams( - booking.eventTypeId - ) + ? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId) : undefined; - const emailsEnabled = platformClientParams - ? platformClientParams.arePlatformEmailsEnabled - : true; + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; const createdAttendee = await this.bookingAttendeesService.addAttendee({ bookingId: booking.id, @@ -137,6 +120,58 @@ export class BookingAttendeesService_2024_08_13 { ); } + async removeAttendee( + bookingUid: string, + attendeeId: number, + user: ApiAuthGuardUser + ): Promise { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid ${bookingUid} not found`); + } + + const platformClientParams = booking.eventTypeId + ? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + + try { + const removedAttendee = await this.bookingAttendeesService.removeAttendee({ + bookingId: booking.id, + attendeeId, + user: { + id: user.id, + email: user.email, + organizationId: user.organizationId, + uuid: user.uuid, + }, + emailsEnabled, + actionSource: "API_V2", + }); + + return plainToClass( + RemovedAttendeeOutput_2024_08_13, + { + id: removedAttendee.id, + bookingId: removedAttendee.bookingId, + name: removedAttendee.name, + email: removedAttendee.email, + timeZone: removedAttendee.timeZone, + }, + { excludeExtraneousValues: true } + ); + } catch (e) { + if (e instanceof ErrorWithCode && e.code === ErrorCode.NotFound) { + throw new NotFoundException(e.message); + } + if (e instanceof ErrorWithCode && e.code === ErrorCode.BadRequest) { + throw new BadRequestException(e.message); + } + throw e; + } + } + private getDisplayEmail(email: string): string { return email.replace(/\+[a-zA-Z0-9]{25}/, ""); } diff --git a/apps/api/v2/src/lib/modules/booking-attendees.module.ts b/apps/api/v2/src/lib/modules/booking-attendees.module.ts new file mode 100644 index 0000000000..9d5482a380 --- /dev/null +++ b/apps/api/v2/src/lib/modules/booking-attendees.module.ts @@ -0,0 +1,21 @@ +import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module"; +import { PrismaBookingAttendeeRepository } from "@/lib/repositories/prisma-booking-attendee.repository"; +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { BookingAttendeesRemoveService } from "@/lib/services/booking-attendees-remove.service"; +import { BookingAttendeesService } from "@/lib/services/booking-attendees.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, BookingEventHandlerModule], + providers: [ + PrismaBookingRepository, + PrismaBookingAttendeeRepository, + PrismaFeaturesRepository, + BookingAttendeesRemoveService, + BookingAttendeesService, + ], + exports: [BookingAttendeesService], +}) +export class BookingAttendeesModule {} diff --git a/apps/api/v2/src/lib/services/booking-attendees-remove.service.ts b/apps/api/v2/src/lib/services/booking-attendees-remove.service.ts new file mode 100644 index 0000000000..6893875982 --- /dev/null +++ b/apps/api/v2/src/lib/services/booking-attendees-remove.service.ts @@ -0,0 +1,18 @@ +import { PrismaBookingAttendeeRepository } from "@/lib/repositories/prisma-booking-attendee.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { Injectable } from "@nestjs/common"; + +import { BookingAttendeesRemoveService as BaseBookingAttendeesRemoveService } from "@calcom/platform-libraries/bookings"; + +import { BookingEventHandlerService } from "./booking-event-handler.service"; + +@Injectable() +export class BookingAttendeesRemoveService extends BaseBookingAttendeesRemoveService { + constructor( + bookingEventHandlerService: BookingEventHandlerService, + featuresRepository: PrismaFeaturesRepository, + bookingAttendeeRepository: PrismaBookingAttendeeRepository + ) { + super({ bookingEventHandlerService, featuresRepository, bookingAttendeeRepository }); + } +} diff --git a/apps/api/v2/src/lib/services/booking-attendees.service.ts b/apps/api/v2/src/lib/services/booking-attendees.service.ts new file mode 100644 index 0000000000..675d8d4ff7 --- /dev/null +++ b/apps/api/v2/src/lib/services/booking-attendees.service.ts @@ -0,0 +1,20 @@ +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { Injectable } from "@nestjs/common"; + +import { BookingAttendeesService as BaseBookingAttendeesService } from "@calcom/platform-libraries/bookings"; + +import { BookingAttendeesRemoveService } from "./booking-attendees-remove.service"; +import { BookingEventHandlerService } from "./booking-event-handler.service"; + +@Injectable() +export class BookingAttendeesService extends BaseBookingAttendeesService { + constructor( + bookingEventHandlerService: BookingEventHandlerService, + featuresRepository: PrismaFeaturesRepository, + bookingRepository: PrismaBookingRepository, + bookingAttendeesRemoveService: BookingAttendeesRemoveService + ) { + super({ bookingEventHandlerService, featuresRepository, bookingRepository, bookingAttendeesRemoveService }); + } +} 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 e37bf8acf7..327c2d52c1 100644 --- a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -46,4 +46,11 @@ export class BookingsRepositoryFixture { async deleteAllBookings(userId: User["id"], userEmail: User["email"]) { return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } }); } + + async getAttendeesByBookingId(bookingId: Booking["id"]) { + return this.prismaReadClient.attendee.findMany({ + where: { bookingId }, + orderBy: { id: "asc" }, + }); + } } diff --git a/packages/features/bookings/di/BookingAttendeeRepository.module.ts b/packages/features/bookings/di/BookingAttendeeRepository.module.ts index 9062b38de8..bce966c913 100644 --- a/packages/features/bookings/di/BookingAttendeeRepository.module.ts +++ b/packages/features/bookings/di/BookingAttendeeRepository.module.ts @@ -1,7 +1,7 @@ import { PrismaBookingAttendeeRepository } from "@calcom/features/bookings/repositories/PrismaBookingAttendeeRepository"; import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; -import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; export const bookingAttendeeRepositoryModule = createModule(); const token = DI_TOKENS.BOOKING_ATTENDEE_REPOSITORY; diff --git a/packages/features/bookings/di/BookingAttendeesRemoveService.container.ts b/packages/features/bookings/di/BookingAttendeesRemoveService.container.ts new file mode 100644 index 0000000000..6bfb9c64bd --- /dev/null +++ b/packages/features/bookings/di/BookingAttendeesRemoveService.container.ts @@ -0,0 +1,15 @@ +import { createContainer } from "@calcom/features/di/di"; +import { + type BookingAttendeesRemoveService, + moduleLoader as bookingAttendeesRemoveServiceModule, +} from "./BookingAttendeesRemoveService.module"; + +const bookingAttendeesRemoveServiceContainer = createContainer(); + +export function getBookingAttendeesRemoveService(): BookingAttendeesRemoveService { + bookingAttendeesRemoveServiceModule.loadModule(bookingAttendeesRemoveServiceContainer); + + return bookingAttendeesRemoveServiceContainer.get( + bookingAttendeesRemoveServiceModule.token + ); +} diff --git a/packages/features/bookings/di/BookingAttendeesRemoveService.module.ts b/packages/features/bookings/di/BookingAttendeesRemoveService.module.ts new file mode 100644 index 0000000000..a7a9035973 --- /dev/null +++ b/packages/features/bookings/di/BookingAttendeesRemoveService.module.ts @@ -0,0 +1,29 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { BookingAttendeesRemoveService } from "../services/BookingAttendeesRemoveService"; +import { moduleLoader as bookingAttendeeRepositoryModuleLoader } from "./BookingAttendeeRepository.module"; +import { moduleLoader as bookingEventHandlerServiceModuleLoader } from "./BookingEventHandlerService.module"; + +const thisModule = createModule(); +const token = DI_TOKENS.BOOKING_ATTENDEES_REMOVE_SERVICE; +const moduleToken = DI_TOKENS.BOOKING_ATTENDEES_REMOVE_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: BookingAttendeesRemoveService, + depsMap: { + bookingEventHandlerService: bookingEventHandlerServiceModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, + bookingAttendeeRepository: bookingAttendeeRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { BookingAttendeesRemoveService }; diff --git a/packages/features/bookings/di/BookingAttendeesService.container.ts b/packages/features/bookings/di/BookingAttendeesService.container.ts new file mode 100644 index 0000000000..57299b68a3 --- /dev/null +++ b/packages/features/bookings/di/BookingAttendeesService.container.ts @@ -0,0 +1,13 @@ +import { createContainer } from "@calcom/features/di/di"; +import { + type BookingAttendeesService, + moduleLoader as bookingAttendeesServiceModule, +} from "./BookingAttendeesService.module"; + +const bookingAttendeesServiceContainer = createContainer(); + +export function getBookingAttendeesService(): BookingAttendeesService { + bookingAttendeesServiceModule.loadModule(bookingAttendeesServiceContainer); + + return bookingAttendeesServiceContainer.get(bookingAttendeesServiceModule.token); +} diff --git a/packages/features/bookings/di/BookingAttendeesService.module.ts b/packages/features/bookings/di/BookingAttendeesService.module.ts new file mode 100644 index 0000000000..4ce11b2f34 --- /dev/null +++ b/packages/features/bookings/di/BookingAttendeesService.module.ts @@ -0,0 +1,31 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { BookingAttendeesService } from "../services/BookingAttendeesService"; +import { moduleLoader as bookingAttendeesRemoveServiceModuleLoader } from "./BookingAttendeesRemoveService.module"; +import { moduleLoader as bookingEventHandlerServiceModuleLoader } from "./BookingEventHandlerService.module"; + +const thisModule = createModule(); +const token = DI_TOKENS.BOOKING_ATTENDEES_SERVICE; +const moduleToken = DI_TOKENS.BOOKING_ATTENDEES_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: BookingAttendeesService, + depsMap: { + bookingEventHandlerService: bookingEventHandlerServiceModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, + bookingRepository: bookingRepositoryModuleLoader, + bookingAttendeesRemoveService: bookingAttendeesRemoveServiceModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { BookingAttendeesService }; diff --git a/packages/features/bookings/di/BookingReferenceRepository.module.ts b/packages/features/bookings/di/BookingReferenceRepository.module.ts index 0c344bedaa..bfa07f58bf 100644 --- a/packages/features/bookings/di/BookingReferenceRepository.module.ts +++ b/packages/features/bookings/di/BookingReferenceRepository.module.ts @@ -1,7 +1,7 @@ import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository"; -import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; export const bookingReferenceRepositoryModule = createModule(); const token = DI_TOKENS.BOOKING_REFERENCE_REPOSITORY; diff --git a/packages/features/bookings/di/tokens.ts b/packages/features/bookings/di/tokens.ts index 3fbc106184..fa30ba5e4a 100644 --- a/packages/features/bookings/di/tokens.ts +++ b/packages/features/bookings/di/tokens.ts @@ -17,4 +17,8 @@ export const BOOKING_DI_TOKENS = { BOOKING_EVENT_HANDLER_SERVICE_MODULE: Symbol("BookingEventHandlerServiceModule"), ATTENDEE_REPOSITORY: Symbol("AttendeeRepository"), ATTENDEE_REPOSITORY_MODULE: Symbol("AttendeeRepositoryModule"), + BOOKING_ATTENDEES_SERVICE: Symbol("BookingAttendeesService"), + BOOKING_ATTENDEES_SERVICE_MODULE: Symbol("BookingAttendeesServiceModule"), + BOOKING_ATTENDEES_REMOVE_SERVICE: Symbol("BookingAttendeesRemoveService"), + BOOKING_ATTENDEES_REMOVE_SERVICE_MODULE: Symbol("BookingAttendeesRemoveServiceModule"), }; diff --git a/packages/features/bookings/lib/dto/IBookingAttendeeRepository.ts b/packages/features/bookings/lib/dto/IBookingAttendeeRepository.ts index af29b952fe..19bceb67be 100644 --- a/packages/features/bookings/lib/dto/IBookingAttendeeRepository.ts +++ b/packages/features/bookings/lib/dto/IBookingAttendeeRepository.ts @@ -1,3 +1,10 @@ +import type { Prisma } from "@calcom/prisma/client"; + export interface IBookingAttendeeRepository { deleteManyByBookingId(bookingId: number): Promise; + deleteByIdAndUpdateBookingResponses( + attendeeId: number, + bookingId: number, + updatedResponses: Prisma.InputJsonValue + ): Promise; } diff --git a/packages/features/bookings/repositories/PrismaBookingAttendeeRepository.ts b/packages/features/bookings/repositories/PrismaBookingAttendeeRepository.ts index 7fe6474da0..d55ac48c82 100644 --- a/packages/features/bookings/repositories/PrismaBookingAttendeeRepository.ts +++ b/packages/features/bookings/repositories/PrismaBookingAttendeeRepository.ts @@ -1,5 +1,4 @@ -import type { PrismaClient } from "@calcom/prisma/client"; - +import type { Prisma, PrismaClient } from "@calcom/prisma/client"; import type { IBookingAttendeeRepository } from "../lib/dto/IBookingAttendeeRepository"; export class PrismaBookingAttendeeRepository implements IBookingAttendeeRepository { @@ -12,4 +11,22 @@ export class PrismaBookingAttendeeRepository implements IBookingAttendeeReposito }, }); } + + async deleteByIdAndUpdateBookingResponses( + attendeeId: number, + bookingId: number, + updatedResponses: Prisma.InputJsonValue + ): Promise { + await this.prismaClient.$transaction([ + this.prismaClient.attendee.delete({ + where: { id: attendeeId }, + }), + this.prismaClient.booking.update({ + where: { id: bookingId }, + data: { + responses: updatedResponses, + }, + }), + ]); + } } diff --git a/packages/features/bookings/services/BookingAttendeesRemoveService.integration-test.ts b/packages/features/bookings/services/BookingAttendeesRemoveService.integration-test.ts new file mode 100644 index 0000000000..12defb6a5c --- /dev/null +++ b/packages/features/bookings/services/BookingAttendeesRemoveService.integration-test.ts @@ -0,0 +1,374 @@ +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { PrismaBookingAttendeeRepository } from "@calcom/features/bookings/repositories/PrismaBookingAttendeeRepository"; +import { prisma } from "@calcom/prisma"; +import { BookingStatus, CreationSource } from "@calcom/prisma/enums"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateCalendarAttendees = vi.fn().mockResolvedValue({}); +vi.mock("@calcom/features/bookings/lib/EventManager", () => ({ + default: class MockEventManager { + updateCalendarAttendees = mockUpdateCalendarAttendees; + }, +})); + +const mockSendEmail = vi.fn().mockResolvedValue(undefined); +vi.mock("@calcom/emails/templates/attendee-cancelled-email", () => ({ + default: class MockAttendeeCancelledEmail { + constructor() {} + sendEmail = mockSendEmail; + }, +})); + +const mockOnAttendeeRemoved = vi.fn().mockResolvedValue(undefined); + +const mockBookingEventHandlerService = { + onAttendeeRemoved: mockOnAttendeeRemoved, +}; + +const mockFeaturesRepository = { + checkIfTeamHasFeature: vi.fn().mockResolvedValue(false), +}; + +vi.mock("@calcom/trpc/server/routers/viewer/bookings/addGuests.handler", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + validateUserPermissions: vi.fn().mockResolvedValue(undefined), + updateCalendarEvent: vi.fn().mockResolvedValue(undefined), + }; +}); + +import type { BookingAttendeesRemoveServiceDeps } from "./BookingAttendeesRemoveService"; +import { BookingAttendeesRemoveService } from "./BookingAttendeesRemoveService"; + +const createdBookingIds: number[] = []; +const createdUserIds: number[] = []; +const createdEventTypeIds: number[] = []; + +describe("BookingAttendeesRemoveService.removeAttendee (Integration Tests)", () => { + const timestamp = Date.now(); + const bookingRepo = new BookingRepository(prisma); + const service = new BookingAttendeesRemoveService({ + bookingEventHandlerService: mockBookingEventHandlerService, + featuresRepository: mockFeaturesRepository, + bookingAttendeeRepository: new PrismaBookingAttendeeRepository(prisma), + } as unknown as BookingAttendeesRemoveServiceDeps); + + let organizerId: number; + let bookingId: number; + let primaryAttendeeId: number; + let secondaryAttendeeId: number; + + const testUser = { + id: 0, + email: "", + organizationId: null as number | null, + uuid: `test-uuid-${timestamp}`, + }; + + beforeAll(async () => { + const organizer = await prisma.user.create({ + data: { + email: `attendee-svc-organizer-${timestamp}@test.com`, + username: `attendee-svc-organizer-${timestamp}`, + name: "Test Organizer", + timeZone: "UTC", + locale: "en", + creationSource: CreationSource.WEBAPP, + schedules: { + create: { + name: "Default Schedule", + timeZone: "UTC", + availability: { + create: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + }, + ], + }, + }, + }, + }, + select: { id: true, email: true }, + }); + organizerId = organizer.id; + createdUserIds.push(organizer.id); + + testUser.id = organizer.id; + testUser.email = organizer.email; + + const eventType = await prisma.eventType.create({ + data: { + title: `attendee-svc-event-${timestamp}`, + slug: `attendee-svc-event-${timestamp}`, + length: 30, + userId: organizerId, + }, + select: { id: true }, + }); + createdEventTypeIds.push(eventType.id); + + const booking = await bookingRepo.createBookingForManagedEventReassignment({ + uid: `attendee-svc-booking-${timestamp}`, + userId: organizerId, + userPrimaryEmail: organizer.email, + eventTypeId: eventType.id, + title: `Attendee Service Test Booking ${timestamp}`, + description: null, + startTime: new Date("2030-06-01T10:00:00.000Z"), + endTime: new Date("2030-06-01T10:30:00.000Z"), + status: BookingStatus.ACCEPTED, + location: null, + smsReminderNumber: null, + idempotencyKey: `attendee-svc-idempotency-${timestamp}`, + iCalUID: `attendee-svc-ical-${timestamp}@test.com`, + iCalSequence: 0, + attendees: [ + { email: "primary@test.com", name: "Primary Attendee", timeZone: "UTC", locale: "en" }, + { + email: "secondary@test.com", + name: "Secondary Attendee", + timeZone: "America/New_York", + locale: "en", + }, + ], + }); + bookingId = booking.id; + createdBookingIds.push(booking.id); + + const attendees = await prisma.attendee.findMany({ + where: { bookingId: booking.id }, + orderBy: { id: "asc" }, + select: { id: true, email: true }, + }); + primaryAttendeeId = attendees[0].id; + secondaryAttendeeId = attendees[1].id; + + await prisma.booking.update({ + where: { id: booking.id }, + data: { + responses: { + name: "Primary Attendee", + email: "primary@test.com", + guests: ["secondary@test.com"], + }, + }, + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(async () => { + if (createdBookingIds.length > 0) { + await prisma.attendee.deleteMany({ where: { bookingId: { in: createdBookingIds } } }); + await prisma.booking.deleteMany({ where: { id: { in: createdBookingIds } } }); + } + if (createdEventTypeIds.length > 0) { + await prisma.eventType.deleteMany({ where: { id: { in: createdEventTypeIds } } }); + } + if (createdUserIds.length > 0) { + await prisma.availability.deleteMany({ where: { Schedule: { userId: { in: createdUserIds } } } }); + await prisma.schedule.deleteMany({ where: { userId: { in: createdUserIds } } }); + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + } + }); + + it("should remove a secondary attendee, update DB, and emit audit event", async () => { + const result = await service.removeAttendee({ + bookingId, + attendeeId: secondaryAttendeeId, + user: testUser, + emailsEnabled: true, + actionSource: "API_V2", + }); + + expect(result).toEqual({ + id: secondaryAttendeeId, + bookingId, + name: "Secondary Attendee", + email: "secondary@test.com", + timeZone: "America/New_York", + }); + + const attendee = await prisma.attendee.findUnique({ + where: { id: secondaryAttendeeId }, + }); + expect(attendee).toBeNull(); + + const updatedBooking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { responses: true }, + }); + const responses = updatedBooking?.responses as { guests?: string[] } | null; + expect(responses?.guests ?? []).not.toContain("secondary@test.com"); + + expect(mockOnAttendeeRemoved).toHaveBeenCalledWith( + expect.objectContaining({ + bookingUid: `attendee-svc-booking-${timestamp}`, + auditData: expect.objectContaining({ + attendees: expect.objectContaining({ + old: expect.arrayContaining(["primary@test.com", "secondary@test.com"]), + new: expect.arrayContaining(["primary@test.com"]), + }), + }), + }) + ); + }); + + describe("primary attendee guard", () => { + it("should throw when trying to remove the primary attendee", async () => { + await expect( + service.removeAttendee({ + bookingId, + attendeeId: primaryAttendeeId, + user: testUser, + emailsEnabled: true, + actionSource: "API_V2", + }) + ).rejects.toThrow("cannot_remove_primary_attendee"); + }); + }); + + describe("non-existent attendee", () => { + it("should throw when attendee does not exist", async () => { + await expect( + service.removeAttendee({ + bookingId, + attendeeId: 999999, + user: testUser, + emailsEnabled: true, + actionSource: "API_V2", + }) + ).rejects.toThrow("attendee_not_found"); + }); + }); + + describe("emails disabled", () => { + let emailsDisabledBookingId: number; + let emailsDisabledAttendeeId: number; + + beforeAll(async () => { + const booking = await bookingRepo.createBookingForManagedEventReassignment({ + uid: `attendee-svc-no-email-${timestamp}`, + userId: organizerId, + userPrimaryEmail: testUser.email, + eventTypeId: createdEventTypeIds[0], + title: `No Email Test Booking ${timestamp}`, + description: null, + startTime: new Date("2030-06-02T10:00:00.000Z"), + endTime: new Date("2030-06-02T10:30:00.000Z"), + status: BookingStatus.ACCEPTED, + location: null, + smsReminderNumber: null, + idempotencyKey: `attendee-svc-no-email-idempotency-${timestamp}`, + iCalUID: `attendee-svc-no-email-ical-${timestamp}@test.com`, + iCalSequence: 0, + attendees: [ + { email: "primary-noemail@test.com", name: "Primary", timeZone: "UTC", locale: "en" }, + { email: "secondary-noemail@test.com", name: "Secondary", timeZone: "UTC", locale: "en" }, + ], + }); + emailsDisabledBookingId = booking.id; + createdBookingIds.push(booking.id); + + await prisma.booking.update({ + where: { id: booking.id }, + data: { + responses: { + name: "Primary", + email: "primary-noemail@test.com", + guests: ["secondary-noemail@test.com"], + }, + }, + }); + + const attendees = await prisma.attendee.findMany({ + where: { bookingId: booking.id }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + emailsDisabledAttendeeId = attendees[1].id; + }); + + it("should not send cancellation email when emailsEnabled is false", async () => { + await service.removeAttendee({ + bookingId: emailsDisabledBookingId, + attendeeId: emailsDisabledAttendeeId, + user: testUser, + emailsEnabled: false, + actionSource: "API_V2", + }); + + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + }); + + describe("responses.guests cleanup with plus-addressed email", () => { + let plusAddressBookingId: number; + let plusAddressAttendeeId: number; + + beforeAll(async () => { + const booking = await bookingRepo.createBookingForManagedEventReassignment({ + uid: `attendee-svc-plus-addr-${timestamp}`, + userId: organizerId, + userPrimaryEmail: testUser.email, + eventTypeId: createdEventTypeIds[0], + title: `Plus Address Test Booking ${timestamp}`, + description: null, + startTime: new Date("2030-06-03T10:00:00.000Z"), + endTime: new Date("2030-06-03T10:30:00.000Z"), + status: BookingStatus.ACCEPTED, + location: null, + smsReminderNumber: null, + idempotencyKey: `attendee-svc-plus-addr-idempotency-${timestamp}`, + iCalUID: `attendee-svc-plus-addr-ical-${timestamp}@test.com`, + iCalSequence: 0, + attendees: [ + { email: "primary-plus@test.com", name: "Primary", timeZone: "UTC", locale: "en" }, + { email: "guest+abc123@test.com", name: "Plus Guest", timeZone: "UTC", locale: "en" }, + ], + }); + plusAddressBookingId = booking.id; + createdBookingIds.push(booking.id); + + await prisma.booking.update({ + where: { id: booking.id }, + data: { + responses: { + name: "Primary", + email: "primary-plus@test.com", + guests: ["guest+abc123@test.com"], + }, + }, + }); + + const attendees = await prisma.attendee.findMany({ + where: { bookingId: booking.id }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + plusAddressAttendeeId = attendees[1].id; + }); + + it("should remove plus-addressed email from responses.guests using extractBaseEmail", async () => { + await service.removeAttendee({ + bookingId: plusAddressBookingId, + attendeeId: plusAddressAttendeeId, + user: testUser, + emailsEnabled: false, + actionSource: "API_V2", + }); + + const updatedBooking = await prisma.booking.findUnique({ + where: { id: plusAddressBookingId }, + select: { responses: true }, + }); + const responses = updatedBooking?.responses as { guests?: string[] } | null; + expect(responses?.guests ?? []).toEqual([]); + }); + }); +}); diff --git a/packages/features/bookings/services/BookingAttendeesRemoveService.ts b/packages/features/bookings/services/BookingAttendeesRemoveService.ts new file mode 100644 index 0000000000..5ff844f5a5 --- /dev/null +++ b/packages/features/bookings/services/BookingAttendeesRemoveService.ts @@ -0,0 +1,170 @@ +import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-email"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import type { BookingAuditContext } from "@calcom/features/booking-audit/lib/dto/types"; +import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; +import type { PrismaBookingAttendeeRepository } from "@calcom/features/bookings/repositories/PrismaBookingAttendeeRepository"; +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { getTranslation } from "@calcom/i18n/server"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { extractBaseEmail } from "@calcom/lib/extract-base-email"; +import logger from "@calcom/lib/logger"; +import type { BookingResponses } from "@calcom/prisma/zod-utils"; +import type { Booking, TUser } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; +import { + buildCalendarEvent, + getBooking, + getOrganizerData, + prepareAttendeesList, + updateCalendarEvent, + validateUserPermissions, +} from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +type RemoveAttendeeInput = { + bookingId: number; + attendeeId: number; + user: TUser; + emailsEnabled?: boolean; + actionSource: ActionSource; + impersonatedByUserUuid?: string | null; +}; + +export type RemovedAttendee = { + id: number; + bookingId: number; + name: string; + email: string; + timeZone: string; +}; + +export type BookingAttendeesRemoveServiceDeps = { + bookingEventHandlerService: BookingEventHandlerService; + featuresRepository: FeaturesRepository; + bookingAttendeeRepository: PrismaBookingAttendeeRepository; +}; + +export class BookingAttendeesRemoveService { + constructor(private readonly deps: BookingAttendeesRemoveServiceDeps) {} + + async removeAttendee({ + bookingId, + attendeeId, + user, + emailsEnabled = true, + actionSource, + impersonatedByUserUuid, + }: RemoveAttendeeInput): Promise { + const booking = await getBooking(bookingId); + await validateUserPermissions(booking, user); + + const attendeeToRemove = this.findAndValidateAttendee(booking, attendeeId); + const remainingAttendees = booking.attendees.filter((a) => a.id !== attendeeId); + const attendeesList = await prepareAttendeesList(remainingAttendees); + await this.removeAttendeeFromBooking(bookingId, attendeeId, attendeeToRemove.email, booking); + + const organizer = await getOrganizerData(booking.userId); + + const evt = await buildCalendarEvent(booking, organizer, attendeesList); + await updateCalendarEvent(booking, evt); + + if (emailsEnabled) { + this.sendCancellationEmail(attendeeToRemove, evt); + } + + const organizationId = user.organizationId ?? null; + const isBookingAuditEnabled = organizationId + ? await this.deps.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit") + : false; + + const auditContext: BookingAuditContext = { + impersonatedBy: impersonatedByUserUuid ?? undefined, + }; + + await this.deps.bookingEventHandlerService.onAttendeeRemoved({ + bookingUid: booking.uid, + actor: makeUserActor(user.uuid), + organizationId, + source: actionSource, + auditData: { + attendees: { + old: booking.attendees.map((a) => a.email), + new: remainingAttendees.map((a) => a.email), + }, + }, + context: auditContext, + isBookingAuditEnabled, + }); + + return { + id: attendeeToRemove.id, + bookingId: booking.id, + name: attendeeToRemove.name, + email: attendeeToRemove.email, + timeZone: attendeeToRemove.timeZone, + }; + } + + private findAndValidateAttendee(booking: Booking, attendeeId: number): Booking["attendees"][number] { + const attendee = booking.attendees.find((a) => a.id === attendeeId); + + if (!attendee) { + throw ErrorWithCode.Factory.NotFound("attendee_not_found"); + } + + const sortedAttendees = [...booking.attendees].sort((a, b) => a.id - b.id); + const primaryAttendee = sortedAttendees[0]; + + if (primaryAttendee && attendee.id === primaryAttendee.id) { + throw ErrorWithCode.Factory.BadRequest("cannot_remove_primary_attendee"); + } + + return attendee; + } + + private async removeAttendeeFromBooking( + bookingId: number, + attendeeId: number, + attendeeEmail: string, + booking: Booking + ): Promise { + const bookingResponses = booking.responses as BookingResponses; + const baseEmailToRemove = extractBaseEmail(attendeeEmail).toLowerCase(); + + const updatedGuests = (bookingResponses?.guests || []).filter((guestEmail: string) => { + return extractBaseEmail(guestEmail).toLowerCase() !== baseEmailToRemove; + }); + + await this.deps.bookingAttendeeRepository.deleteByIdAndUpdateBookingResponses(attendeeId, bookingId, { + ...bookingResponses, + guests: updatedGuests, + }); + } + + private async prepareAttendeePerson(attendee: Booking["attendees"][number]): Promise { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + } + + private async sendCancellationEmail( + attendeeToRemove: Booking["attendees"][number], + evt: CalendarEvent + ): Promise { + try { + const removedAttendeePerson = await this.prepareAttendeePerson(attendeeToRemove); + const email = new AttendeeCancelledEmail(evt, removedAttendeePerson); + await email.sendEmail(); + } catch (error) { + logger.error("Failed to send cancellation email to removed attendee", { + errorName: error instanceof Error ? error.name : "unknown_error", + }); + } + } +} diff --git a/packages/features/bookings/services/BookingAttendeesService.ts b/packages/features/bookings/services/BookingAttendeesService.ts index e00b9266ae..dc115e146b 100644 --- a/packages/features/bookings/services/BookingAttendeesService.ts +++ b/packages/features/bookings/services/BookingAttendeesService.ts @@ -1,18 +1,15 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; -import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler"; -import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; -import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; +import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import type { BookingAttendeesRemoveService } from "@calcom/features/bookings/services/BookingAttendeesRemoveService"; +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import { prisma } from "@calcom/prisma"; -import type { - Booking, - TUser, -} from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; +import type { Booking, TUser } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; import { buildCalendarEvent, getBooking, @@ -47,13 +44,28 @@ export type CreatedAttendee = { phoneNumber: string | null; }; +type RemoveAttendeeInput = { + bookingId: number; + attendeeId: number; + user: TUser; + emailsEnabled?: boolean; + actionSource: ActionSource; +}; + +export type BookingAttendeesServiceDeps = { + bookingEventHandlerService: BookingEventHandlerService; + featuresRepository: FeaturesRepository; + bookingRepository: BookingRepository; + bookingAttendeesRemoveService: BookingAttendeesRemoveService; +}; + export class BookingAttendeesService { + constructor(private readonly deps: BookingAttendeesServiceDeps) {} + async getBookingAttendees(bookingUid: string) { - const bookingRepository = new BookingRepository(prisma); - const booking = - await bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ - bookingUid, - }); + const booking = await this.deps.bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid, + }); if (!booking) { throw new Error(`Booking with uid ${bookingUid} not found`); @@ -63,11 +75,9 @@ export class BookingAttendeesService { } async getBookingAttendee(bookingUid: string, attendeeId: number) { - const bookingRepository = new BookingRepository(prisma); - const booking = - await bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ - bookingUid, - }); + const booking = await this.deps.bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid, + }); if (!booking) { throw new Error(`Booking with uid ${bookingUid} not found`); @@ -99,10 +109,7 @@ export class BookingAttendeesService { const organizer = await getOrganizerData(booking.userId); - const validatedAttendees = await sanitizeAndFilterGuests( - [attendee], - booking - ); + const validatedAttendees = await sanitizeAndFilterGuests([attendee], booking); const newAttendeeDetails = validatedAttendees.map((a) => ({ name: a.name || "", @@ -131,17 +138,12 @@ export class BookingAttendeesService { await this.sendAttendeeNotification(evt, booking, attendeeEmail); } - const bookingEventHandlerService = getBookingEventHandlerService(); - const featuresRepository = getFeaturesRepository(); const organizationId = user.organizationId ?? null; const isBookingAuditEnabled = organizationId - ? await featuresRepository.checkIfTeamHasFeature( - organizationId, - "booking-audit" - ) + ? await this.deps.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit") : false; - await bookingEventHandlerService.onAttendeeAdded({ + await this.deps.bookingEventHandlerService.onAttendeeAdded({ bookingUid: booking.uid, actor: makeUserActor(user.uuid), organizationId, @@ -171,6 +173,10 @@ export class BookingAttendeesService { }; } + async removeAttendee(input: RemoveAttendeeInput) { + return this.deps.bookingAttendeesRemoveService.removeAttendee(input); + } + private async sendAttendeeNotification( evt: CalendarEvent, booking: Booking, @@ -183,12 +189,12 @@ export class BookingAttendeesService { await emailsAndSmsHandler.handleAddAttendee({ evt, eventType: { - metadata: eventTypeMetaDataSchemaWithTypedApps.parse( - booking?.eventType?.metadata - ), + metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata), schedulingType: booking.eventType?.schedulingType || null, }, newGuests: [attendeeEmail], }); } } + +export type { RemovedAttendee } from "@calcom/features/bookings/services/BookingAttendeesRemoveService"; diff --git a/packages/features/users/di/Profile.module.ts b/packages/features/users/di/Profile.module.ts index 702baca636..139e4f91a0 100644 --- a/packages/features/users/di/Profile.module.ts +++ b/packages/features/users/di/Profile.module.ts @@ -1,7 +1,7 @@ import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; -import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; export const profileRepositoryModule = createModule(); const token = DI_TOKENS.PROFILE_REPOSITORY; diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts index d58800dd71..f28e09fa98 100644 --- a/packages/platform/libraries/bookings.ts +++ b/packages/platform/libraries/bookings.ts @@ -1,6 +1,7 @@ export { getAuditActorRepository } from "@calcom/features/booking-audit/di/AuditActorRepository.container"; export { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; export { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService"; +export { getBookingAttendeesService } from "@calcom/features/bookings/di/BookingAttendeesService.container"; export { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler"; export { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; export type { @@ -18,6 +19,7 @@ export { BookingEmailAndSmsSyncTasker } from "@calcom/features/bookings/lib/task export { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; export { BookingEmailAndSmsTaskService } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTaskService"; export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTriggerTasker"; +export { BookingAttendeesRemoveService } from "@calcom/features/bookings/services/BookingAttendeesRemoveService"; export { BookingAttendeesService } from "@calcom/features/bookings/services/BookingAttendeesService"; export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook"; export { PrismaOrgMembershipRepository } from "@calcom/features/membership/repositories/PrismaOrgMembershipRepository";