Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
02e13b5
feat: add country code filtering for SMS workflows
devin-ai-integration[bot] Aug 11, 2025
d4e5e39
fix: use all libphonenumber-js supported countries in workflow config…
devin-ai-integration[bot] Sep 8, 2025
428e004
fix: restrict country code selection to SMS_ATTENDEE action only
devin-ai-integration[bot] Sep 8, 2025
8e5f5d9
Move allowed country codes to render for SMS to attendee action
joeauyeung Sep 8, 2025
8597a4d
merge
joeauyeung Sep 9, 2025
b18f24f
Revert "merge"
joeauyeung Sep 9, 2025
a5c90c6
Merge branch 'main' into devin/sms-country-code-filtering-1754928743
joeauyeung Sep 9, 2025
93c686b
Fix merge
joeauyeung Sep 9, 2025
182c7d4
fix: pass workflow data to booking field generation for country code …
devin-ai-integration[bot] Sep 9, 2025
71353df
Merge branch 'devin/sms-country-code-filtering-1754928743' of https:/…
devin-ai-integration[bot] Sep 9, 2025
29673a5
Fix in
joeauyeung Sep 9, 2025
31b4c02
fix: resolve all ESLint warnings with useMemo and useCallback hooks
devin-ai-integration[bot] Sep 9, 2025
ffe86c6
fix: resolve merge conflicts and TypeScript compilation errors in Wor…
devin-ai-integration[bot] Sep 9, 2025
45f5e7e
feat: add help text for country code selection in SMS workflows
devin-ai-integration[bot] Sep 10, 2025
605075b
Remove unused var
joeauyeung Sep 9, 2025
2ea935e
Add intersection of allowed country codes to booking field
joeauyeung Sep 10, 2025
e7081eb
Fix duplicate input
joeauyeung Sep 11, 2025
85b55de
Remove duplicate component
joeauyeung Sep 11, 2025
3a308c1
Undo old change
joeauyeung Sep 11, 2025
095f502
refactor: optimize generateCountryData to use getCountries() from lib…
devin-ai-integration[bot] Sep 11, 2025
e18e579
Do not fail sending SMS message if country code is not allowed
joeauyeung Sep 11, 2025
7a372ea
type fixes
joeauyeung Sep 11, 2025
64bdb77
type fixes
joeauyeung Sep 11, 2025
746cbd2
Type fix in API
joeauyeung Sep 11, 2025
abba4e0
merge
joeauyeung Sep 12, 2025
3a070a5
fix: remove unused profile property from getEventTypesFromDB select
devin-ai-integration[bot] Sep 12, 2025
434098d
fix: add profile back to destructuring with eslint ignore comment
devin-ai-integration[bot] Sep 12, 2025
833787a
Merge branch 'main' into devin/sms-country-code-filtering-1754928743
joeauyeung Sep 12, 2025
5aa4fd9
API V2 type fix
joeauyeung Sep 13, 2025
67973dd
Fix API v2 type error
joeauyeung Sep 13, 2025
9f73b9f
Merge branch into devin/sms-country-code
joeauyeung Sep 24, 2025
dbf5fe7
Eslint fix
joeauyeung Sep 24, 2025
8f28c40
Move country code utils to lib
joeauyeung Sep 24, 2025
ea32e36
Eslint fix
joeauyeung Sep 24, 2025
55f388b
Add allowed country code to booking question dialog
joeauyeung Sep 24, 2025
dee6e56
Fix country dropdown stuck behind dialog
joeauyeung Sep 24, 2025
4eeb9c8
Eslint fix
joeauyeung Sep 24, 2025
662f835
Default to first country if countries are restricted
joeauyeung Sep 24, 2025
a9e0938
fix: auto-prepend country code for single country phone restrictions
devin-ai-integration[bot] Sep 24, 2025
df89000
Revert "fix: auto-prepend country code for single country phone restr…
devin-ai-integration[bot] Sep 24, 2025
f886ee8
Refactor - remove disableCountryCode prop
joeauyeung Sep 24, 2025
882a075
Eslint fixes
joeauyeung Sep 25, 2025
056d830
Add country code if single country is allowed
joeauyeung Sep 25, 2025
97c5c82
Merge branch 'main' into devin/sms-country-code-filtering-1754928743
joeauyeung Sep 25, 2025
aedab18
Remove allowed country code logic from `getBookingFields`
joeauyeung Sep 25, 2025
8fcf65a
Address coderabbit comment
joeauyeung Sep 25, 2025
ea7fe5e
Remove allowed country codes from workflow page
joeauyeung Sep 25, 2025
6ae2e13
Remove allowed countries from new workflow step
joeauyeung Sep 25, 2025
8edef4c
Remove allowed countries from workflow schema
joeauyeung Sep 25, 2025
9ab2a9e
Remove allowed country codes from workflow logic
joeauyeung Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class EventTypeResponseTransformPipe implements PipeTransform {
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
return plainToClass(
EventTypeOutput_2024_06_14,
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType, false),
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Platform changes are eslint fixes. Since we removed the unused param from getResponseEventType

{ strategy: "exposeAll" }
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,7 @@ type Input = Pick<

@Injectable()
export class OutputEventTypesService_2024_06_14 {
getResponseEventType(
ownerId: number,
databaseEventType: Input,
isOrgTeamEvent: boolean
): EventTypeOutput_2024_06_14 {
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
const {
id,
length,
Expand Down Expand Up @@ -138,7 +134,7 @@ export class OutputEventTypesService_2024_06_14 {
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(databaseEventType.bookingFields)
: this.getDefaultBookingFields(isOrgTeamEvent);
: this.getDefaultBookingFields();

const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
Expand Down Expand Up @@ -261,14 +257,13 @@ export class OutputEventTypesService_2024_06_14 {
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}

getDefaultBookingFields(isOrgTeamEvent: boolean) {
getDefaultBookingFields() {
const defaultBookingFields = getBookingFieldsWithSystemFields({
disableGuests: false,
bookingFields: null,
customInputs: [],
metadata: null,
workflows: [],
isOrgTeamEvent,
});
return this.transformBookingFields(defaultBookingFields);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,13 @@ export class OutputOrganizationsEventTypesService {
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly usersRepository: UsersRepository
) {}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
databaseEventType,
isOrgTeamEvent
databaseEventType
);
const hosts =
databaseEventType.schedulingType === "MANAGED"
Expand Down
3 changes: 1 addition & 2 deletions apps/web/lib/booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,11 @@ export const getEventTypesFromDB = async (id: number) => {

const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;

return {
isDynamic: false,
...restEventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
bookingFields: getBookingFieldsWithSystemFields({ ...eventType }),
metadata,
};
};
Expand Down
3 changes: 3 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3670,6 +3670,9 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"allowed_country_codes": "Allowed Country Codes",
"select_country_codes": "Select country codes...",
"country_code_restriction_help": "SMS will only be sent to phone numbers from the selected countries. Leave empty to allow all countries.",
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
Expand Down
8 changes: 3 additions & 5 deletions packages/features/bookings/lib/getBookingFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const getSmsReminderNumberSource = ({
}: {
workflowId: Workflow["id"];
isSmsReminderNumberRequired: boolean;
allowedCountryCodes?: string[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
allowedCountryCodes?: string[];
allowedCountryCodes?: string[];

This is not used

}) => ({
id: `${workflowId}`,
type: "workflow",
Expand Down Expand Up @@ -84,15 +85,13 @@ export const getAIAgentCallPhoneNumberSource = ({
export const getBookingFieldsWithSystemFields = ({
bookingFields,
disableGuests,
isOrgTeamEvent = false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing an unused variable here.

disableBookingTitle,
customInputs,
metadata,
workflows,
}: {
bookingFields: Fields | EventType["bookingFields"];
disableGuests: boolean;
isOrgTeamEvent?: boolean;
disableBookingTitle?: boolean;
customInputs: EventTypeCustomInput[] | z.infer<typeof customInputSchema>[];
metadata: EventType["metadata"] | z.infer<typeof EventTypeMetaDataSchema>;
Expand All @@ -107,7 +106,6 @@ export const getBookingFieldsWithSystemFields = ({
return ensureBookingInputsHaveSystemFields({
bookingFields: parsedBookingFields,
disableGuests,
isOrgTeamEvent,
disableBookingTitle,
additionalNotesRequired: parsedMetaData?.additionalNotesRequired || false,
customInputs: parsedCustomInputs,
Expand All @@ -118,15 +116,13 @@ export const getBookingFieldsWithSystemFields = ({
export const ensureBookingInputsHaveSystemFields = ({
bookingFields,
disableGuests,
isOrgTeamEvent,
disableBookingTitle,
additionalNotesRequired,
customInputs,
workflows,
}: {
bookingFields: Fields;
disableGuests: boolean;
isOrgTeamEvent: boolean;
disableBookingTitle?: boolean;
additionalNotesRequired: boolean;
customInputs: z.infer<typeof customInputSchema>[];
Expand All @@ -147,10 +143,12 @@ export const ensureBookingInputsHaveSystemFields = ({
};

const smsNumberSources = [] as NonNullable<(typeof bookingFields)[number]["sources"]>;

workflows.forEach((workflow) => {
workflow.workflow.steps.forEach((step) => {
if (step.action === "SMS_ATTENDEE" || step.action === "WHATSAPP_ATTENDEE") {
const workflowId = workflow.workflow.id;

smsNumberSources.push(
getSmsReminderNumberSource({
workflowId,
Expand Down
35 changes: 30 additions & 5 deletions packages/features/bookings/lib/getBookingResponsesSchema.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import { isValidPhoneNumber, getCountryCallingCode, type CountryCode } from "libphonenumber-js";
import z from "zod";

import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema, fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";

// eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
type BookingFields = (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
type CommonParams = { bookingFields: BookingFields; view: View };
Expand Down Expand Up @@ -103,15 +102,41 @@ function preprocess<T extends z.ZodType>({
};
try {
parsedValue = JSON.parse(value);
} catch (e) {}
} catch (e) {
console.error("Failed to parse JSON:", e);
}
const optionsInputs = field.optionsInputs;
const optionInputField = optionsInputs?.[parsedValue.value];
if (optionInputField && optionInputField.type === "phone") {
parsedValue.optionValue = ensureValidPhoneNumber(parsedValue.optionValue);
let phoneValue = parsedValue.optionValue;
// Auto-prepend country code for single country restrictions
if (field.allowedCountryCodes?.length === 1 && phoneValue) {
const countryCode = field.allowedCountryCodes[0].toUpperCase();
const dialCode = getCountryCallingCode(countryCode as CountryCode);

// If phoneValue doesn't start with + or doesn't start with the correct country code
if (!phoneValue.startsWith(`+${dialCode}`)) {
// Remove any existing + and prepend the correct country code
phoneValue = `+${dialCode}${phoneValue.replace(/^\+/, "")}`;
}
}
parsedValue.optionValue = ensureValidPhoneNumber(phoneValue);
}
newResponses[field.name] = parsedValue;
} else if (field.type === "phone") {
newResponses[field.name] = ensureValidPhoneNumber(value);
// Auto-prepend country code for single country restrictions
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the single country restriction causes issues for many countries. For example, in Austria if I don’t see +43, I naturally enter my number with a leading 0:
Screenshot 2025-09-30 at 10 57 07 AM

But then we change it to: +430680123456
Screenshot 2025-09-30 at 11 00 01 AM

Which is the wrong international format, as it should be +43 680123456

let phoneValue = value;
if (field.allowedCountryCodes?.length === 1 && phoneValue) {
const countryCode = field.allowedCountryCodes[0].toUpperCase();
const dialCode = getCountryCallingCode(countryCode as CountryCode);

// If phoneValue doesn't start with + or doesn't start with the correct country code
if (!phoneValue.startsWith(`+${dialCode}`)) {
// Remove any existing + and prepend the correct country code
phoneValue = `+${dialCode}${phoneValue.replace(/^\+/, "")}`;
}
}
newResponses[field.name] = ensureValidPhoneNumber(phoneValue);
} else {
newResponses[field.name] = value;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ const _getEventType = async ({
const eventType =
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);

const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId;

return {
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
bookingFields: getBookingFieldsWithSystemFields({ ...eventType }),
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,9 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
throw new Error(ErrorCode.EventTypeNotFound);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { profile, hosts, users, ...restEventType } = eventType;

const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;

const hostsWithSelectedCalendars = hosts.map((host) => ({
...host,
user: withSelectedCalendars(host.user),
Expand All @@ -213,7 +212,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
customInputs: customInputSchema.array().parse(eventType?.customInputs || []),
locations: (eventType?.locations ?? []) as LocationObject[],
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType }),
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
isDynamic: false,
hostGroups: eventType.hostGroups || [],
Expand Down
43 changes: 37 additions & 6 deletions packages/features/components/phone-input/PhoneInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PhoneInputProps = {
disabled?: boolean;
onChange: (value: string) => void;
defaultCountry?: string;
allowedCountryCodes?: string[];
inputStyle?: CSSProperties;
flagButtonStyle?: CSSProperties;
};
Expand All @@ -30,6 +31,7 @@ function BasePhoneInput({
onChange,
value,
defaultCountry = "us",
allowedCountryCodes,
...rest
}: PhoneInputProps) {
const isPlatform = useIsPlatform();
Expand All @@ -53,17 +55,34 @@ function BasePhoneInput({

if (!isPlatform) {
return (
<BasePhoneInputWeb name={name} className={className} onChange={onChange} value={value} {...rest} />
<BasePhoneInputWeb
name={name}
className={className}
onChange={onChange}
value={value}
allowedCountryCodes={allowedCountryCodes}
{...rest}
/>
);
}

const singleCountry = allowedCountryCodes?.length === 1;
const onlyCountries = allowedCountryCodes?.map((code) => code.toLowerCase());

// If country codes are restricted, use the first one as the default
const effectiveDefaultCountry = allowedCountryCodes?.length
? allowedCountryCodes[0].toLowerCase()
: defaultCountry;

return (
<PhoneInput
{...rest}
value={value ? value.trim().replace(/^\+?/, "+") : undefined}
enableSearch
disableSearchIcon
country={defaultCountry}
country={effectiveDefaultCountry}
onlyCountries={onlyCountries && onlyCountries.length > 0 ? onlyCountries : undefined}
disableCountryCode={singleCountry}
inputProps={{
Comment on lines 83 to 86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Confirm react-phone-input-2 prop name: disableCountryCode vs countryCodeEditable.

If the library expects countryCodeEditable={false} (not disableCountryCode), the current prop will be ignored.

Also applies to: 148-149


🌐 Web query:

Does react-phone-input-2 support a `disableCountryCode` prop, or should `countryCodeEditable={false}` be used to lock the dial code prefix?

💡 Result:

Short answer: Use countryCodeEditable={false} to lock the dial-code prefix. The component also exposes disableCountryCode (which tries to keep the dial code out of the input value), but it does not prevent the dial code/auto-guessing behavior reliably — users have reported unexpected behavior and issues. See the prop docs and related GitHub issues. [1][2]

Sources:

  • react-phone-input-2 props documentation (shows disableCountryCode and countryCodeEditable). [1]
  • GitHub issues discussing disableCountryCode bugs and unexpected country-guess behavior. [2]

Use countryCodeEditable={false} instead of disableCountryCode – disableCountryCode only omits the dial code from the value and doesn’t reliably lock the prefix. Update the props in packages/features/components/phone-input/PhoneInput.tsx (lines 86-89, 148-149).

🤖 Prompt for AI Agents
In packages/features/components/phone-input/PhoneInput.tsx around lines 86-89
and 148-149, the component currently passes disableCountryCode which only omits
the dial code and doesn’t lock the prefix; replace disableCountryCode with
countryCodeEditable={false} in the props passed to the phone input so the
country dial code is rendered but not editable, and ensure any related logic or
prop typings are updated accordingly to accept countryCodeEditable.

name,
required: rest.required,
Expand Down Expand Up @@ -101,19 +120,29 @@ function BasePhoneInputWeb({
className = "",
onChange,
value,
allowedCountryCodes,
inputStyle,
flagButtonStyle,
...rest
}: Omit<PhoneInputProps, "defaultCountry">) {
const defaultCountry = useDefaultCountry();
const onlyCountries = allowedCountryCodes?.map((code) => code.toLowerCase());
const singleCountry = allowedCountryCodes?.length === 1;

// If country codes are restricted, use the first one as the default
const effectiveDefaultCountry = allowedCountryCodes?.length
? allowedCountryCodes[0].toLowerCase()
: defaultCountry;

return (
<PhoneInput
{...rest}
value={value ? value.trim().replace(/^\+?/, "+") : undefined}
country={value ? undefined : defaultCountry}
country={value ? undefined : effectiveDefaultCountry}
enableSearch
disableSearchIcon
onlyCountries={onlyCountries && onlyCountries.length > 0 ? onlyCountries : undefined}
disableCountryCode={singleCountry}
inputProps={{
name,
required: rest.required,
Expand Down Expand Up @@ -162,9 +191,11 @@ const useDefaultCountry = () => {
return;
}

isSupportedCountry(data?.countryCode)
? setDefaultCountry(data.countryCode.toLowerCase())
: setDefaultCountry(navigator.language.split("-")[1]?.toLowerCase() || "us");
if (isSupportedCountry(data?.countryCode)) {
setDefaultCountry(data.countryCode.toLowerCase());
} else {
setDefaultCountry(navigator.language.split("-")[1]?.toLowerCase() || "us");
}
},
[query.data]
);
Expand Down
Loading
Loading