mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Implement phone number management features including listing owned numbers and assigning existing numbers to policies
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
700
Dashboard/src/Components/CallSMS/PhoneNumberPurchase.tsx
Normal file
700
Dashboard/src/Components/CallSMS/PhoneNumberPurchase.tsx
Normal 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;
|
||||
@@ -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'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 "your-oneuptime-domain" 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user