feat: Implement phone number management features including listing owned numbers and assigning existing numbers to policies

This commit is contained in:
Nawaz Dhandala
2026-01-17 13:25:12 +00:00
parent 5f2bda119a
commit 272ae08048
5 changed files with 1063 additions and 34 deletions

View File

@@ -3,6 +3,7 @@ import { NotificationWebhookHost } from "../Config";
import {
AvailablePhoneNumber,
ICallProvider,
OwnedPhoneNumber,
PurchasedPhoneNumber,
} from "Common/Types/Call/CallProvider";
import TwilioConfig from "Common/Types/CallAndSMS/TwilioConfig";
@@ -178,6 +179,239 @@ router.post(
},
);
// List owned phone numbers (already purchased in Twilio account)
router.post(
"/list-owned",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body as JSONObject;
const projectId: ObjectID | undefined = body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined;
const projectCallSMSConfigId: ObjectID | undefined = body[
"projectCallSMSConfigId"
]
? new ObjectID(body["projectCallSMSConfigId"] as string)
: undefined;
if (!projectId) {
throw new BadDataException("projectId is required");
}
if (!projectCallSMSConfigId) {
throw new BadDataException(
"projectCallSMSConfigId is required. Please configure a project-level Twilio configuration.",
);
}
// Check if project exists
const project: Project | null = await ProjectService.findOneById({
id: projectId,
select: {
_id: true,
name: true,
},
props: {
isRoot: true,
},
});
if (!project) {
throw new BadDataException("Project not found");
}
// Get project Twilio config
const customTwilioConfig: TwilioConfig | null =
await getProjectTwilioConfig(projectCallSMSConfigId);
if (!customTwilioConfig) {
throw new BadDataException("Project Call/SMS Config not found");
}
const provider: ICallProvider =
CallProviderFactory.getProviderWithConfig(customTwilioConfig);
const numbers: OwnedPhoneNumber[] = await provider.listOwnedNumbers();
type ResponseNumber = {
phoneNumberId: string;
phoneNumber: string;
friendlyName: string;
voiceUrl?: string;
};
const responseNumbers: Array<ResponseNumber> = numbers.map(
(n: OwnedPhoneNumber): ResponseNumber => {
return {
phoneNumberId: n.phoneNumberId,
phoneNumber: n.phoneNumber,
friendlyName: n.friendlyName,
voiceUrl: n.voiceUrl,
};
},
);
return Response.sendJsonObjectResponse(req, res, {
ownedNumbers: responseNumbers,
});
} catch (err) {
logger.error(err);
return next(err);
}
},
);
// Assign an existing phone number to a policy
router.post(
"/assign-existing",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body as JSONObject;
const projectId: ObjectID | undefined = body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined;
const phoneNumberId: string | undefined = body["phoneNumberId"] as
| string
| undefined;
const phoneNumber: string | undefined = body["phoneNumber"] as
| string
| undefined;
const incomingCallPolicyId: ObjectID | undefined = body[
"incomingCallPolicyId"
]
? new ObjectID(body["incomingCallPolicyId"] as string)
: undefined;
if (!projectId) {
throw new BadDataException("projectId is required");
}
if (!phoneNumberId) {
throw new BadDataException("phoneNumberId is required");
}
if (!phoneNumber) {
throw new BadDataException("phoneNumber is required");
}
if (!incomingCallPolicyId) {
throw new BadDataException("incomingCallPolicyId is required");
}
// Check if project exists
const project: Project | null = await ProjectService.findOneById({
id: projectId,
select: {
_id: true,
name: true,
},
props: {
isRoot: true,
},
});
if (!project) {
throw new BadDataException("Project not found");
}
// Check if incoming call policy exists and get its project config
const incomingCallPolicy: IncomingCallPolicy | null =
await IncomingCallPolicyService.findOneById({
id: incomingCallPolicyId,
select: {
_id: true,
projectId: true,
projectCallSMSConfigId: true,
routingPhoneNumber: true,
},
props: {
isRoot: true,
},
});
if (!incomingCallPolicy) {
throw new BadDataException("Incoming Call Policy not found");
}
if (incomingCallPolicy.projectId?.toString() !== projectId.toString()) {
throw new BadDataException(
"Incoming Call Policy does not belong to this project",
);
}
if (incomingCallPolicy.routingPhoneNumber) {
throw new BadDataException(
"This policy already has a phone number. Please release it first.",
);
}
// Require project-level Twilio config
if (!incomingCallPolicy.projectCallSMSConfigId) {
throw new BadDataException(
"This policy does not have a project Twilio configuration. Please configure one first.",
);
}
// Get project Twilio config
const customTwilioConfig: TwilioConfig | null =
await getProjectTwilioConfig(incomingCallPolicy.projectCallSMSConfigId);
if (!customTwilioConfig) {
throw new BadDataException("Project Call/SMS Config not found");
}
const provider: ICallProvider =
CallProviderFactory.getProviderWithConfig(customTwilioConfig);
/*
* Construct webhook URL - single endpoint for all phone numbers
* Twilio sends the "To" phone number in every webhook, so we look up the policy by phone number
*/
const webhookUrl: string = `${NotificationWebhookHost}/notification/incoming-call/voice`;
const assigned: PurchasedPhoneNumber = await provider.assignExistingNumber(
phoneNumberId,
webhookUrl,
);
// Get country code from phone number
const countryCode: string =
Phone.getCountryCodeFromPhoneNumber(phoneNumber);
const areaCode: string = Phone.getAreaCodeFromPhoneNumber(phoneNumber);
/*
* Update the incoming call policy with the assigned number
*/
await IncomingCallPolicyService.updateOneById({
id: incomingCallPolicyId,
data: {
routingPhoneNumber: new Phone(assigned.phoneNumber),
callProviderPhoneNumberId: assigned.phoneNumberId,
phoneNumberCountryCode: countryCode,
phoneNumberAreaCode: areaCode,
phoneNumberPurchasedAt: new Date(),
},
props: {
isRoot: true,
},
});
return Response.sendJsonObjectResponse(req, res, {
success: true,
phoneNumberId: assigned.phoneNumberId,
phoneNumber: assigned.phoneNumber,
});
} catch (err) {
logger.error(err);
return next(err);
}
},
);
// Purchase a phone number
router.post(
"/purchase",

View File

@@ -4,6 +4,7 @@ import {
DialStatusData,
ICallProvider,
IncomingCallData,
OwnedPhoneNumber,
PurchasedPhoneNumber,
SearchNumberOptions,
WebhookRequest,
@@ -74,6 +75,33 @@ export default class TwilioCallProvider implements ICallProvider {
);
}
public async listOwnedNumbers(): Promise<OwnedPhoneNumber[]> {
const numbers: Array<{
sid: string;
phoneNumber: string;
friendlyName: string;
voiceUrl?: string;
}> = await this.client.incomingPhoneNumbers.list({
limit: 100,
});
return numbers.map(
(n: {
sid: string;
phoneNumber: string;
friendlyName: string;
voiceUrl?: string;
}): OwnedPhoneNumber => {
return {
phoneNumberId: n.sid,
phoneNumber: n.phoneNumber,
friendlyName: n.friendlyName,
voiceUrl: n.voiceUrl,
};
},
);
}
public async purchaseNumber(
phoneNumber: string,
webhookUrl: string,
@@ -94,6 +122,26 @@ export default class TwilioCallProvider implements ICallProvider {
};
}
public async assignExistingNumber(
phoneNumberId: string,
webhookUrl: string,
): Promise<PurchasedPhoneNumber> {
// Update the webhook URL for an existing phone number
const updated: Twilio.Twilio["incomingPhoneNumbers"] extends {
(sid: string): { update: (opts: Record<string, unknown>) => Promise<infer R> };
}
? R
: never = await this.client.incomingPhoneNumbers(phoneNumberId).update({
voiceUrl: webhookUrl,
voiceMethod: "POST",
});
return {
phoneNumberId: updated.sid,
phoneNumber: updated.phoneNumber,
};
}
public async releaseNumber(phoneNumberId: string): Promise<void> {
await this.client.incomingPhoneNumbers(phoneNumberId).remove();
}

View File

@@ -15,6 +15,14 @@ export interface PurchasedPhoneNumber {
phoneNumber: string;
}
// Owned phone number (already purchased in Twilio account)
export interface OwnedPhoneNumber {
phoneNumberId: string; // Provider's ID (e.g., Twilio SID)
phoneNumber: string; // "+14155550123"
friendlyName: string; // "(415) 555-0123"
voiceUrl?: string; // Current webhook URL configured
}
// Search options for available numbers
export interface SearchNumberOptions {
countryCode: string;
@@ -61,10 +69,15 @@ export interface ICallProvider {
searchAvailableNumbers(
options: SearchNumberOptions,
): Promise<AvailablePhoneNumber[]>;
listOwnedNumbers(): Promise<OwnedPhoneNumber[]>;
purchaseNumber(
phoneNumber: string,
webhookUrl: string,
): Promise<PurchasedPhoneNumber>;
assignExistingNumber(
phoneNumberId: string,
webhookUrl: string,
): Promise<PurchasedPhoneNumber>;
releaseNumber(phoneNumberId: string): Promise<void>;
updateWebhookUrl(phoneNumberId: string, webhookUrl: string): Promise<void>;

View File

@@ -0,0 +1,700 @@
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import IconProp from "Common/Types/Icon/IconProp";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import { NOTIFICATION_URL } from "Common/UI/Config";
import API from "Common/UI/Utils/API/API";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Card from "Common/UI/Components/Card/Card";
import Icon from "Common/UI/Components/Icon/Icon";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import { DropdownOption } from "Common/UI/Components/Dropdown/Dropdown";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
// Available phone number from search
interface AvailablePhoneNumber {
phoneNumber: string;
friendlyName: string;
locality?: string;
region?: string;
country: string;
}
// Owned phone number (already purchased in Twilio)
interface OwnedPhoneNumber {
phoneNumberId: string;
phoneNumber: string;
friendlyName: string;
voiceUrl?: string;
}
export interface PhoneNumberPurchaseProps {
projectId: ObjectID;
incomingCallPolicyId: ObjectID;
projectCallSMSConfigId?: ObjectID | undefined;
currentPhoneNumber?: string | undefined;
onPhoneNumberPurchased?: () => void;
onPhoneNumberReleased?: () => void;
}
// Country codes supported by Twilio for voice
const COUNTRY_OPTIONS: Array<DropdownOption> = [
{ label: "United States (+1)", value: "US" },
{ label: "United Kingdom (+44)", value: "GB" },
{ label: "Canada (+1)", value: "CA" },
{ label: "Australia (+61)", value: "AU" },
{ label: "Germany (+49)", value: "DE" },
{ label: "France (+33)", value: "FR" },
{ label: "Netherlands (+31)", value: "NL" },
{ label: "Sweden (+46)", value: "SE" },
{ label: "Ireland (+353)", value: "IE" },
{ label: "Belgium (+32)", value: "BE" },
{ label: "Switzerland (+41)", value: "CH" },
{ label: "Austria (+43)", value: "AT" },
{ label: "Spain (+34)", value: "ES" },
{ label: "Italy (+39)", value: "IT" },
{ label: "Poland (+48)", value: "PL" },
{ label: "Portugal (+351)", value: "PT" },
{ label: "Denmark (+45)", value: "DK" },
{ label: "Norway (+47)", value: "NO" },
{ label: "Finland (+358)", value: "FI" },
{ label: "Japan (+81)", value: "JP" },
{ label: "Singapore (+65)", value: "SG" },
{ label: "Hong Kong (+852)", value: "HK" },
{ label: "New Zealand (+64)", value: "NZ" },
{ label: "Brazil (+55)", value: "BR" },
{ label: "Mexico (+52)", value: "MX" },
{ label: "Israel (+972)", value: "IL" },
{ label: "South Africa (+27)", value: "ZA" },
];
const PhoneNumberPurchase: FunctionComponent<PhoneNumberPurchaseProps> = (
props: PhoneNumberPurchaseProps,
): ReactElement => {
const [showSearchModal, setShowSearchModal] = useState<boolean>(false);
const [showReleaseConfirmModal, setShowReleaseConfirmModal] =
useState<boolean>(false);
const [showPurchaseConfirmModal, setShowPurchaseConfirmModal] =
useState<boolean>(false);
const [showSuccessModal, setShowSuccessModal] = useState<boolean>(false);
const [successMessage, setSuccessMessage] = useState<string>("");
const [error, setError] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [availableNumbers, setAvailableNumbers] = useState<
Array<AvailablePhoneNumber>
>([]);
const [selectedNumber, setSelectedNumber] =
useState<AvailablePhoneNumber | null>(null);
// Owned phone numbers state
const [ownedNumbers, setOwnedNumbers] = useState<Array<OwnedPhoneNumber>>([]);
const [selectedOwnedNumber, setSelectedOwnedNumber] =
useState<OwnedPhoneNumber | null>(null);
const [isLoadingOwned, setIsLoadingOwned] = useState<boolean>(false);
const [showAssignConfirmModal, setShowAssignConfirmModal] =
useState<boolean>(false);
const [showOwnedNumbers, setShowOwnedNumbers] = useState<boolean>(false);
useEffect(() => {
setError("");
}, [showSearchModal, showReleaseConfirmModal, showPurchaseConfirmModal, showAssignConfirmModal]);
// Search for available phone numbers
const searchPhoneNumbers = async (values: JSONObject): Promise<void> => {
try {
setIsSearching(true);
setError("");
setAvailableNumbers([]);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(NOTIFICATION_URL.toString()).addRoute(
"/phone-number/search",
),
data: {
projectId: props.projectId.toString(),
projectCallSMSConfigId: props.projectCallSMSConfigId?.toString(),
countryCode: values["countryCode"],
areaCode: values["areaCode"] || undefined,
contains: values["contains"] || undefined,
},
});
if (response.isFailure()) {
setError(API.getFriendlyMessage(response));
setIsSearching(false);
return;
}
const data: JSONObject = response.data as JSONObject;
const numbers: Array<AvailablePhoneNumber> =
(data["availableNumbers"] as Array<AvailablePhoneNumber>) || [];
setAvailableNumbers(numbers);
setIsSearching(false);
setShowSearchModal(false);
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsSearching(false);
}
};
// Purchase a phone number
const purchasePhoneNumber = async (): Promise<void> => {
if (!selectedNumber) {
return;
}
try {
setIsLoading(true);
setError("");
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(NOTIFICATION_URL.toString()).addRoute(
"/phone-number/purchase",
),
data: {
projectId: props.projectId.toString(),
phoneNumber: selectedNumber.phoneNumber,
incomingCallPolicyId: props.incomingCallPolicyId.toString(),
},
});
if (response.isFailure()) {
setError(API.getFriendlyMessage(response));
setIsLoading(false);
return;
}
setIsLoading(false);
setShowPurchaseConfirmModal(false);
setAvailableNumbers([]);
setSelectedNumber(null);
setSuccessMessage(
`Phone number ${selectedNumber.phoneNumber} has been purchased and configured for this policy.`,
);
setShowSuccessModal(true);
if (props.onPhoneNumberPurchased) {
props.onPhoneNumberPurchased();
}
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsLoading(false);
}
};
// Release a phone number
const releasePhoneNumber = async (): Promise<void> => {
try {
setIsLoading(true);
setError("");
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.delete({
url: URL.fromString(NOTIFICATION_URL.toString()).addRoute(
`/phone-number/release/${props.incomingCallPolicyId.toString()}`,
),
});
if (response.isFailure()) {
setError(API.getFriendlyMessage(response));
setIsLoading(false);
return;
}
setIsLoading(false);
setShowReleaseConfirmModal(false);
setSuccessMessage(
"Phone number has been released back to Twilio. You can purchase a new number.",
);
setShowSuccessModal(true);
if (props.onPhoneNumberReleased) {
props.onPhoneNumberReleased();
}
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsLoading(false);
}
};
// Fetch owned phone numbers from Twilio
const fetchOwnedNumbers = async (): Promise<void> => {
try {
setIsLoadingOwned(true);
setError("");
setOwnedNumbers([]);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(NOTIFICATION_URL.toString()).addRoute(
"/phone-number/list-owned",
),
data: {
projectId: props.projectId.toString(),
projectCallSMSConfigId: props.projectCallSMSConfigId?.toString(),
},
});
if (response.isFailure()) {
setError(API.getFriendlyMessage(response));
setIsLoadingOwned(false);
return;
}
const data: JSONObject = response.data as JSONObject;
const numbers: Array<OwnedPhoneNumber> =
(data["ownedNumbers"] as Array<OwnedPhoneNumber>) || [];
setOwnedNumbers(numbers);
setIsLoadingOwned(false);
setShowOwnedNumbers(true);
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsLoadingOwned(false);
}
};
// Assign an existing phone number to this policy
const assignExistingNumber = async (): Promise<void> => {
if (!selectedOwnedNumber) {
return;
}
try {
setIsLoading(true);
setError("");
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(NOTIFICATION_URL.toString()).addRoute(
"/phone-number/assign-existing",
),
data: {
projectId: props.projectId.toString(),
phoneNumberId: selectedOwnedNumber.phoneNumberId,
phoneNumber: selectedOwnedNumber.phoneNumber,
incomingCallPolicyId: props.incomingCallPolicyId.toString(),
},
});
if (response.isFailure()) {
setError(API.getFriendlyMessage(response));
setIsLoading(false);
return;
}
setIsLoading(false);
setShowAssignConfirmModal(false);
setOwnedNumbers([]);
setSelectedOwnedNumber(null);
setShowOwnedNumbers(false);
setSuccessMessage(
`Phone number ${selectedOwnedNumber.phoneNumber} has been assigned and configured for this policy.`,
);
setShowSuccessModal(true);
if (props.onPhoneNumberPurchased) {
props.onPhoneNumberPurchased();
}
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsLoading(false);
}
};
// Render current phone number section
const renderCurrentPhoneNumber = (): ReactElement => {
if (props.currentPhoneNumber) {
return (
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center space-x-3">
<Icon
icon={IconProp.CheckCircle}
className="text-green-500 h-6 w-6"
/>
<div>
<p className="text-sm font-medium text-gray-900">
Current Phone Number
</p>
<p className="text-lg font-semibold text-green-700">
{props.currentPhoneNumber}
</p>
</div>
</div>
<Button
title="Release Number"
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
icon={IconProp.Trash}
onClick={() => {
setShowReleaseConfirmModal(true);
}}
/>
</div>
);
}
return (
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center space-x-3">
<Icon
icon={IconProp.ExclaimationCircle}
className="text-yellow-500 h-6 w-6"
/>
<div>
<p className="text-sm font-medium text-gray-900">
No Phone Number Configured
</p>
<p className="text-sm text-gray-600">
Search and purchase a phone number to enable incoming call
routing.
</p>
</div>
</div>
</div>
);
};
// Render search results
const renderSearchResults = (): ReactElement => {
if (availableNumbers.length === 0) {
return <></>;
}
return (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">
Available Phone Numbers (New)
</h4>
<div className="space-y-2 max-h-80 overflow-y-auto">
{availableNumbers.map(
(number: AvailablePhoneNumber, index: number) => {
return (
<div
key={index}
className="flex items-center justify-between p-3 bg-white border rounded-lg hover:bg-gray-50"
>
<div>
<p className="font-medium text-gray-900">
{number.friendlyName}
</p>
<p className="text-sm text-gray-500">
{number.locality && `${number.locality}, `}
{number.region && `${number.region}, `}
{number.country}
</p>
</div>
<Button
title="Purchase"
buttonStyle={ButtonStyleType.SUCCESS}
icon={IconProp.Add}
onClick={() => {
setSelectedNumber(number);
setShowPurchaseConfirmModal(true);
}}
/>
</div>
);
},
)}
</div>
</div>
);
};
// Render owned numbers list
const renderOwnedNumbers = (): ReactElement => {
if (!showOwnedNumbers) {
return <></>;
}
if (isLoadingOwned) {
return (
<div className="mt-4">
<ComponentLoader />
</div>
);
}
if (ownedNumbers.length === 0) {
return (
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">
No existing phone numbers found in your Twilio account. You can search and purchase a new number instead.
</p>
</div>
);
}
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">
Existing Phone Numbers in Twilio
</h4>
<Button
title="Hide"
buttonStyle={ButtonStyleType.NORMAL}
onClick={() => {
setShowOwnedNumbers(false);
setOwnedNumbers([]);
}}
/>
</div>
<div className="space-y-2 max-h-80 overflow-y-auto">
{ownedNumbers.map((number: OwnedPhoneNumber, index: number) => {
const isInUse: boolean = !!number.voiceUrl;
return (
<div
key={index}
className="flex items-center justify-between p-3 bg-white border rounded-lg hover:bg-gray-50"
>
<div>
<p className="font-medium text-gray-900">
{number.friendlyName}
</p>
<p className="text-xs text-gray-400">{number.phoneNumber}</p>
{isInUse && (
<p className="text-xs text-yellow-600 mt-1">
Currently has a webhook configured
</p>
)}
</div>
<Button
title="Use This"
buttonStyle={ButtonStyleType.SUCCESS_OUTLINE}
icon={IconProp.Check}
onClick={() => {
setSelectedOwnedNumber(number);
setShowAssignConfirmModal(true);
}}
/>
</div>
);
})}
</div>
</div>
);
};
// Check if Twilio config is set
if (!props.projectCallSMSConfigId) {
return (
<Card
title="Purchase Phone Number"
description="Buy a phone number from Twilio to receive incoming calls"
>
<Alert
type={AlertType.WARNING}
title="Twilio Configuration Required"
strongTitle={true}
>
Please link a Twilio configuration to this policy before purchasing a
phone number.
</Alert>
</Card>
);
}
return (
<>
<Card
title="Phone Number"
description="Use an existing Twilio phone number or purchase a new one"
buttons={[
{
title: "Use Existing Number",
buttonStyle: ButtonStyleType.OUTLINE,
icon: IconProp.List,
onClick: () => {
fetchOwnedNumbers();
},
disabled: isLoadingOwned,
},
{
title: "Buy New Number",
buttonStyle: ButtonStyleType.PRIMARY,
icon: IconProp.Add,
onClick: () => {
setShowSearchModal(true);
},
},
]}
>
<div className="p-6">
{renderCurrentPhoneNumber()}
{renderOwnedNumbers()}
{renderSearchResults()}
</div>
</Card>
{/* Search Modal */}
{showSearchModal ? (
<BasicFormModal
title="Search Available Phone Numbers"
description="Search for phone numbers available in your Twilio account. The number will be purchased using your Twilio balance."
formProps={{
name: "Search Phone Numbers",
error: error,
fields: [
{
title: "Country",
description: "Select the country for the phone number",
field: {
countryCode: true,
},
fieldType: FormFieldSchemaType.Dropdown,
dropdownOptions: COUNTRY_OPTIONS,
required: true,
placeholder: "Select a country",
},
{
title: "Area Code (Optional)",
description:
"Specify an area code to narrow down the search (e.g., 415 for San Francisco)",
field: {
areaCode: true,
},
fieldType: FormFieldSchemaType.Text,
required: false,
placeholder: "415",
},
{
title: "Contains (Optional)",
description:
"Search for numbers containing specific digits (e.g., 555)",
field: {
contains: true,
},
fieldType: FormFieldSchemaType.Text,
required: false,
placeholder: "555",
},
],
}}
submitButtonText="Search"
onClose={() => {
setShowSearchModal(false);
setError("");
}}
isLoading={isSearching}
onSubmit={searchPhoneNumbers}
/>
) : (
<></>
)}
{/* Release Confirmation Modal */}
{showReleaseConfirmModal ? (
<ConfirmModal
title="Release Phone Number"
description={`Are you sure you want to release the phone number ${props.currentPhoneNumber}? This action will return the number to Twilio and it may not be available for re-purchase.`}
error={error}
submitButtonText="Release Number"
submitButtonType={ButtonStyleType.DANGER}
onClose={() => {
setShowReleaseConfirmModal(false);
setError("");
}}
isLoading={isLoading}
onSubmit={releasePhoneNumber}
/>
) : (
<></>
)}
{/* Purchase Confirmation Modal */}
{showPurchaseConfirmModal && selectedNumber ? (
<ConfirmModal
title="Confirm Purchase"
description={`Are you sure you want to purchase ${selectedNumber.friendlyName}? This will be charged to your Twilio account.`}
error={error}
submitButtonText="Purchase"
submitButtonType={ButtonStyleType.SUCCESS}
onClose={() => {
setShowPurchaseConfirmModal(false);
setSelectedNumber(null);
setError("");
}}
isLoading={isLoading}
onSubmit={purchasePhoneNumber}
/>
) : (
<></>
)}
{/* Assign Existing Number Confirmation Modal */}
{showAssignConfirmModal && selectedOwnedNumber ? (
<ConfirmModal
title="Assign Phone Number"
description={`Are you sure you want to use ${selectedOwnedNumber.friendlyName} for this policy? ${selectedOwnedNumber.voiceUrl ? "This number currently has a webhook configured which will be updated to point to OneUptime." : "The webhook will be configured automatically."}`}
error={error}
submitButtonText="Assign Number"
submitButtonType={ButtonStyleType.SUCCESS}
onClose={() => {
setShowAssignConfirmModal(false);
setSelectedOwnedNumber(null);
setError("");
}}
isLoading={isLoading}
onSubmit={assignExistingNumber}
/>
) : (
<></>
)}
{/* Success Modal */}
{showSuccessModal ? (
<ConfirmModal
title="Success"
description={successMessage}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
onSubmit={() => {
setShowSuccessModal(false);
setSuccessMessage("");
}}
/>
) : (
<></>
)}
{/* Loading State */}
{isLoading && !showReleaseConfirmModal && !showPurchaseConfirmModal && !showAssignConfirmModal ? (
<ComponentLoader />
) : (
<></>
)}
{/* Error State */}
{error &&
!showSearchModal &&
!showReleaseConfirmModal &&
!showPurchaseConfirmModal &&
!showAssignConfirmModal ? (
<ErrorMessage message={error} />
) : (
<></>
)}
</>
);
};
export default PhoneNumberPurchase;

