fix: v2 rescheduled booking with confirmation (#19833)

* fix: old bookings controller

* fix: new bookings controller

* fix: BookerPlatformWrapper pass rescheduledBy to BookerComponent

* rescheduleBy email

* fix test

* remove log

* centralize getOAuthUserEmail, examples app rescheduledBy

* revert

* finalize

* fix: old bookings rescheduling

* unify email validation

* update input description

* chore: move booker platform wrapper types

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
Lauris Skraucis
2025-03-11 20:04:59 +01:00
committed by GitHub
co-authored by Morgan Vernay
parent ff5c6de5da
commit 76b9b9b0fe
16 changed files with 315 additions and 220 deletions
@@ -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<number | undefined> {
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<string | undefined> {
if (!eventTypeId) {
return undefined;
@@ -396,7 +423,9 @@ export class BookingsController_2024_04_15 {
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
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;
@@ -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);
@@ -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;
@@ -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();
});
});
});
});
@@ -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);
@@ -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) {
@@ -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;
@@ -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);
@@ -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}`;
}
@@ -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";
@@ -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<string, string | string[]>;
handleCreateBooking?: (input: UseCreateBookingInput) => void;
onCreateBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
onCreateBookingError?: (data: ApiErrorResponse | Error) => void;
onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse<BookingResponse[]>) => void;
onCreateRecurringBookingError?: (data: ApiErrorResponse | Error) => void;
onCreateInstantBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
onCreateInstantBookingError?: (data: ApiErrorResponse | Error) => void;
onReserveSlotSuccess?: (data: ApiSuccessResponse<string>) => void;
onReserveSlotError?: (data: ApiErrorResponse) => void;
onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void;
onDeleteSlotError?: (data: ApiErrorResponse) => void;
locationUrl?: string;
view?: VIEW_TYPE;
metadata?: Record<string, string>;
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=""
+14
View File
@@ -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;
};
+1 -1
View File
@@ -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.
@@ -118,7 +118,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
</div>
</div>
)}
{!!booking.bookingFieldsResponses?.notes && (
{"bookingFieldsResponses" in booking && !!booking.bookingFieldsResponses?.notes && (
<div className="flex gap-[70px]">
<div className="w-[40px]">
<h4>Additional notes</h4>
@@ -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
@@ -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 && (
<Booker
rescheduleUid={rescheduleUid}
rescheduledBy={rescheduledBy}
eventSlug={eventTypeSlugQueryParam}
username={props.calUsername ?? ""}
onCreateBookingSuccess={(data) => {
@@ -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",