"use client"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { createPhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPost, getWorkflowsSummaryApiV1WorkflowSummaryGet, updatePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdPut, } from "@/client/sdk.gen"; import type { PhoneNumberResponse } from "@/client/types.gen"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { useAuth } from "@/lib/auth"; interface PhoneNumberDialogProps { open: boolean; onOpenChange: (open: boolean) => void; configId: number; existing?: PhoneNumberResponse | null; onSaved: () => void; } const NO_WORKFLOW = "__none__"; // Mirrors api/schemas/telephony_phone_number.py::_validate_address_shape and // api/utils/telephony_address.py — keep in sync. Returns an error message // when the address would normalize to a broken canonical form, or null when // the input is acceptable. const ADDRESS_FORMAT_STRIP_RE = /[\s\-()]/g; const ADDRESS_E164_RE = /^\+\d{8,15}$/; const ADDRESS_BARE_DIGITS_RE = /^\d{8,15}$/; function validateAddress(rawAddress: string, countryCode: string): string | null { const trimmed = rawAddress.trim(); if (!trimmed) return "Address is required"; if (/^sips?:/i.test(trimmed)) return null; const stripped = trimmed.replace(ADDRESS_FORMAT_STRIP_RE, ""); if (ADDRESS_E164_RE.test(stripped)) return null; if (ADDRESS_BARE_DIGITS_RE.test(stripped) && !countryCode.trim()) { return "PSTN addresses without a leading '+' need a Country (ISO-2) hint, or include the country code in the address (e.g. +14155551234)."; } return null; } export function PhoneNumberDialog({ open, onOpenChange, configId, existing, onSaved, }: PhoneNumberDialogProps) { const { user, getAccessToken } = useAuth(); const isEdit = !!existing; const [address, setAddress] = useState(""); const [countryCode, setCountryCode] = useState(""); const [label, setLabel] = useState(""); const [isActive, setIsActive] = useState(true); const [isDefaultCallerId, setIsDefaultCallerId] = useState(false); const [inboundWorkflowId, setInboundWorkflowId] = useState(NO_WORKFLOW); const [workflows, setWorkflows] = useState<{ id: number; name: string }[]>([]); const [submitting, setSubmitting] = useState(false); const [addressTouched, setAddressTouched] = useState(false); // Reset form when the dialog opens. useEffect(() => { if (!open) return; setAddress(existing?.address ?? ""); setCountryCode(existing?.country_code ?? ""); setLabel(existing?.label ?? ""); setIsActive(existing?.is_active ?? true); setIsDefaultCallerId(existing?.is_default_caller_id ?? false); setInboundWorkflowId( existing?.inbound_workflow_id ? String(existing.inbound_workflow_id) : NO_WORKFLOW, ); setAddressTouched(false); }, [open, existing]); // Only validate the address on create — edits keep the immutable address. const addressError = isEdit ? null : validateAddress(address, countryCode); // Load workflows for the inbound dropdown. useEffect(() => { if (!open || !user) return; let cancelled = false; (async () => { const token = await getAccessToken(); const res = await getWorkflowsSummaryApiV1WorkflowSummaryGet({ headers: { Authorization: `Bearer ${token}` }, query: { status: "active" }, }); if (cancelled) return; const items = res.data ?? []; setWorkflows(items.map((w) => ({ id: w.id, name: w.name }))); })(); return () => { cancelled = true; }; }, [open, user, getAccessToken]); const handleSubmit = async () => { if (!isEdit) { const err = validateAddress(address, countryCode); if (err) { setAddressTouched(true); toast.error(err); return; } } setSubmitting(true); try { const token = await getAccessToken(); const inboundId = inboundWorkflowId === NO_WORKFLOW ? null : Number(inboundWorkflowId); let providerSync: PhoneNumberResponse["provider_sync"] | undefined; if (isEdit && existing) { const res = await updatePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdPut( { headers: { Authorization: `Bearer ${token}` }, path: { config_id: configId, phone_number_id: existing.id }, body: { label: label || undefined, is_active: isActive, country_code: countryCode || undefined, inbound_workflow_id: inboundId ?? undefined, clear_inbound_workflow: inboundId === null, }, }, ); if (res.error) throw new Error(detailFromError(res.error)); providerSync = res.data?.provider_sync; toast.success("Phone number updated"); } else { const res = await createPhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPost( { headers: { Authorization: `Bearer ${token}` }, path: { config_id: configId }, body: { address: address.trim(), country_code: countryCode || undefined, label: label || undefined, is_active: isActive, is_default_caller_id: isDefaultCallerId, inbound_workflow_id: inboundId ?? undefined, }, }, ); if (res.error) throw new Error(detailFromError(res.error)); providerSync = res.data?.provider_sync; toast.success("Phone number added"); } if (providerSync && !providerSync.ok) { toast.warning( providerSync.message ?? "Saved, but failed to sync inbound webhook to the provider.", ); } onOpenChange(false); onSaved(); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to save phone number"); } finally { setSubmitting(false); } }; return ( {isEdit ? "Edit phone number" : "Add phone number"} PSTN numbers (E.164), SIP URIs (sip:user@host), and SIP extensions are all supported.
setAddress(e.target.value)} onBlur={() => setAddressTouched(true)} disabled={isEdit} aria-invalid={addressTouched && !!addressError} /> {!isEdit && addressTouched && addressError && (

{addressError}

)} {isEdit && (

Address cannot be changed. Delete this number and create a new one to change it.

)} {isEdit && (

Stored as {existing?.address_normalized} ({existing?.address_type})

)}
setCountryCode(e.target.value.toUpperCase())} />
setLabel(e.target.value)} />

Used when per-number inbound routing is enabled. Today, inbound calls still route by the workflow_id in the webhook URL.

{!isEdit && (

Used as the from-number for test calls when set.

)}
); } function detailFromError(err: unknown): string { if (typeof err === "string") return err; const e = err as { detail?: unknown }; if (typeof e?.detail === "string") return e.detail; if (Array.isArray(e?.detail) && e.detail.length > 0) { const first = e.detail[0] as { msg?: string }; if (first?.msg) return first.msg; } return "Failed to save phone number"; }