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> { 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 => 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> +): Promise { 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 { + 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 { + 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, + }); +} diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts index e54c4eed8e..f034c9e7c7 100644 --- a/packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts @@ -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;