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 <apk@cognition.ai>

* style: apply biome formatting to conflict-resolved files

Co-Authored-By: bot_apk <apk@cognition.ai>

* 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 <apk@cognition.ai>
This commit is contained in:
authored and GitHub committed 2026-03-18 13:25:53 +02:00
1 parent 72acf09ff2
commit 3c57960b7d
23 files changed
+1469 -94

No files matched your search

@@ -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,
@@ -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<GetBookingAttendeesOutput_2024_08_13> {
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<GetBookingAttendeeOutput_2024_08_13> {
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<AddAttendeeOutput_2024_08_13> {
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.
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
`,
})
async removeAttendee(
@Param("bookingUid") bookingUid: string,
@Param("attendeeId", ParseIntPipe) attendeeId: number,
@GetUser() user: ApiAuthGuardUser
): Promise<RemoveAttendeeOutput_2024_08_13> {
const removedAttendee = await this.bookingAttendeesService.removeAttendee(bookingUid, attendeeId, user);
return {
status: SUCCESS_STATUS,
data: removedAttendee,
};
}
}
@@ -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("<p>email</p>"));
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>(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<number> {
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;
}
});
@@ -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;
}
@@ -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<BookingAttendeeWithId_2024_08_13[]> {
const attendees = await this.bookingAttendeesService.getBookingAttendees(
bookingUid
);
async getBookingAttendees(bookingUid: string): Promise<BookingAttendeeWithId_2024_08_13[]> {
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<BookingAttendeeWithId_2024_08_13> {
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<BookingAttendeeOutput_2024_08_13> {
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<RemovedAttendeeOutput_2024_08_13> {
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}/, "");
}
@@ -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 {}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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" },
});
}
}
@@ -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;
@@ -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<BookingAttendeesRemoveService>(
bookingAttendeesRemoveServiceModule.token
);
}
@@ -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 };
@@ -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<BookingAttendeesService>(bookingAttendeesServiceModule.token);
}
@@ -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 };
@@ -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;
+4
View File
@@ -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"),
};
@@ -1,3 +1,10 @@
import type { Prisma } from "@calcom/prisma/client";
export interface IBookingAttendeeRepository {
deleteManyByBookingId(bookingId: number): Promise<void>;
deleteByIdAndUpdateBookingResponses(
attendeeId: number,
bookingId: number,
updatedResponses: Prisma.InputJsonValue
): Promise<void>;
}
@@ -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<void> {
await this.prismaClient.$transaction([
this.prismaClient.attendee.delete({
where: { id: attendeeId },
}),
this.prismaClient.booking.update({
where: { id: bookingId },
data: {
responses: updatedResponses,
},
}),
]);
}
}
@@ -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<string, unknown>;
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([]);
});
});
});
@@ -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<RemovedAttendee> {
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<void> {
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<Person> {
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<void> {
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",
});
}
}
}
@@ -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";
+1 -1
View File
@@ -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;
+2
View File
@@ -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";