View File

@@ -3,7 +3,7 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import IncomingCallPolicy from "Common/Models/DatabaseModels/IncomingCallPolicy";
import ProjectCallSMSConfig from "Common/Models/DatabaseModels/ProjectCallSMSConfig";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import React, { Fragment, FunctionComponent, ReactElement, useState } from "react";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import FieldType from "Common/UI/Components/Types/FieldType";
@@ -18,11 +18,62 @@ import PageMap from "../../../Utils/PageMap";
import Route from "Common/Types/API/Route";
import IconProp from "Common/Types/Icon/IconProp";
import Icon from "Common/UI/Components/Icon/Icon";
import PhoneNumberPurchase from "../../../Components/CallSMS/PhoneNumberPurchase";
import ProjectUtil from "Common/UI/Utils/Project";
import useAsyncEffect from "use-async-effect";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import API from "Common/UI/Utils/API/API";
const IncomingCallPolicyView: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
const projectId: ObjectID = ProjectUtil.getCurrentProjectId()!;
const [policy, setPolicy] = useState<IncomingCallPolicy | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
// Fetch policy data
useAsyncEffect(async () => {
try {
setIsLoading(true);
setError("");
const fetchedPolicy: IncomingCallPolicy | null = await ModelAPI.getItem({
modelType: IncomingCallPolicy,
id: modelId,
select: {
routingPhoneNumber: true,
projectCallSMSConfigId: true,
projectCallSMSConfig: {
name: true,
},
},
});
setPolicy(fetchedPolicy);
setIsLoading(false);
} catch (err) {
setError(API.getFriendlyMessage(err));
setIsLoading(false);
}
}, [modelId, refreshToggle]);
const handlePhoneNumberChange = (): void => {
setRefreshToggle(!refreshToggle);
};
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
@@ -302,7 +353,7 @@ const IncomingCallPolicyView: FunctionComponent<
</div>
</div>
{/* Step 3 */}
{/* Step 3 - Purchase Phone Number */}
<div className="flex space-x-4">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">
@@ -310,38 +361,9 @@ const IncomingCallPolicyView: FunctionComponent<
</div>
</div>
<div className="flex-1">
<h5 className="font-medium text-gray-900">Purchase a Phone Number from Twilio</h5>
<p className="text-sm text-gray-600 mt-1">
Visit the{" "}
<Link
to={new Route("https://console.twilio.com/us1/develop/phone-numbers/manage/incoming")}
openInNewTab={true}
className="text-blue-600 hover:underline font-medium"
>
Twilio Console Phone Numbers
</Link>{" "}
to purchase a phone number. Choose a number with voice capabilities.
</p>
</div>
</div>
{/* Step 4 */}
<div className="flex space-x-4">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">
4
</div>
</div>
<div className="flex-1">
<h5 className="font-medium text-gray-900">Configure Webhook URL in Twilio</h5>
<p className="text-sm text-gray-600 mt-1">
In the Twilio Console, configure your phone number&apos;s voice webhook URL to point to your OneUptime instance:
</p>
<div className="mt-2 p-3 bg-gray-100 rounded font-mono text-sm break-all">
https://your-oneuptime-domain/notification/incoming-call/voice
</div>
<p className="text-xs text-gray-500 mt-2">
Replace &quot;your-oneuptime-domain&quot; with your actual OneUptime instance domain.
<h5 className="font-medium text-gray-900">Purchase a Phone Number</h5>
<p className="text-sm text-gray-600 mt-1 mb-3">
Search and purchase a phone number from Twilio. The webhook will be configured automatically.
</p>
</div>
</div>
@@ -349,6 +371,18 @@ const IncomingCallPolicyView: FunctionComponent<
</div>
</Card>
</div>
{/* Phone Number Purchase Card */}
<div className="mt-5">
<PhoneNumberPurchase
projectId={projectId}
incomingCallPolicyId={modelId}
projectCallSMSConfigId={policy?.projectCallSMSConfigId}
currentPhoneNumber={policy?.routingPhoneNumber?.toString()}
onPhoneNumberPurchased={handlePhoneNumberChange}
onPhoneNumberReleased={handlePhoneNumberChange}
/>
</div>
</Fragment>
);
};