feat: Add endpoint to add attendees to existing bookings (#24414)

* feat: add endpoint to add attendees to existing bookings

- Created POST /v2/bookings/:bookingUid/attendees endpoint
- Added AddAttendeesInput_2024_08_13 for input validation
- Added AddAttendeesOutput_2024_08_13 for response format
- Created BookingAttendeesService_2024_08_13 for business logic
- Created BookingAttendeesController_2024_08_13 for API endpoint
- Added validation to check for duplicate attendee emails
- Integrated with existing booking and event type repositories
- Added validateAndTransformAddAttendeesInput method to InputBookingsService
- Fixed pre-existing ESLint no-prototype-builtins warnings
- Left placeholder for custom booking field validation logic

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* refactor: move booking attendee operations to dedicated repository

* refactor: move repository files into dedicated repositories directory

* feat: validate guests field availability before adding attendees to booking

* feat: migrate addAttendees API to use existing addGuests handler

* refactor: remove unused validateAndTransformAddAttendeesInput method from InputBookingsService

* refactor: rename attendees to guests in booking API endpoints and types

* refactor: rename booking-attendees to booking-guests for consistency

* WIP: add e2e tests for add booking guests endpoint

* faet: improve guest booking tests

* refactor: extract getHtml method in email templates

* feat: add email toggle support for guest invites based on OAuth client settings

* refactor: addGuests handler

* feat: add SMS notifications when adding guests to existing bookings

* refactor: rename add-attendees to add-guests for consistent terminology

* refactor: added repository pattern in addGuests.handler

* test: add attendee scheduled email spy to booking guests tests

* fix: use event type team ID instead of user org ID for booking permission check

* Update BookingEmailSmsHandler.ts

* Remove comments

* refactor: rename booking guests to booking attendees

* refactor: rename guest-related methods to use attendees terminology for consistency

* update api docs

* refactor: restructure addGuests handler to top

* refactor: update guest email format to use object structure in booking tests

* docs: clarify API version header requirement for booking attendees endpoint

* docs: add email notification details to booking attendees API documentation

* refactor: rename booking attendees to guests for consistency

* refactor: rename attendees to guests in booking API endpoints

* feat: add email validation for guest invites

* feat: improve error handling for guest booking failures

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-10-28 14:16:07 +05:30
1 parent c0530cc91c
commit b620581d7a
34 files changed
+1302 -103

No files matched your search

@@ -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 {}
@@ -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.
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
`,
})
async addGuests(
@Param("bookingUid") bookingUid: string,
@Body() body: AddGuestsInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<AddGuestsOutput_2024_08_13> {
const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -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("<p>email</p>"));
const organizerAddGuestsEmailSpy = jest
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
const attendeeScheduledEmailSpy = jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
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>(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;
}
});
@@ -0,0 +1,3 @@
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
@@ -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
);
}
}
}
@@ -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";
@@ -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";
@@ -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";
@@ -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) {
@@ -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,
@@ -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";
@@ -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
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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);
}
+199
View File
@@ -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 <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>\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 <token>` where `<token>` 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": {
+1 -1
View File
@@ -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<User, "id" | "email">) {
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
+42
View File
@@ -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<unknown>[] = [];
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));
};
@@ -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<Record<string, unknown>> {
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"),
};
}
@@ -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<Record<string, unknown>> {
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"),
};
}
@@ -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);
}
}
}
@@ -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,
},
});
}
}
+1
View File
@@ -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";
+6
View File
@@ -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 };
@@ -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[];
}
@@ -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";
@@ -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,
@@ -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<NonNullable<TrpcSessionUser>, "id" | "email" | "organizationId"> &
Partial<Pick<NonNullable<TrpcSessionUser>, "profile">>;
type AddGuestsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: TUser;
};
input: TAddGuestsInputSchema;
emailsEnabled?: boolean;
};
export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
type Booking = NonNullable<Awaited<ReturnType<BookingRepository["findByIdIncludeDestinationCalendar"]>>>;
type OrganizerData = Awaited<ReturnType<typeof getOrganizerData>>;
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<void> {
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<string>();
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<Map<string, boolean>> {
const userRepo = new UserRepository(prisma);
const guestUsers = await userRepo.findManyByEmailsWithEmailVerificationSettings({ emails: guestEmails });
@@ -101,8 +169,40 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
}
const uniqueGuests = deduplicatedGuests.filter((guest) => {
const baseGuestEmail = extractBaseEmail(guest).toLowerCase();
return emailToRequiresVerification;
}
async function sanitizeAndFilterGuests(
guests: Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>,
booking: Booking
): Promise<
Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>
> {
const guestEmails = guests.map((guest) => guest.email);
const deduplicatedGuests = deduplicateGuestEmails(guestEmails);
const blacklistedGuestEmails = getBlacklistedEmails();
const guestEmailsLowerCase = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
const emailToRequiresVerification = await getEmailVerificationRequirements(guestEmailsLowerCase);
// Create a map of email to guest object for easy lookup
const emailToGuestMap = new Map(
guests.map((guest) => [extractBaseEmail(guest.email).toLowerCase(), guest])
);
const uniqueGuestEmails = deduplicatedGuests.filter((email) => {
const baseGuestEmail = extractBaseEmail(email).toLowerCase();
return (
!booking.attendees.some(
(attendee) => extractBaseEmail(attendee.email).toLowerCase() === baseGuestEmail
@@ -112,41 +212,37 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
);
});
if (uniqueGuests.length === 0)
if (uniqueGuestEmails.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "emails_must_be_unique_valid" });
}
const guestsFullDetails = uniqueGuests.map((guest) => {
return {
name: "",
email: guest,
timeZone: organizer.timeZone,
locale: organizer.locale,
};
});
// Return the full guest objects for unique emails
return uniqueGuestEmails
.map((email) => emailToGuestMap.get(extractBaseEmail(email).toLowerCase()))
.filter((guest): guest is NonNullable<typeof guest> => guest !== undefined);
}
async function updateBookingAttendees(
bookingId: number,
newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[],
uniqueGuestEmails: string[],
booking: Booking
) {
const bookingResponses = booking.responses as BookingResponses;
const bookingRepository = new BookingRepository(prisma);
const bookingAttendees = await prisma.booking.update({
where: {
id: bookingId,
},
include: {
attendees: true,
},
data: {
attendees: {
createMany: {
data: guestsFullDetails,
},
},
responses: {
...bookingResponses,
guests: [...(bookingResponses?.guests || []), ...uniqueGuests],
},
return await bookingRepository.updateBookingAttendees({
bookingId,
newAttendees,
updatedResponses: {
...bookingResponses,
guests: [...(bookingResponses?.guests || []), ...uniqueGuestEmails],
},
});
}
const attendeesListPromises = bookingAttendees.attendees.map(async (attendee) => {
async function prepareAttendeesList(attendees: Booking["attendees"]) {
const attendeesListPromises = attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
@@ -158,7 +254,14 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
};
});
const attendeesList = await Promise.all(attendeesListPromises);
return await Promise.all(attendeesListPromises);
}
async function buildCalendarEvent(
booking: Booking,
organizer: OrganizerData,
attendeesList: Awaited<ReturnType<typeof prepareAttendeesList>>
): Promise<CalendarEvent> {
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
@@ -199,20 +302,39 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
};
}
const credentials = await getUsersCredentialsIncludeServiceAccountKey(ctx.user);
return evt;
}
async function updateCalendarEvent(booking: Booking, evt: CalendarEvent): Promise<void> {
if (!booking.user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Booking user not found" });
}
const credentials = await getUsersCredentialsIncludeServiceAccountKey(booking.user);
const eventManager = new EventManager({
...user,
...booking.user,
credentials: [...credentials],
});
await eventManager.updateCalendarAttendees(evt, booking);
}
try {
await sendAddGuestsEmails(evt, uniqueGuests);
} catch (err) {
console.error("Error sending AddGuestsEmails", err);
}
async function sendGuestNotifications(
evt: CalendarEvent,
booking: Booking,
uniqueGuests: string[]
): Promise<void> {
const emailsAndSmsHandler = new BookingEmailSmsHandler({
logger: logger,
});
return { message: "Guests added" };
};
await emailsAndSmsHandler.handleAddGuests({
evt,
eventType: {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata),
schedulingType: booking.eventType?.schedulingType || null,
},
newGuests: uniqueGuests,
});
}
@@ -1,8 +1,16 @@
import { z } from "zod";
const ZGuestSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
timeZone: z.string().optional(),
phoneNumber: z.string().optional(),
language: z.string().optional(),
});
export const ZAddGuestsInputSchema = z.object({
bookingId: z.number(),
guests: z.array(z.string().email()),
guests: z.array(ZGuestSchema),
});
export type TAddGuestsInputSchema = z.infer<typeof ZAddGuestsInputSchema>;