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:
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,
|
||||
|
||||
+42
-27
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+374
@@ -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,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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user