diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index b75323d8f3..8aaede75fc 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -14,8 +14,10 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { BillingService } from "@/modules/billing/services/billing.service"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { UsersRepository } from "@/modules/users/users.repository"; import { Controller, Post, @@ -101,7 +103,8 @@ export class BookingsController_2024_04_15 { private readonly billingService: BillingService, private readonly config: ConfigService, private readonly apiKeyRepository: ApiKeysRepository, - private readonly platformBookingsService: PlatformBookingsService + private readonly platformBookingsService: PlatformBookingsService, + private readonly usersRepository: UsersRepository ) {} @Get("/") @@ -352,6 +355,30 @@ export class BookingsController_2024_04_15 { } } + private async getOwnerIdRescheduledBooking( + request: Request, + platformClientId?: string + ): Promise { + if ( + platformClientId && + request.body.rescheduledBy && + !request.body.rescheduledBy.includes(platformClientId) + ) { + request.body.rescheduledBy = OAuthClientUsersService.getOAuthUserEmail( + platformClientId, + request.body.rescheduledBy + ); + } + + if (request.body.rescheduledBy) { + if (request.body.rescheduledBy !== request.body.responses.email) { + return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id; + } + } + + return undefined; + } + private async getOAuthClientIdFromEventType(eventTypeId: number): Promise { if (!eventTypeId) { return undefined; @@ -396,7 +423,9 @@ export class BookingsController_2024_04_15 { ): Promise { const requestId = req.get("X-Request-Id"); const clone = { ...req }; - const userId = (await this.getOwnerId(req)) ?? -1; + const userId = clone.body.rescheduleUid + ? await this.getOwnerIdRescheduledBooking(req, oAuthClientId) + : await this.getOwnerId(req); const oAuthParams = oAuthClientId ? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed)) : DEFAULT_PLATFORM_PARAMS; diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/managed-user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/managed-user-bookings.e2e-spec.ts index 100ad8129b..25eba490e4 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/managed-user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/managed-user-bookings.e2e-spec.ts @@ -12,6 +12,7 @@ import { CreateManagedUserData, CreateManagedUserOutput, } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; @@ -140,7 +141,9 @@ describe("Managed user bookings 2024-04-15", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -193,7 +196,9 @@ describe("Managed user bookings 2024-04-15", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -220,7 +225,9 @@ describe("Managed user bookings 2024-04-15", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -444,11 +451,6 @@ describe("Managed user bookings 2024-04-15", () => { }); }); - function getOAuthUserEmail(oAuthClientId: string, userEmail: string) { - const [username, emailDomain] = userEmail.split("@"); - return `${username}+${oAuthClientId}@${emailDomain}`; - } - afterAll(async () => { await userRepositoryFixture.delete(firstManagedUser.user.id); await userRepositoryFixture.delete(secondManagedUser.user.id); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 09006269d1..a45f1ce3b5 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -13,6 +13,8 @@ import { Validate, } from "class-validator"; +import { RESCHEDULED_BY_DOCS } from "@calcom/platform-types"; + class Location { @IsString() @ApiProperty() @@ -76,6 +78,13 @@ export class CreateBookingInput_2024_04_15 { @ApiPropertyOptional() rescheduleUid?: string; + @IsOptional() + @ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS }) + @Validate((value: string) => !value || isEmail(value), { + message: "Invalid rescheduledBy email format", + }) + rescheduledBy?: string; + @IsTimeZone() @ApiProperty() timeZone!: string; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts index b4f34afe4e..e99e3c81b8 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts @@ -94,6 +94,8 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { let emailsDisabledSetup: EmailSetup; const authEmail = `confirm-emails-2024-08-13-admin-${randomString()}@api.com`; + let userEmailsEnabled = ""; + const userEmailsDisabled = `confirm-emails-2024-08-13-user-${randomString()}@api.com`; beforeAll(async () => { const moduleRef = await withApiAuth( @@ -141,8 +143,12 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { async function setupEnabledEmails() { const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true); + userEmailsEnabled = `confirm-emails-2024-08-13-user-${randomString()}+${ + oAuthClientEmailsEnabled.id + }@api.com`; + const user = await userRepositoryFixture.create({ - email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`, + email: userEmailsEnabled, platformOAuthClients: { connect: { id: oAuthClientEmailsEnabled.id, @@ -179,7 +185,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false); const user = await userRepositoryFixture.create({ - email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`, + email: userEmailsDisabled, platformOAuthClients: { connect: { id: oAuthClientEmailsDisabled.id, @@ -282,9 +288,6 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { expect(responseBody.data).toBeDefined(); expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid; }); }); @@ -338,10 +341,6 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(AttendeeDeclinedEmail.prototype.getHtml).not.toHaveBeenCalled(); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid; }); }); }); @@ -351,117 +350,176 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => { jest.clearAllMocks(); }); - it("should send an email when creating a booking that requires confirmation", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), - eventTypeId: emailsEnabledSetup.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", - }, - }; + describe("confirming booking that requires confirmation and rescheduling it", () => { + it("should send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.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", + }, + }; - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(responseBody.data.status).toEqual("pending"); - expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); - expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); - emailsEnabledSetup.createdBookingUid = responseBody.data.uid; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + emailsEnabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should send an email when confirming a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + + describe("rescheduling emails", () => { + it("should send confirmation emails when organizer reschedules a booking that requires confirmation", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 14, 30, 0)).toISOString(), + rescheduledBy: userEmailsEnabled, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("accepted"); + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); }); + + it("should send requested rescheduling emails when attendee rescheduling a booking that requires confirmation", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.rescheduledBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); }); - it("should send an email when confirming a booking", async () => { - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: GetBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; - }); - }); + describe("declining booking that requires confirmation", () => { + it("should send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.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", + }, + }; - it("should send an email when creating a booking that requires confirmation", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), - eventTypeId: emailsEnabledSetup.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", - }, - }; + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + emailsEnabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(responseBody.data.status).toEqual("pending"); - expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); - expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); - emailsEnabledSetup.createdBookingUid = responseBody.data.uid; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } - }); - }); - - it("should send an email when declining a booking", async () => { - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: GetBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; - }); + it("should send an email when declining a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts index 3fcd33e7c7..ddc0b95cc3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts @@ -7,6 +7,7 @@ import { CreateManagedUserData, CreateManagedUserOutput, } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; @@ -149,7 +150,9 @@ describe("Managed user bookings 2024-08-13", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -202,7 +205,9 @@ describe("Managed user bookings 2024-08-13", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -229,7 +234,9 @@ describe("Managed user bookings 2024-08-13", () => { const responseBody: CreateManagedUserOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.accessToken).toBeDefined(); expect(responseBody.data.refreshToken).toBeDefined(); @@ -470,11 +477,6 @@ describe("Managed user bookings 2024-08-13", () => { }); }); - function getOAuthUserEmail(oAuthClientId: string, userEmail: string) { - const [username, emailDomain] = userEmail.split("@"); - return `${username}+${oAuthClientId}@${emailDomain}`; - } - afterAll(async () => { await userRepositoryFixture.delete(firstManagedUser.user.id); await userRepositoryFixture.delete(secondManagedUser.user.id); 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 84368076d5..99ec5ecfc0 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 @@ -8,7 +8,9 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20 import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -75,7 +77,8 @@ export class InputBookingsService_2024_08_13 { private readonly config: ConfigService, private readonly apiKeyRepository: ApiKeysRepository, private readonly bookingSeatRepository: BookingSeatRepository, - private readonly platformBookingsService: PlatformBookingsService + private readonly platformBookingsService: PlatformBookingsService, + private readonly usersRepository: UsersRepository ) {} async createBookingRequest( @@ -314,7 +317,24 @@ export class InputBookingsService_2024_08_13 { ); const newRequest = { ...request }; - const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + let userId: number | undefined = undefined; + + if ( + oAuthClientParams && + request.body.rescheduledBy && + !request.body.rescheduledBy.includes(oAuthClientParams.platformClientId) + ) { + request.body.rescheduledBy = OAuthClientUsersService.getOAuthUserEmail( + oAuthClientParams.platformClientId, + request.body.rescheduledBy + ); + } + + if (request.body.rescheduledBy) { + if (request.body.rescheduledBy !== bodyTransformed.responses.email) { + userId = (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id; + } + } const location = await this.getRescheduleBookingLocation(bookingUid); if (oAuthClientParams) { diff --git a/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts b/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts index 5d1145ce0d..aacd3adbb9 100644 --- a/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts @@ -9,7 +9,6 @@ import { PlatformOAuthClient } from "@calcom/prisma/client"; @Injectable() export class PlatformBookingsService { constructor( - private readonly oAuthClientsUsersService: OAuthClientUsersService, private readonly usersRepository: UsersRepository, private readonly eventTypesRepository: EventTypesRepository_2024_06_14, private readonly oAuthClientRepository: OAuthClientRepository @@ -25,7 +24,7 @@ export class PlatformBookingsService { if (!attendeeEmail.includes(platformClientId)) { // note(Lauris): we need to do this when managed user is booking another managed user and the managed user doing the booking entered their email without oAuth client id // or if one of the guests added are managed users without oAuth client id in their email. - const oAuthUserEmail = this.oAuthClientsUsersService.getOAuthUserEmail(platformClientId, attendeeEmail); + const oAuthUserEmail = OAuthClientUsersService.getOAuthUserEmail(platformClientId, attendeeEmail); const oAuthUser = await this.usersRepository.findByEmail(oAuthUserEmail); if (oAuthUser) { return oAuthUserEmail; diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index 2c990c2b0c..74d747858a 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -7,6 +7,7 @@ import { Locales } from "@/lib/enums/locales"; import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UsersModule } from "@/modules/users/users.module"; @@ -218,7 +219,9 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone); expect(responseBody.data.user.name).toEqual(requestBody.name); expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart); @@ -297,7 +300,7 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.user.email).toEqual( - getOAuthUserEmail(oAuthClientEventTypesDisabled.id, requestBody.email) + OAuthClientUsersService.getOAuthUserEmail(oAuthClientEventTypesDisabled.id, requestBody.email) ); expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone); expect(responseBody.data.user.name).toEqual(requestBody.name); @@ -518,7 +521,9 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userEmail)); + expect(responseBody.data.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail) + ); }); it(`/PUT/:id`, async () => { @@ -536,7 +541,9 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); - expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)); + expect(responseBody.data.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail) + ); expect(responseBody.data.locale).toEqual(Locales.PT_BR); }); @@ -548,13 +555,6 @@ describe("OAuth Client Users Endpoints", () => { .expect(200); }); - function getOAuthUserEmail(oAuthClientId: string, userEmail: string) { - const [username, emailDomain] = userEmail.split("@"); - const email = `${username}+${oAuthClientId}@${emailDomain}`; - - return email; - } - afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id); diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index 17397abfbb..424431cf74 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -36,7 +36,7 @@ export class OAuthClientUsersService { "You cannot create a managed user outside of an organization - the OAuth client does not belong to any organization." ); } else { - const email = this.getOAuthUserEmail(oAuthClientId, body.email); + const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email); user = ( await createNewUsersConnectToOrgIfExists({ invitations: [ @@ -98,7 +98,7 @@ export class OAuthClientUsersService { } async getExistingUserByEmail(oAuthClientId: string, email: string) { - const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email); + const oAuthEmail = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, email); return await this.userRepository.findByEmail(oAuthEmail); } @@ -121,7 +121,7 @@ export class OAuthClientUsersService { async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) { if (body.email) { - const emailWithOAuthId = this.getOAuthUserEmail(oAuthClientId, body.email); + const emailWithOAuthId = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email); body.email = emailWithOAuthId; const newUsername = slugify(emailWithOAuthId); await this.userRepository.updateUsername(userId, newUsername); @@ -130,7 +130,7 @@ export class OAuthClientUsersService { return this.userRepository.update(userId, body); } - getOAuthUserEmail(oAuthClientId: string, userEmail: string) { + static getOAuthUserEmail(oAuthClientId: string, userEmail: string) { const [username, emailDomain] = userEmail.split("@"); return `${username}+${oAuthClientId}@${emailDomain}`; } diff --git a/packages/platform/atoms/booker-embed/BookerEmbed.tsx b/packages/platform/atoms/booker-embed/BookerEmbed.tsx index 23cfd17027..c9b4c0e64f 100644 --- a/packages/platform/atoms/booker-embed/BookerEmbed.tsx +++ b/packages/platform/atoms/booker-embed/BookerEmbed.tsx @@ -1,8 +1,8 @@ +import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper"; import type { BookerPlatformWrapperAtomPropsForIndividual, BookerPlatformWrapperAtomPropsForTeam, -} from "../booker/BookerPlatformWrapper"; -import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper"; +} from "../booker/types"; import { CalProvider } from "../cal-provider/CalProvider"; import { useGetRoutingFormUrlProps } from "./useGetRoutingFormUrlProps"; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index eeb5d3159f..ea67c57a27 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -3,7 +3,6 @@ import { useMemo, useEffect, useCallback, useState } from "react"; import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; -import type { BookerProps } from "@calcom/features/bookings/Booker"; import { Booker as BookerComponent } from "@calcom/features/bookings/Booker"; import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; @@ -15,20 +14,12 @@ import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/get import { getUsernameList } from "@calcom/lib/defaultEvents"; import { localStorage } from "@calcom/lib/webstorage"; import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; -import type { BookingResponse } from "@calcom/platform-libraries"; -import type { - ApiErrorResponse, - ApiSuccessResponse, - ApiSuccessResponseWithoutData, -} from "@calcom/platform-types"; -import type { RoutingFormSearchParams } from "@calcom/platform-types"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { transformApiEventTypeForAtom, transformApiTeamEventTypeForAtom, } from "../event-types/atom-api-transformers/transformApiEventTypeForAtom"; -import type { UseCreateBookingInput } from "../hooks/bookings/useCreateBooking"; import { useCreateBooking } from "../hooks/bookings/useCreateBooking"; import { useCreateInstantBooking } from "../hooks/bookings/useCreateInstantBooking"; import { useCreateRecurringBooking } from "../hooks/bookings/useCreateRecurringBooking"; @@ -46,59 +37,10 @@ import { useConnectedCalendars } from "../hooks/useConnectedCalendars"; import { useMe } from "../hooks/useMe"; import { useSlots } from "../hooks/useSlots"; import { AtomsWrapper } from "../src/components/atoms-wrapper"; - -export type BookerPlatformWrapperAtomProps = Omit< - BookerProps, - "username" | "entity" | "isTeamEvent" | "teamId" -> & { - rescheduleUid?: string; - rescheduledBy?: string; - bookingUid?: string; - entity?: BookerProps["entity"]; - // values for the booking form and booking fields - defaultFormValues?: { - firstName?: string; - lastName?: string; - guests?: string[]; - name?: string; - email?: string; - notes?: string; - rescheduleReason?: string; - } & Record; - handleCreateBooking?: (input: UseCreateBookingInput) => void; - onCreateBookingSuccess?: (data: ApiSuccessResponse) => void; - onCreateBookingError?: (data: ApiErrorResponse | Error) => void; - onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse) => void; - onCreateRecurringBookingError?: (data: ApiErrorResponse | Error) => void; - onCreateInstantBookingSuccess?: (data: ApiSuccessResponse) => void; - onCreateInstantBookingError?: (data: ApiErrorResponse | Error) => void; - onReserveSlotSuccess?: (data: ApiSuccessResponse) => void; - onReserveSlotError?: (data: ApiErrorResponse) => void; - onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void; - onDeleteSlotError?: (data: ApiErrorResponse) => void; - locationUrl?: string; - view?: VIEW_TYPE; - metadata?: Record; - bannerUrl?: string; - onDryRunSuccess?: () => void; - hostsLimit?: number; - preventEventTypeRedirect?: boolean; -}; - -type VIEW_TYPE = keyof typeof BookerLayouts; - -export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & { - username: string | string[]; - isTeamEvent?: false; - routingFormSearchParams?: RoutingFormSearchParams; -}; - -export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomProps & { - username?: string | string[]; - isTeamEvent: true; - teamId: number; - routingFormSearchParams?: RoutingFormSearchParams; -}; +import type { + BookerPlatformWrapperAtomPropsForIndividual, + BookerPlatformWrapperAtomPropsForTeam, +} from "./types"; export const BookerPlatformWrapper = ( props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam @@ -504,7 +446,7 @@ export const BookerPlatformWrapper = ( } } rescheduleUid={props.rescheduleUid ?? null} - rescheduledBy={null} + rescheduledBy={props.rescheduledBy} bookingUid={props.bookingUid ?? null} isRedirect={false} fromUserNameRedirected="" diff --git a/packages/platform/atoms/booker/types.ts b/packages/platform/atoms/booker/types.ts index d560f5b19b..a7daa643ca 100644 --- a/packages/platform/atoms/booker/types.ts +++ b/packages/platform/atoms/booker/types.ts @@ -4,6 +4,7 @@ import type { ApiSuccessResponse, ApiErrorResponse, ApiSuccessResponseWithoutData, + RoutingFormSearchParams, } from "@calcom/platform-types"; import type { BookerLayouts } from "@calcom/prisma/zod-utils"; @@ -48,3 +49,16 @@ export type BookerPlatformWrapperAtomProps = Omit< }; type VIEW_TYPE = keyof typeof BookerLayouts; + +export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & { + username: string | string[]; + isTeamEvent?: false; + routingFormSearchParams?: RoutingFormSearchParams; +}; + +export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomProps & { + username?: string | string[]; + isTeamEvent: true; + teamId: number; + routingFormSearchParams?: RoutingFormSearchParams; +}; diff --git a/packages/platform/atoms/router/Router.tsx b/packages/platform/atoms/router/Router.tsx index a11a914720..b2b65c51b2 100644 --- a/packages/platform/atoms/router/Router.tsx +++ b/packages/platform/atoms/router/Router.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import React, { useState } from "react"; import { BookerEmbed } from "../booker-embed"; -import type { BookerPlatformWrapperAtomPropsForTeam } from "../booker/BookerPlatformWrapper"; +import type { BookerPlatformWrapperAtomPropsForTeam } from "../booker/types"; /** * Renders the Router component with predefined props. diff --git a/packages/platform/examples/base/src/pages/[bookingUid].tsx b/packages/platform/examples/base/src/pages/[bookingUid].tsx index b0729bba2a..80909aebb7 100644 --- a/packages/platform/examples/base/src/pages/[bookingUid].tsx +++ b/packages/platform/examples/base/src/pages/[bookingUid].tsx @@ -118,7 +118,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string )} - {!!booking.bookingFieldsResponses?.notes && ( + {"bookingFieldsResponses" in booking && !!booking.bookingFieldsResponses?.notes && (

Additional notes

@@ -140,7 +140,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string className="underline" onClick={() => { router.push( - `/booking?rescheduleUid=${booking?.uid}&eventTypeSlug=${booking?.eventType.slug}` + `/booking?rescheduleUid=${booking?.uid}&eventTypeSlug=${booking?.eventType.slug}&rescheduledBy=${props.calEmail}` ); }}> Reschedule diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index a230974ab3..462957d94e 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -18,6 +18,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string const { data: teams } = useTeams(); const { isLoading: isLoadingTeamEvents, data: teamEventTypes } = useTeamEventTypes(teams?.[0]?.id || 0); const rescheduleUid = (router.query.rescheduleUid as string) ?? ""; + const rescheduledBy = (router.query.rescheduledBy as string) ?? ""; const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? ""; return ( @@ -125,6 +126,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string {!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && ( { diff --git a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts index 3417a6e04d..61d50bd06b 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts @@ -1,5 +1,9 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsDateString, IsOptional, IsString } from "class-validator"; +import { IsDateString, IsOptional, IsString, Validate, isEmail } from "class-validator"; + +export const RESCHEDULED_BY_DOCS = `Email of the person who is rescheduling the booking - only needed when rescheduling a booking that requires a confirmation. +If event type owner email is provided then rescheduled booking will be automatically confirmed. If attendee email or no email is passed then the event type +owner will have to confirm the rescheduled booking.`; export class RescheduleBookingInput_2024_08_13 { @IsDateString() @@ -9,6 +13,13 @@ export class RescheduleBookingInput_2024_08_13 { }) start!: string; + @IsOptional() + @ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS }) + @Validate((value: string) => !value || isEmail(value), { + message: "Invalid rescheduledBy email format", + }) + rescheduledBy?: string; + @IsString() @IsOptional() @ApiPropertyOptional({ @@ -26,6 +37,13 @@ export class RescheduleSeatedBookingInput_2024_08_13 { }) start!: string; + @IsOptional() + @ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS }) + @Validate((value: string) => !value || isEmail(value), { + message: "Invalid rescheduledBy email format", + }) + rescheduledBy?: string; + @ApiProperty({ type: String, example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1",