diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
index 3359fa8d0c..15ea4dd09e 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
@@ -1,6 +1,8 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
+import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
@@ -15,6 +17,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
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 { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
@@ -71,9 +74,11 @@ import { Module } from "@nestjs/common";
OAuthClientRepository,
OAuthClientUsersService,
BookingsService_2024_08_13,
+ BookingGuestsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
OutputBookingReferencesService_2024_08_13,
+ OutputEventTypesService_2024_06_14,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
@@ -92,7 +97,7 @@ import { Module } from "@nestjs/common";
BookingReferencesRepository_2024_08_13,
CalVideoService,
],
- controllers: [BookingsController_2024_08_13],
+ controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
})
export class BookingsModule_2024_08_13 {}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-guests.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-guests.controller.ts
new file mode 100644
index 0000000000..3bb01bc87d
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-guests.controller.ts
@@ -0,0 +1,67 @@
+import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
+import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
+import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
+import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
+import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
+import { Controller, Post, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
+import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
+
+import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
+import { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/bookings/:bookingUid/guests",
+ version: VERSION_2024_08_13_VALUE,
+})
+@UseGuards(PermissionsGuard)
+@DocsTags("Bookings / Guests")
+@ApiHeader({
+ name: "cal-api-version",
+ description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
+ example: VERSION_2024_08_13,
+ required: true,
+})
+export class BookingGuestsController_2024_08_13 {
+ private readonly logger = new Logger("BookingGuestsController_2024_08_13");
+
+ constructor(private readonly bookingGuestsService: BookingGuestsService_2024_08_13) {}
+
+ @Post("/")
+ @HttpCode(HttpStatus.OK)
+ @Permissions([BOOKING_WRITE])
+ @UseGuards(ApiAuthGuard, BookingUidGuard)
+ @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
+ @ApiOperation({
+ summary: "Add guests to an existing booking",
+ description: `Add one or more guests to an existing booking.
+
+ **Email Notifications:**
+ When guests are added, the following notifications are sent (unless disabled by event type settings):
+
+ - **Organizer & Team Members:** Receive an "Add Guests" notification email informing them that new guests have been added to the booking.
+
+ - **New Guests:** Receive a "Scheduled Event" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.
+
+ - **Existing Guests:** Receive an "Add Guests" notification email informing them that additional guests have been added to the booking.
+
+ The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.
+ `,
+ })
+ async addGuests(
+ @Param("bookingUid") bookingUid: string,
+ @Body() body: AddGuestsInput_2024_08_13,
+ @GetUser() user: ApiAuthGuardUser
+ ): Promise {
+ const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: booking,
+ };
+ }
+}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/add-guests.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/add-guests.e2e-spec.ts
new file mode 100644
index 0000000000..829597612e
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/add-guests.e2e-spec.ts
@@ -0,0 +1,523 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+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 { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { UsersModule } from "@/modules/users/users.module";
+import { INestApplication } from "@nestjs/common";
+import { NestExpressApplication } from "@nestjs/platform-express";
+import { Test } from "@nestjs/testing";
+import * as request from "supertest";
+import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
+import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
+import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { randomString } from "test/utils/randomString";
+
+import {
+ CAL_API_VERSION_HEADER,
+ ERROR_STATUS,
+ SUCCESS_STATUS,
+ VERSION_2024_08_13,
+} from "@calcom/platform-constants";
+import {
+ AttendeeAddGuestsEmail,
+ AttendeeScheduledEmail,
+ OrganizerAddGuestsEmail,
+} from "@calcom/platform-libraries/emails";
+import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
+import type { User, Team } from "@calcom/prisma/client";
+
+const attendeeAddGuestsEmailSpy = jest
+ .spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
+ .mockImplementation(() => Promise.resolve("email
"));
+const organizerAddGuestsEmailSpy = jest
+ .spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
+ .mockImplementation(() => Promise.resolve("email
"));
+const attendeeScheduledEmailSpy = jest
+ .spyOn(AttendeeScheduledEmail.prototype, "getHtml")
+ .mockImplementation(() => Promise.resolve("email
"));
+
+type TestUser = {
+ user: User;
+ accessToken: string;
+ refreshToken: string;
+};
+
+type TestSetup = {
+ organizer: TestUser;
+ unrelatedUser: TestUser;
+ guest: TestUser;
+ eventTypeId: number;
+ bookingUid: string;
+};
+
+describe("Bookings Endpoints 2024-08-13 add guests", () => {
+ 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 () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
+ })
+ .overrideGuard(PermissionsGuard)
+ .useValue({
+ canActivate: () => true,
+ })
+ .compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
+ eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
+ oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
+ teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ schedulesService = moduleRef.get(SchedulesService_2024_04_15);
+ tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
+
+ organization = await teamRepositoryFixture.create({
+ name: `user-emails-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: `user-emails-2024-08-13-organizer-${randomString()}@api.com`,
+ platformOAuthClients: {
+ connect: { id: oAuthClient.id },
+ },
+ });
+
+ const unrelatedUserData = await userRepositoryFixture.create({
+ email: `user-emails-2024-08-13-unrelated-${randomString()}@api.com`,
+ platformOAuthClients: {
+ connect: { id: oAuthClient.id },
+ },
+ });
+
+ const guestUser = await userRepositoryFixture.create({
+ email: `user-emails-2024-08-13-guest-${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 guestTokens = await tokensRepositoryFixture.createTokens(guestUser.id, oAuthClient.id);
+
+ const schedule: CreateScheduleInput_2024_04_15 = {
+ name: `user-add-guests-2024-08-13-schedule-${randomString()}`,
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+ await schedulesService.createUserSchedule(organizerUser.id, schedule);
+
+ const eventType = await eventTypesRepositoryFixture.create(
+ {
+ title: `user-add-guests-2024-08-13-event-type-${randomString()}`,
+ slug: `user-add-guests-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,
+ },
+ guest: {
+ user: guestUser,
+ accessToken: guestTokens.accessToken,
+ refreshToken: guestTokens.refreshToken,
+ },
+ eventTypeId: eventType.id,
+ bookingUid: "",
+ };
+ }
+
+ 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 () => {
+ attendeeAddGuestsEmailSpy.mockClear();
+ organizerAddGuestsEmailSpy.mockClear();
+ attendeeScheduledEmailSpy.mockClear();
+ });
+
+ describe("POST /v2/bookings/:bookingUid/guests", () => {
+ beforeAll(async () => {
+ const createBookingBody: CreateBookingInput_2024_08_13 = {
+ start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
+ eventTypeId: testSetup.eventTypeId,
+ attendee: {
+ name: "Mr Proper",
+ email: "mr_proper@gmail.com",
+ timeZone: "Europe/Rome",
+ language: "it",
+ },
+ 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;
+ });
+
+ describe("Authentication", () => {
+ it("should return 401 when adding guests without authentication", async () => {
+ const addGuestsBody = {
+ guests: [{ email: "unauthenticated.guest@example.com" }],
+ };
+
+ const addGuestsResponse = await request(app.getHttpServer())
+ .post(`/v2/bookings/${testSetup.bookingUid}/guests`)
+ .send(addGuestsBody)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
+
+ expect(addGuestsResponse.status).toBe(401);
+ });
+ });
+
+ describe("Authorization - Organizer", () => {
+ it("should allow booking organizer to add multiple guests", async () => {
+ const addGuestsBody = {
+ guests: [{ email: "organizer.guest1@example.com" }, { email: "organizer.guest2@example.com" }],
+ };
+
+ 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;
+
+ verifyAddGuestsResponse(
+ addGuestsResponseBody,
+ ["organizer.guest1@example.com", "organizer.guest2@example.com"],
+ true
+ );
+ });
+
+ it("should return 403 when unrelated user tries to add guests", async () => {
+ const addGuestsBody = {
+ guests: [
+ { email: "non-organizer.guest1@example.com" },
+ { email: "non-organizer.guest2@example.com" },
+ ],
+ };
+
+ 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.unrelatedUser.accessToken}`)
+ .expect(403);
+ });
+ });
+
+ describe("Authorization - guest", () => {
+ it("should allow organizer to add a user as guest, then guest can add more guests", async () => {
+ // Step 1: Organizer adds guest user as a guest
+ const addguestBody = {
+ guests: [{ email: testSetup.guest.user.email }],
+ };
+
+ const addguestResponse = await request(app.getHttpServer())
+ .post(`/v2/bookings/${testSetup.bookingUid}/guests`)
+ .send(addguestBody)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
+ .expect(200);
+
+ const addguestResponseBody: AddGuestsOutput_2024_08_13 = addguestResponse.body;
+
+ verifyAddGuestsResponse(addguestResponseBody, [testSetup.guest.user.email], true);
+
+ // Step 2: Now the guest can add their own guests
+ const addGuestsBody = {
+ guests: [{ email: "guest.guest1@example.com" }],
+ };
+
+ 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.guest.accessToken}`)
+ .expect(200);
+
+ const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
+
+ verifyAddGuestsResponse(addGuestsResponseBody, ["guest.guest1@example.com"], true);
+ });
+
+ it("should return 403 when non-guest user tries to add guests", async () => {
+ const addGuestsBody = {
+ guests: [{ email: "non-guest.guest1@example.com" }, { email: "non-guest.guest2@example.com" }],
+ };
+
+ 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.unrelatedUser.accessToken}`)
+ .expect(403);
+ });
+ });
+
+ describe("Validation", () => {
+ it("should return 400 when attempting to add duplicate guest email", async () => {
+ const addGuestsBody = {
+ guests: [{ email: "organizer.guest1@example.com" }],
+ };
+
+ 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(addGuestsResponse.status).toEqual(400);
+ expect(addGuestsResponse.body.status).toEqual(ERROR_STATUS);
+ });
+ });
+ });
+
+ describe("POST /v2/bookings/:bookingUid/guests - Emails Disabled", () => {
+ let emailsDisabledSetup: TestSetup;
+
+ beforeAll(async () => {
+ const oAuthClientDisabled = await createOAuthClient(organization.id, false);
+
+ const organizerUser = await userRepositoryFixture.create({
+ email: `user-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: `user-no-emails-2024-08-13-schedule-${randomString()}`,
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+ await schedulesService.createUserSchedule(organizerUser.id, schedule);
+
+ const eventType = await eventTypesRepositoryFixture.create(
+ {
+ title: `user-no-emails-2024-08-13-event-type-${randomString()}`,
+ slug: `user-no-emails-2024-08-13-event-type-${randomString()}`,
+ length: 60,
+ },
+ organizerUser.id
+ );
+
+ const createBookingBody: CreateBookingInput_2024_08_13 = {
+ start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
+ eventTypeId: eventType.id,
+ attendee: {
+ name: "No Email User",
+ email: "no.email.user@gmail.com",
+ timeZone: "Europe/Rome",
+ language: "it",
+ },
+ 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"
+ );
+ }
+
+ emailsDisabledSetup = {
+ organizer: {
+ user: organizerUser,
+ accessToken: organizerTokens.accessToken,
+ refreshToken: organizerTokens.refreshToken,
+ },
+ unrelatedUser: testSetup.unrelatedUser,
+ guest: testSetup.guest,
+ eventTypeId: eventType.id,
+ bookingUid: createBookingResponseBody.data.uid,
+ };
+ });
+
+ it("should NOT send emails when adding guests (emails disabled)", async () => {
+ attendeeAddGuestsEmailSpy.mockClear();
+ organizerAddGuestsEmailSpy.mockClear();
+ attendeeScheduledEmailSpy.mockClear();
+
+ const addGuestsBody = {
+ guests: [{ email: "no-email.guest1@example.com" }, { email: "no-email.guest2@example.com" }],
+ };
+
+ const addGuestsResponse = await request(app.getHttpServer())
+ .post(`/v2/bookings/${emailsDisabledSetup.bookingUid}/guests`)
+ .send(addGuestsBody)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`)
+ .expect(200);
+
+ const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
+
+ verifyAddGuestsResponse(
+ addGuestsResponseBody,
+ ["no-email.guest1@example.com", "no-email.guest2@example.com"],
+ false
+ );
+ });
+
+ 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 userRepositoryFixture.deleteByEmail(testSetup.guest.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;
+ }
+
+ function verifyAddGuestsResponse(
+ responseBody: AddGuestsOutput_2024_08_13,
+ expectedGuestEmails: string[],
+ shouldEmailsBeSent: boolean
+ ): BookingOutput_2024_08_13 {
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ if (!responseDataIsBooking(responseBody.data)) {
+ throw new Error(
+ "Invalid response data - expected booking but received array of possibly recurring bookings"
+ );
+ }
+
+ const bookingData = responseBody.data;
+
+ expect(bookingData.guests).toBeDefined();
+ const guestEmails = bookingData.guests;
+
+ expectedGuestEmails.forEach((email) => {
+ expect(guestEmails).toContain(email);
+ });
+
+ expect(bookingData.guests).toBeDefined();
+ expectedGuestEmails.forEach((email) => {
+ expect(bookingData.guests).toContain(email);
+ });
+
+ if (shouldEmailsBeSent) {
+ expect(attendeeAddGuestsEmailSpy).toHaveBeenCalled();
+ expect(organizerAddGuestsEmailSpy).toHaveBeenCalled();
+ expect(attendeeScheduledEmailSpy).toHaveBeenCalled();
+ } else {
+ expect(attendeeAddGuestsEmailSpy).not.toHaveBeenCalled();
+ expect(organizerAddGuestsEmailSpy).not.toHaveBeenCalled();
+ expect(attendeeScheduledEmailSpy).not.toHaveBeenCalled();
+ }
+
+ return bookingData;
+ }
+});
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/add-guests.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/add-guests.output.ts
new file mode 100644
index 0000000000..f8d8a26ac8
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/add-guests.output.ts
@@ -0,0 +1,3 @@
+import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
+
+export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/booking-references.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/booking-references.repository.ts
similarity index 100%
rename from apps/api/v2/src/ee/bookings/2024-08-13/booking-references.repository.ts
rename to apps/api/v2/src/ee/bookings/2024-08-13/repositories/booking-references.repository.ts
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts
similarity index 100%
rename from apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts
rename to apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts
new file mode 100644
index 0000000000..a89773441b
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts
@@ -0,0 +1,46 @@
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
+import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
+import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
+import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
+import { Injectable, Logger, HttpException, NotFoundException } from "@nestjs/common";
+
+import { addGuestsHandler } from "@calcom/platform-libraries/bookings";
+import type { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
+
+@Injectable()
+export class BookingGuestsService_2024_08_13 {
+ private readonly logger = new Logger("BookingGuestsService_2024_08_13");
+
+ constructor(
+ private readonly bookingsRepository: BookingsRepository_2024_08_13,
+ private readonly bookingsService: BookingsService_2024_08_13,
+ private readonly platformBookingsService: PlatformBookingsService
+ ) {}
+
+ async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) {
+ 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;
+
+ const res = await addGuestsHandler({
+ ctx: { user },
+ input: { bookingId: booking.id, guests: input.guests },
+ emailsEnabled,
+ });
+ if (res.message === "Guests added") {
+ return await this.bookingsService.getBooking(bookingUid, user);
+ } else {
+ throw new HttpException(
+ "Failed to add guests to the booking",
+ 500
+ );
+ }
+ }
+}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-references.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-references.service.ts
index c1440505a6..aad7f2b5fa 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-references.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-references.service.ts
@@ -1,5 +1,5 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts
index b9d94bc8f0..de6660d4ef 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts
@@ -1,4 +1,4 @@
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
index 531113296d..69810c2a72 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
@@ -1,4 +1,4 @@
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
index b2a931e08c..c308529f25 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
@@ -1,4 +1,4 @@
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import {
eventTypeBookingFieldsSchema,
EventTypeWithOwnerAndTeam,
@@ -554,7 +554,7 @@ export class InputBookingsService_2024_08_13 {
}
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
- return body.hasOwnProperty("seatUid");
+ return Object.prototype.hasOwnProperty.call(body, "seatUid");
}
async transformInputRescheduleSeatedBooking(
@@ -770,7 +770,7 @@ export class InputBookingsService_2024_08_13 {
}
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
- return body.hasOwnProperty("seatUid");
+ return Object.prototype.hasOwnProperty.call(body, "seatUid");
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts
index 3b0ad2da0e..4e929d9f9e 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts
@@ -1,4 +1,4 @@
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import {
defaultBookingMetadata,
defaultBookingResponses,
diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts
index 4d467a4f46..33dddedd53 100644
--- a/apps/api/v2/src/ee/calendars/calendars.module.ts
+++ b/apps/api/v2/src/ee/calendars/calendars.module.ts
@@ -1,5 +1,5 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts
index 4f394fd8a7..3750f2486f 100644
--- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts
+++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts
@@ -286,10 +286,9 @@ export class EventTypesAtomService {
.filter(({ ...app }) => app.slug === slug)
.map(
async ({
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
credentials: _,
credential,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
+
key: _2 /* don't leak to frontend */,
...app
}: EnabledAppType) => {
@@ -368,7 +367,7 @@ export class EventTypesAtomService {
endTime: endTime.toString(),
};
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
- if (eventType.users.length === 0 && !!!eventType.team)
+ if (eventType.users.length === 0 && !eventType.team)
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
const [user] = eventType?.users.length
? eventType.users
diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts
index 8dc8c2213c..7752233c7c 100644
--- a/apps/api/v2/src/modules/billing/billing.module.ts
+++ b/apps/api/v2/src/modules/billing/billing.module.ts
@@ -1,4 +1,4 @@
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts
index 0d5ffcf316..9ca6f74197 100644
--- a/apps/api/v2/src/modules/billing/services/billing.service.ts
+++ b/apps/api/v2/src/modules/billing/services/billing.service.ts
@@ -1,5 +1,5 @@
import { AppConfig } from "@/config/type";
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
diff --git a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts
index e9cebf0fcc..669b2168b1 100644
--- a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts
+++ b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts
@@ -1,4 +1,4 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
diff --git a/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts
index ecdc196a06..e12354f353 100644
--- a/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts
+++ b/apps/api/v2/src/modules/cal-unified-calendars/services/google-calendar.service.ts
@@ -1,4 +1,4 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { calendar_v3 } from "@googleapis/calendar";
diff --git a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.module.ts b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.module.ts
index 2431b8001b..c2b026df51 100644
--- a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.module.ts
+++ b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.module.ts
@@ -1,6 +1,6 @@
-import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
+import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
-import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
diff --git a/apps/web/components/dialog/AddGuestsDialog.tsx b/apps/web/components/dialog/AddGuestsDialog.tsx
index 57a3441da3..59c3389eb4 100644
--- a/apps/web/components/dialog/AddGuestsDialog.tsx
+++ b/apps/web/components/dialog/AddGuestsDialog.tsx
@@ -47,7 +47,8 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
}
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
if (validationResult.success) {
- addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
+ const guests = multiEmailValue.map((email) => ({ email }));
+ addGuestsMutation.mutate({ bookingId, guests });
} else {
setIsInvalidEmail(true);
}
diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json
index 3cf92c99ce..9436e898cb 100644
--- a/docs/api-reference/v2/openapi.json
+++ b/docs/api-reference/v2/openapi.json
@@ -8998,6 +8998,64 @@
"tags": ["Bookings"]
}
},
+ "/v2/bookings/{bookingUid}/guests": {
+ "post": {
+ "operationId": "BookingGuestsController_2024_08_13_addGuests",
+ "summary": "Add guests to an existing booking",
+ "description": "Add one or more guests to an existing booking.\n \n **Email Notifications:**\n When guests are added, the following notifications are sent (unless disabled by event type settings):\n \n - **Organizer & Team Members:** Receive an \"Add Guests\" notification email informing them that new guests have been added to the booking.\n \n - **New Guests:** Receive a \"Scheduled Event\" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.\n \n - **Existing Guests:** Receive an \"Add Guests\" notification email informing them that additional guests have been added to the booking.\n \n The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.\n ",
+ "parameters": [
+ {
+ "name": "cal-api-version",
+ "in": "header",
+ "description": "Must be set to 2024-08-13. This header is required as this endpoint does not exist in older API versions.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "bookingUid",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "Authorization",
+ "in": "header",
+ "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddGuestsInput_2024_08_13"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddGuestsOutput_2024_08_13"
+ }
+ }
+ }
+ }
+ },
+ "tags": ["Bookings / Guests"]
+ }
+ },
"/v2/calendars/{calendar}/event/{eventUid}": {
"get": {
"operationId": "CalUnifiedCalendarsController_getCalendarEventDetails",
@@ -27285,6 +27343,147 @@
},
"required": ["status", "data"]
},
+ "Guest": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "description": "The email of the guest.",
+ "example": "john.doe@example.com"
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the guest.",
+ "example": "John Doe"
+ },
+ "timeZone": {
+ "type": "string",
+ "description": "The time zone of the guest.",
+ "example": "America/New_York"
+ },
+ "phoneNumber": {
+ "type": "string",
+ "description": "The phone number of the guest in international format.",
+ "example": "+919876543210"
+ },
+ "language": {
+ "type": "string",
+ "enum": [
+ "ar",
+ "ca",
+ "de",
+ "es",
+ "eu",
+ "he",
+ "id",
+ "ja",
+ "lv",
+ "pl",
+ "ro",
+ "sr",
+ "th",
+ "vi",
+ "az",
+ "cs",
+ "el",
+ "es-419",
+ "fi",
+ "hr",
+ "it",
+ "km",
+ "nl",
+ "pt",
+ "ru",
+ "sv",
+ "tr",
+ "zh-CN",
+ "bg",
+ "da",
+ "en",
+ "et",
+ "fr",
+ "hu",
+ "iw",
+ "ko",
+ "no",
+ "pt-BR",
+ "sk",
+ "ta",
+ "uk",
+ "zh-TW",
+ "bn"
+ ],
+ "description": "The preferred language of the guest. Used for booking confirmation.",
+ "example": "it",
+ "default": "en"
+ }
+ },
+ "required": ["email"]
+ },
+ "AddGuestsInput_2024_08_13": {
+ "type": "object",
+ "properties": {
+ "guests": {
+ "description": "Array of guests to add to the booking",
+ "example": [
+ {
+ "email": "john.doe@example.com",
+ "name": "John Doe",
+ "timeZone": "America/New_York"
+ },
+ {
+ "email": "jane.smith@example.com",
+ "name": "Jane Smith"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Guest"
+ }
+ }
+ },
+ "required": ["guests"]
+ },
+ "AddGuestsOutput_2024_08_13": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": ["success", "error"]
+ },
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/BookingOutput_2024_08_13"
+ },
+ {
+ "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
+ }
+ },
+ {
+ "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13"
+ },
+ {
+ "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
+ }
+ }
+ ],
+ "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects"
+ }
+ },
+ "required": ["status", "data"]
+ },
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
diff --git a/packages/app-store/delegationCredential.ts b/packages/app-store/delegationCredential.ts
index 4b984641db..d6bd5dd302 100644
--- a/packages/app-store/delegationCredential.ts
+++ b/packages/app-store/delegationCredential.ts
@@ -653,7 +653,7 @@ export async function getCredentialForCalendarCache({ credentialId }: { credenti
/**
* It includes in-memory DelegationCredential credentials as well.
*/
-export async function getUsersCredentialsIncludeServiceAccountKey(user: User) {
+export async function getUsersCredentialsIncludeServiceAccountKey(user: Pick) {
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts
index 74463692c4..695550de53 100644
--- a/packages/emails/email-manager.ts
+++ b/packages/emails/email-manager.ts
@@ -695,6 +695,48 @@ export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: st
await Promise.all(emailsToSend);
};
+
+export const sendAddGuestsEmailsAndSMS = async (args: {
+ calEvent: CalendarEvent;
+ newGuests: string[];
+ eventTypeMetadata?: EventTypeMetadata;
+}) => {
+ const { calEvent, newGuests, eventTypeMetadata } = args;
+ const calendarEvent = formatCalEvent(calEvent);
+
+ const emailsAndSMSToSend: Promise[] = [];
+
+ if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
+ emailsAndSMSToSend.push(sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent })));
+
+ if (calendarEvent.team?.members) {
+ for (const teamMember of calendarEvent.team.members) {
+ emailsAndSMSToSend.push(
+ sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent, teamMember }))
+ );
+ }
+ }
+ }
+
+ if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
+ const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent);
+
+ for (const attendee of calendarEvent.attendees) {
+ if (newGuests.includes(attendee.email)) {
+ emailsAndSMSToSend.push(sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee)));
+
+ if (attendee.phoneNumber) {
+ emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(attendee));
+ }
+ } else {
+ emailsAndSMSToSend.push(sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee)));
+ }
+ }
+ }
+
+ await Promise.all(emailsAndSMSToSend);
+};
+
export const sendFeedbackEmail = async (feedback: Feedback) => {
await sendEmail(() => new FeedbackEmail(feedback));
};
diff --git a/packages/emails/templates/attendee-add-guests-email.ts b/packages/emails/templates/attendee-add-guests-email.ts
index 211128f074..36b00ea29d 100644
--- a/packages/emails/templates/attendee-add-guests-email.ts
+++ b/packages/emails/templates/attendee-add-guests-email.ts
@@ -5,6 +5,13 @@ import renderEmail from "../src/renderEmail";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
+ async getHtml() {
+ return await renderEmail("AttendeeAddGuestsEmail", {
+ calEvent: this.calEvent,
+ attendee: this.attendee,
+ });
+ }
+
protected async getNodeMailerPayload(): Promise> {
return {
icalEvent: generateIcsFile({
@@ -20,10 +27,7 @@ export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getFormattedDate(),
})}`,
- html: await renderEmail("AttendeeAddGuestsEmail", {
- calEvent: this.calEvent,
- attendee: this.attendee,
- }),
+ html: await this.getHtml(),
text: this.getTextBody("new_guests_added"),
};
}
diff --git a/packages/emails/templates/organizer-add-guests-email.ts b/packages/emails/templates/organizer-add-guests-email.ts
index ba734ffbb2..62b46b081e 100644
--- a/packages/emails/templates/organizer-add-guests-email.ts
+++ b/packages/emails/templates/organizer-add-guests-email.ts
@@ -6,6 +6,13 @@ import renderEmail from "../src/renderEmail";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
+ async getHtml() {
+ return await renderEmail("OrganizerAddGuestsEmail", {
+ attendee: this.calEvent.organizer,
+ calEvent: this.calEvent,
+ });
+ }
+
protected async getNodeMailerPayload(): Promise> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
@@ -27,10 +34,7 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
})}`,
- html: await renderEmail("OrganizerAddGuestsEmail", {
- attendee: this.calEvent.organizer,
- calEvent: this.calEvent,
- }),
+ html: await this.getHtml(),
text: this.getTextBody("new_guests_added"),
};
}
diff --git a/packages/features/bookings/lib/BookingEmailSmsHandler.ts b/packages/features/bookings/lib/BookingEmailSmsHandler.ts
index 0aaa973b95..4c210725b0 100644
--- a/packages/features/bookings/lib/BookingEmailSmsHandler.ts
+++ b/packages/features/bookings/lib/BookingEmailSmsHandler.ts
@@ -15,6 +15,7 @@ import {
sendScheduledEmailsAndSMS,
sendOrganizerRequestEmail,
sendAttendeeRequestEmailAndSMS,
+ sendAddGuestsEmailsAndSMS,
} from "@calcom/emails";
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
@@ -68,6 +69,10 @@ type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
additionalNotes?: string | null;
};
+type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & {
+ newGuests: string[];
+};
+
type RescheduledSideEffectsPayload = {
action: typeof BookingActionMap.rescheduled;
data: RescheduleEmailAndSmsPayload;
@@ -322,4 +327,30 @@ export class BookingEmailSmsHandler {
this.log.error("Failed to send requested event related emails", err);
}
}
+
+ /**
+ * Handles notifications when guests are added to an existing booking.
+ */
+ public async handleAddGuests(data: AddGuestsEmailAndSmsPayload) {
+ const {
+ evt,
+ eventType: { metadata },
+ newGuests,
+ } = data;
+
+ this.log.debug(
+ "Action: ADD_GUESTS. Sending add guests emails and SMS.",
+ safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
+ );
+
+ try {
+ await sendAddGuestsEmailsAndSMS({
+ calEvent: evt,
+ newGuests,
+ eventTypeMetadata: metadata,
+ });
+ } catch (err) {
+ this.log.error("Failed to send add guests related emails and SMS", err);
+ }
+ }
}
diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts
index aad9ba1595..1c98093d2e 100644
--- a/packages/features/bookings/repositories/BookingRepository.ts
+++ b/packages/features/bookings/repositories/BookingRepository.ts
@@ -1172,4 +1172,51 @@ export class BookingRepository {
},
});
}
+
+ async findByIdIncludeDestinationCalendar(bookingId: number) {
+ return await this.prismaClient.booking.findUnique({
+ where: {
+ id: bookingId,
+ },
+ include: {
+ attendees: true,
+ eventType: true,
+ destinationCalendar: true,
+ references: true,
+ user: {
+ include: {
+ destinationCalendar: true,
+ credentials: true,
+ },
+ },
+ },
+ });
+ }
+
+ async updateBookingAttendees({
+ bookingId,
+ newAttendees,
+ updatedResponses,
+ }: {
+ bookingId: number;
+ newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[];
+ updatedResponses: Prisma.InputJsonValue;
+ }) {
+ return await this.prismaClient.booking.update({
+ where: {
+ id: bookingId,
+ },
+ include: {
+ attendees: true,
+ },
+ data: {
+ attendees: {
+ createMany: {
+ data: newAttendees,
+ },
+ },
+ responses: updatedResponses,
+ },
+ });
+ }
}
diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts
index b46a24d51b..eceb5447f6 100644
--- a/packages/platform/libraries/bookings.ts
+++ b/packages/platform/libraries/bookings.ts
@@ -8,3 +8,4 @@ export type {
InstantBookingCreateResult,
RegularBookingCreateResult,
} from "@calcom/features/bookings/lib/dto/types";
+export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
diff --git a/packages/platform/libraries/emails.ts b/packages/platform/libraries/emails.ts
index 044ace5a0c..80d66acf05 100644
--- a/packages/platform/libraries/emails.ts
+++ b/packages/platform/libraries/emails.ts
@@ -1,3 +1,4 @@
+import AttendeeAddGuestsEmail from "@calcom/emails/templates/attendee-add-guests-email";
import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@calcom/emails/templates/attendee-declined-email";
import AttendeeRequestEmail from "@calcom/emails/templates/attendee-request-email";
@@ -5,6 +6,7 @@ import AttendeeRescheduledEmail from "@calcom/emails/templates/attendee-reschedu
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
import AttendeeUpdatedEmail from "@calcom/emails/templates/attendee-updated-email";
import AttendeeVerifyEmail from "@calcom/emails/templates/attendee-verify-email";
+import OrganizerAddGuestsEmail from "@calcom/emails/templates/organizer-add-guests-email";
import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email";
@@ -16,6 +18,10 @@ import { verifyEmailCodeHandler } from "@calcom/trpc/server/routers/viewer/workf
export { AttendeeVerifyEmail };
+export { AttendeeAddGuestsEmail };
+
+export { OrganizerAddGuestsEmail };
+
export { AttendeeScheduledEmail };
export { OrganizerScheduledEmail };
diff --git a/packages/platform/types/bookings/2024-08-13/inputs/add-guests.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/add-guests.input.ts
new file mode 100644
index 0000000000..34f4e324cd
--- /dev/null
+++ b/packages/platform/types/bookings/2024-08-13/inputs/add-guests.input.ts
@@ -0,0 +1,89 @@
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import {
+ IsArray,
+ IsEmail,
+ ArrayMinSize,
+ IsString,
+ IsOptional,
+ IsTimeZone,
+ IsEnum,
+ Validate,
+ ValidateNested,
+} from "class-validator";
+import { isValidPhoneNumber } from "libphonenumber-js";
+
+import type { BookingLanguageType } from "./language";
+import { BookingLanguage } from "./language";
+
+class Guest {
+ @ApiProperty({
+ type: String,
+ description: "The email of the guest.",
+ example: "john.doe@example.com",
+ })
+ @IsEmail()
+ email!: string;
+
+ @ApiPropertyOptional({
+ type: String,
+ description: "The name of the guest.",
+ example: "John Doe",
+ })
+ @IsString()
+ @IsOptional()
+ name?: string;
+
+ @ApiPropertyOptional({
+ type: String,
+ description: "The time zone of the guest.",
+ example: "America/New_York",
+ })
+ @IsTimeZone()
+ @IsOptional()
+ timeZone?: string;
+
+ @ApiPropertyOptional({
+ type: String,
+ description: "The phone number of the guest in international format.",
+ example: "+919876543210",
+ })
+ @IsOptional()
+ @Validate((value: string) => !value || isValidPhoneNumber(value), {
+ message: "Invalid phone number format. Please use international format.",
+ })
+ phoneNumber?: string;
+
+ @ApiPropertyOptional({
+ enum: BookingLanguage,
+ description: "The preferred language of the guest. Used for booking confirmation.",
+ example: BookingLanguage.it,
+ default: BookingLanguage.en,
+ })
+ @IsEnum(BookingLanguage)
+ @IsOptional()
+ language?: BookingLanguageType;
+}
+
+export class AddGuestsInput_2024_08_13 {
+ @ArrayMinSize(1)
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => Guest)
+ @ApiProperty({
+ type: [Guest],
+ description: "Array of guests to add to the booking",
+ example: [
+ {
+ email: "john.doe@example.com",
+ name: "John Doe",
+ timeZone: "America/New_York",
+ },
+ {
+ email: "jane.smith@example.com",
+ name: "Jane Smith",
+ },
+ ],
+ })
+ guests!: Guest[];
+}
diff --git a/packages/platform/types/bookings/2024-08-13/inputs/index.ts b/packages/platform/types/bookings/2024-08-13/inputs/index.ts
index a9e46ee866..2bc8acf9df 100644
--- a/packages/platform/types/bookings/2024-08-13/inputs/index.ts
+++ b/packages/platform/types/bookings/2024-08-13/inputs/index.ts
@@ -9,3 +9,4 @@ export * from "./reschedule-booking-input.pipe";
export * from "./cancel-booking-input.pipe";
export * from "./decline-booking.input";
export * from "./validators/validate-metadata";
+export * from "./add-guests.input";
diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts
index c2851d826d..a58732c2b3 100644
--- a/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts
+++ b/packages/platform/types/bookings/2024-08-13/outputs/get-booking.output.ts
@@ -3,12 +3,13 @@ import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+
import {
BookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
-} from "@calcom/platform-types";
+} from "./booking.output";
@ApiExtraModels(
BookingOutput_2024_08_13,
diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts
index 395f86a143..e6e758d29b 100644
--- a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts
@@ -1,15 +1,19 @@
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
+import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
-import { sendAddGuestsEmails } from "@calcom/emails";
+import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
import EventManager from "@calcom/features/bookings/lib/EventManager";
+import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
+import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { BookingResponses } from "@calcom/prisma/zod-utils";
+import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
@@ -17,36 +21,75 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TAddGuestsInputSchema } from "./addGuests.schema";
+type TUser = Pick, "id" | "email" | "organizationId"> &
+ Partial, "profile">>;
+
type AddGuestsOptions = {
ctx: {
- user: NonNullable;
+ user: TUser;
};
input: TAddGuestsInputSchema;
+ emailsEnabled?: boolean;
};
-export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
+
+type Booking = NonNullable>>;
+type OrganizerData = Awaited>;
+
+export const addGuestsHandler = async ({ ctx, input, emailsEnabled = true }: AddGuestsOptions) => {
const { user } = ctx;
const { bookingId, guests } = input;
- const booking = await prisma.booking.findUnique({
- where: {
- id: bookingId,
- },
- include: {
- attendees: true,
- eventType: true,
- destinationCalendar: true,
- references: true,
- user: {
- include: {
- destinationCalendar: true,
- credentials: true,
- },
- },
- },
- });
+ const booking = await getBooking(bookingId);
- if (!booking) throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
+ await validateUserPermissions(booking, user);
+ validateGuestsFieldEnabled(booking);
+
+ const organizer = await getOrganizerData(booking.userId);
+
+ const uniqueGuests = await sanitizeAndFilterGuests(guests, booking);
+
+ const newGuestsDetails = uniqueGuests.map((guest) => ({
+ name: guest.name || "",
+ email: guest.email,
+ timeZone: guest.timeZone || organizer.timeZone,
+ locale: guest.language || organizer.locale,
+ }));
+
+ const uniqueGuestEmails = uniqueGuests.map((guest) => guest.email);
+
+ const bookingAttendees = await updateBookingAttendees(
+ bookingId,
+ newGuestsDetails,
+ uniqueGuestEmails,
+ booking
+ );
+
+ const attendeesList = await prepareAttendeesList(bookingAttendees.attendees);
+
+ const evt = await buildCalendarEvent(booking, organizer, attendeesList);
+
+ await updateCalendarEvent(booking, evt);
+
+ if (emailsEnabled) {
+ await sendGuestNotifications(evt, booking, uniqueGuestEmails);
+ }
+
+ return { message: "Guests added" };
+};
+
+async function getBooking(bookingId: number) {
+ const bookingRepository = new BookingRepository(prisma);
+ const booking = await bookingRepository.findByIdIncludeDestinationCalendar(bookingId);
+
+ if (!booking || !booking.user) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
+ }
+
+ return booking;
+}
+
+async function validateUserPermissions(booking: Booking, user: TUser): Promise {
const isOrganizer = booking.userId === user.id;
const isAttendee = !!booking.attendees.find((attendee) => attendee.email === user.email);
@@ -55,7 +98,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
const permissionCheckService = new PermissionCheckService();
hasBookingUpdatePermission = await permissionCheckService.checkPermission({
userId: user.id,
- teamId: booking.eventType.teamId,
+ teamId: booking.eventType?.teamId,
permission: "booking.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
@@ -64,10 +107,30 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
if (!hasBookingUpdatePermission && !isOrganizer && !isAttendee) {
throw new TRPCError({ code: "FORBIDDEN", message: "you_do_not_have_permission" });
}
+}
- const organizer = await prisma.user.findUniqueOrThrow({
+function validateGuestsFieldEnabled(booking: Booking): void {
+ const parsedBookingFields = booking?.eventType?.bookingFields
+ ? eventTypeBookingFields.parse(booking.eventType.bookingFields)
+ : [];
+
+ const guestsBookingField = parsedBookingFields.find((field) => field.name === "guests");
+ if (guestsBookingField?.hidden) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Cannot add guests to this booking. The guests field is disabled for event type "${booking?.eventType?.title}" (ID: ${booking?.eventTypeId}). Please contact the event organizer to enable guest additions.`,
+ });
+ }
+}
+
+async function getOrganizerData(userId: number | null) {
+ if (!userId) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
+ }
+
+ return await prisma.user.findUniqueOrThrow({
where: {
- id: booking.userId || 0,
+ id: userId,
},
select: {
name: true,
@@ -76,13 +139,11 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
locale: true,
},
});
+}
- const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
- ? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
- : [];
-
+function deduplicateGuestEmails(guests: string[]): string[] {
const seenBaseEmails = new Set();
- const deduplicatedGuests = guests.filter((guest) => {
+ return guests.filter((guest) => {
const baseEmail = extractBaseEmail(guest).toLowerCase();
if (seenBaseEmails.has(baseEmail)) {
return false;
@@ -90,8 +151,15 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
seenBaseEmails.add(baseEmail);
return true;
});
+}
- const guestEmails = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
+function getBlacklistedEmails(): string[] {
+ return process.env.BLACKLISTED_GUEST_EMAILS
+ ? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
+ : [];
+}
+
+async function getEmailVerificationRequirements(guestEmails: string[]): Promise