feat: support phone based booking api (#17635)

* chore: save progress

* feat: support phone based booking using API v2

* chore: change name of the variable

* fix: update event types and booking fields for improved validation and new properties

- Updated imports in input and output event types services to use the correct library version.
- Added new `avatarUrl` and `successRedirectUrl` properties to the Swagger documentation for user and booking schemas.
- Enhanced booking fields validation by changing the `required` property to a boolean type.
- Improved descriptions and examples in the OpenAPI specifications for better clarity.
- Refactored booking fields input and output classes to include new properties and ensure proper validation.

* fix: booking with phone number

* test: add event type update test

* test: add test for creating booking

* chore: update unit test

* fix: tests

* fix: add turbo

* chore: remove emial

* refactor: make email optional and move it to service

* fix: test

* chore: bump platform-libraries

* fix: e2e test

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
This commit is contained in:
Udit Takkarandsupalarry authored and GitHub committed 2025-01-08 21:16:10 +05:30
1 parent 20a5775f31
commit c9c5493de6
19 files changed
+6318 -101

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.79",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.81",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -59,6 +59,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let team1EventTypeId: number;
let team2EventTypeId: number;
let phoneOnlyEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -205,6 +206,83 @@ describe("Bookings Endpoints 2024-08-13", () => {
team1EventTypeId = team1EventType.id;
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: "Phone Only Event Type",
slug: "phone-only-event-type",
length: 15,
assignAllTeamMembers: false,
hosts: {
connectOrCreate: [
{
where: {
userId_eventTypeId: {
userId: teamUser.id,
eventTypeId: team1EventTypeId,
},
},
create: {
userId: teamUser.id,
isFixed: true,
},
},
],
},
bookingFields: [
{
name: "name",
type: "name",
label: "your name",
sources: [{ id: "default", type: "default", label: "Default" }],
variant: "fullName",
editable: "system",
required: true,
defaultLabel: "your_name",
variantsConfig: {
variants: {
fullName: {
fields: [{ name: "fullName", type: "text", label: "your name", required: true }],
},
},
},
},
{
name: "email",
type: "email",
label: "your email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
defaultLabel: "email_address",
},
{
name: "attendeePhoneNumber",
type: "phone",
label: "phone_number",
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: true,
placeholder: "",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
locations: [],
});
phoneOnlyEventTypeId = phoneOnlyEventType.id;
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
@@ -322,6 +400,60 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should create a phone based booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: phoneOnlyEventTypeId,
attendee: {
name: "alice",
phoneNumber: "+919876543210",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
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);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(phoneOnlyEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: "919876543210@sms.cal.com",
phoneNumber: body.attendee.phoneNumber,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a team 2 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
@@ -398,7 +530,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data.length).toEqual(2);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
@@ -436,7 +568,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.length).toEqual(3);
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
});
@@ -13,7 +13,6 @@ import { Request } from "express";
import { z } from "zod";
import {
handleNewBooking,
handleNewRecurringBooking,
getAllUserBookings,
handleInstantMeeting,
@@ -23,6 +22,7 @@ import {
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -160,9 +160,14 @@ export class InputBookingsService_2024_08_13 {
? {
...inputBooking.bookingFieldsResponses,
name: inputBooking.attendee.name,
email: inputBooking.attendee.email,
email: inputBooking.attendee.email ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
}
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
: {
name: inputBooking.attendee.name,
email: inputBooking.attendee.email ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
},
};
}
@@ -72,6 +72,7 @@ type DatabaseBooking = Booking & {
email: string;
timeZone: string;
locale: string | null;
phoneNumber?: string | null;
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
@@ -121,6 +122,7 @@ export class OutputBookingsService_2024_08_13 {
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
phoneNumber: attendee.phoneNumber ?? undefined,
})),
guests: bookingResponses.guests,
location,
@@ -128,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(databaseEventType.bookingFields)
: this.getDefaultBookingFields(isOrgTeamEvent);
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users || []);
@@ -577,6 +577,98 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("should be able to configure phone-only event type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
bookingFields: [
{
type: "email",
required: false,
label: "Email",
},
{
type: "phone",
slug: "attendeePhoneNumber",
required: true,
label: "Phone number",
},
],
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.bookingFields).toEqual([
{
isDefault: true,
type: "name",
slug: "name",
required: true,
disableOnPrefill: false,
},
{
isDefault: true,
type: "email",
slug: "email",
required: false,
label: "Email",
disableOnPrefill: false,
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "phone",
slug: "attendeePhoneNumber",
required: true,
hidden: true,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: false,
},
]);
});
});
it("should assign all members to managed event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
assignAllTeamMembers: true,
+21 -8
View File
@@ -7778,6 +7778,9 @@
"label": {
"type": "string"
},
"required": {
"type": "object"
},
"placeholder": {
"type": "string"
},
@@ -7789,6 +7792,7 @@
"required": [
"type",
"label",
"required",
"placeholder"
]
},
@@ -8488,6 +8492,10 @@
"label": {
"type": "string"
},
"required": {
"type": "object",
"default": true
},
"placeholder": {
"type": "string"
},
@@ -8504,16 +8512,13 @@
"slug": {
"type": "string",
"default": "email"
},
"required": {
"type": "boolean"
}
},
"required": [
"type",
"required",
"isDefault",
"slug",
"required"
"slug"
]
},
"NameDefaultFieldOutput_2024_06_14": {
@@ -10112,7 +10117,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -12538,7 +12543,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -14471,6 +14476,11 @@
"description": "The time zone of the attendee.",
"example": "America/New_York"
},
"phoneNumber": {
"type": "string",
"description": "The phone number of the attendee in international format.",
"example": "+919876543210"
},
"language": {
"type": "string",
"enum": [
@@ -14524,7 +14534,6 @@
},
"required": [
"name",
"email",
"timeZone"
]
},
@@ -15132,6 +15141,10 @@
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
},
"seatUid": {
"type": "string",
"example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1"
+21 -8
View File
@@ -7206,6 +7206,9 @@
"label": {
"type": "string"
},
"required": {
"type": "object"
},
"placeholder": {
"type": "string"
},
@@ -7214,7 +7217,7 @@
"description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&email=bob@gmail.com`, the email field will be prefilled with this value and disabled."
}
},
"required": ["type", "label", "placeholder"]
"required": ["type", "label", "required", "placeholder"]
},
"TitleDefaultFieldInput_2024_06_14": {
"type": "object",
@@ -7858,6 +7861,10 @@
"label": {
"type": "string"
},
"required": {
"type": "object",
"default": true
},
"placeholder": {
"type": "string"
},
@@ -7874,12 +7881,9 @@
"slug": {
"type": "string",
"default": "email"
},
"required": {
"type": "boolean"
}
},
"required": ["type", "isDefault", "slug", "required"]
"required": ["type", "required", "isDefault", "slug"]
},
"NameDefaultFieldOutput_2024_06_14": {
"type": "object",
@@ -9307,7 +9311,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -11433,7 +11437,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -13017,6 +13021,11 @@
"description": "The time zone of the attendee.",
"example": "America/New_York"
},
"phoneNumber": {
"type": "string",
"description": "The phone number of the attendee in international format.",
"example": "+919876543210"
},
"language": {
"type": "string",
"enum": [
@@ -13068,7 +13077,7 @@
"default": "en"
}
},
"required": ["name", "email", "timeZone"]
"required": ["name", "timeZone"]
},
"CreateBookingInput_2024_08_13": {
"type": "object",
@@ -13628,6 +13637,10 @@
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
},
"seatUid": {
"type": "string",
"example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1"
@@ -162,7 +162,7 @@ function preprocess<T extends z.ZodType>({
}
if (bookingField.type === "email") {
if (!bookingField.hidden) {
if (!bookingField.hidden && bookingField.required) {
// Email RegExp to validate if the input is a valid email
if (!emailSchema.safeParse(value).success) {
ctx.addIssue({
@@ -9,7 +9,7 @@ import { SMSLockState } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[twilioProvider]"] });
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE || process.env.IS_E2E;
function createTwilioClient() {
if (process.env.TWILIO_SID && process.env.TWILIO_TOKEN && process.env.TWILIO_MESSAGING_SID) {
@@ -187,6 +187,7 @@ describe("transformBookingFieldsApiToInternal", () => {
label: "Your email",
placeholder: "bob@gmail.com",
disableOnPrefill: true,
required: true,
};
const input: InputBookingField_2024_06_14[] = [bookingField];
@@ -113,6 +113,7 @@ function getBaseProperties(field: InputBookingField): CustomField | SystemField
label: field.label,
placeholder: field.placeholder,
disableOnPrefill: !!field.disableOnPrefill,
required: field.required,
};
}
@@ -1,25 +1,52 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import type { ValidationArguments, ValidationOptions } from "class-validator";
import {
IsInt,
IsDateString,
IsTimeZone,
IsEnum,
IsEmail,
ValidateNested,
IsArray,
IsString,
isEmail,
IsOptional,
IsUrl,
IsObject,
IsBoolean,
Min,
registerDecorator,
Validate,
} from "class-validator";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { BookingLanguageType } from "./language";
import { BookingLanguage } from "./language";
import { ValidateMetadata } from "./validators/validate-metadata";
function RequireEmailOrPhone(validationOptions?: ValidationOptions) {
return function (target: object, propertyName: string) {
registerDecorator({
name: "requireEmailOrPhone",
target: target.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const obj = args.object as Attendee;
const hasPhoneNumber = !!obj.phoneNumber && obj.phoneNumber.trim().length > 0;
const hasEmail = !!obj.email && obj.email.trim().length > 0;
return hasPhoneNumber || hasEmail;
},
defaultMessage(): string {
return "At least one contact method (email or phone number) must be provided";
},
},
});
};
}
class Attendee {
@ApiProperty({
type: String,
@@ -29,13 +56,17 @@ class Attendee {
@IsString()
name!: string;
@ApiProperty({
@ApiPropertyOptional({
type: String,
description: "The email of the attendee.",
example: "john.doe@example.com",
})
@IsEmail()
email!: string;
@IsOptional()
@Validate((value: string) => !value || isEmail(value), {
message: "Invalid email format",
})
@RequireEmailOrPhone()
email?: string;
@ApiProperty({
type: String,
@@ -45,6 +76,17 @@ class Attendee {
@IsTimeZone()
timeZone!: string;
@ApiPropertyOptional({
type: String,
description: "The phone number of the attendee 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 attendee. Used for booking confirmation.",
@@ -44,6 +44,12 @@ class Attendee {
@IsBoolean()
@Expose()
absent!: boolean;
@ApiPropertyOptional({ type: String, example: "+1234567890" })
@IsString()
@Expose()
@IsOptional()
phoneNumber?: string;
}
export class SeatedAttendee extends Attendee {
@@ -66,6 +66,11 @@ export class EmailDefaultFieldInput_2024_06_14 {
@DocsProperty()
label?: string;
@IsBoolean()
@IsOptional()
@DocsProperty()
required = true;
@IsString()
@IsOptional()
@DocsProperty()
@@ -379,9 +379,9 @@ export class UpdateEventTypeInput_2024_06_14 {
@IsOptional()
@IsString()
@DocsPropertyOptional({
description: `Customizable event name with valid variables:
{Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},
{Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},
description: `Customizable event name with valid variables:
{Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},
{Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},
{HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}`,
example: "{Event type title} between {Organiser} and {Scheduler}",
})
+1
View File
@@ -323,6 +323,7 @@
"HUBSPOT_CLIENT_ID",
"HUBSPOT_CLIENT_SECRET",
"INTEGRATION_TEST_MODE",
"IS_E2E",
"INTERCOM_SECRET",
"INTERCOM_SECRET",
"INSIGHTS_DATABASE_URL",
+5973 -70
View File
File diff suppressed because it is too large. Load diff