feat: refactor telephony to support multiple telephony configurations (#251)

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-04-29 11:39:57 +05:30 committed by GitHub
parent 2f860e7f6d
commit e16f6438bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 10906 additions and 5420 deletions

View file

@ -0,0 +1,340 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost,
getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet,
updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut,
} from "@/client/sdk.gen";
import type {
TelephonyConfigurationCreateRequest,
TelephonyConfigurationDetail,
TelephonyProviderMetadata,
} from "@/client/types.gen";
type TelephonyConfigPayload = TelephonyConfigurationCreateRequest["config"];
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 { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
interface ConfigFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// When provided, the dialog is in edit mode.
existing?: TelephonyConfigurationDetail | null;
onSaved: () => void;
}
type FieldValues = Record<string, string | number | undefined>;
export function ConfigFormDialog({
open,
onOpenChange,
existing,
onSaved,
}: ConfigFormDialogProps) {
const { user, getAccessToken } = useAuth();
const [providers, setProviders] = useState<TelephonyProviderMetadata[]>([]);
const [providerName, setProviderName] = useState<string>("");
const [name, setName] = useState<string>("");
const [isDefault, setIsDefault] = useState<boolean>(false);
const [values, setValues] = useState<FieldValues>({});
const [submitting, setSubmitting] = useState<boolean>(false);
const isEdit = !!existing;
const lockedProvider = isEdit;
const currentProvider = useMemo(
() => providers.find((p) => p.provider === providerName),
[providers, providerName],
);
// Fetch provider metadata once when the dialog opens.
useEffect(() => {
if (!open || !user) return;
let cancelled = false;
(async () => {
const token = await getAccessToken();
const res = await getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet(
{ headers: { Authorization: `Bearer ${token}` } },
);
if (cancelled) return;
const list = res.data?.providers ?? [];
setProviders(list);
if (existing) {
setProviderName(existing.provider);
setName(existing.name);
setIsDefault(existing.is_default_outbound);
setValues((existing.credentials ?? {}) as FieldValues);
} else if (list.length > 0 && !providerName) {
setProviderName(list[0].provider);
setValues({});
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, existing, user, getAccessToken]);
// When provider changes during create, clear field values.
useEffect(() => {
if (!isEdit) setValues({});
}, [providerName, isEdit]);
const updateField = (fieldName: string, value: string | number) => {
setValues((prev) => ({ ...prev, [fieldName]: value }));
};
const handleSubmit = async () => {
if (!currentProvider) return;
if (!isEdit && !name.trim()) {
toast.error("Name is required");
return;
}
setSubmitting(true);
try {
const token = await getAccessToken();
// Build the provider-discriminated config payload from collected values.
const configPayload = {
provider: providerName,
...values,
} as unknown as TelephonyConfigPayload;
if (isEdit && existing) {
const res = await updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: existing.id },
body: { name: name || undefined, config: configPayload },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Configuration updated");
} else {
const res = await createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost(
{
headers: { Authorization: `Bearer ${token}` },
body: {
name: name.trim(),
is_default_outbound: isDefault,
config: configPayload,
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Configuration created");
}
onOpenChange(false);
onSaved();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit telephony configuration" : "Add telephony configuration"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update credentials for this configuration. Phone numbers are managed separately."
: "Connect a telephony provider account. Phone numbers are added after the configuration is created."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cfg-name">Name</Label>
<Input
id="cfg-name"
placeholder="e.g. Twilio US prod"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cfg-provider">Provider</Label>
<Select
value={providerName}
onValueChange={setProviderName}
disabled={lockedProvider || providers.length === 0}
>
<SelectTrigger id="cfg-provider">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.provider} value={p.provider}>
{p.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
{lockedProvider && (
<p className="text-xs text-muted-foreground">
Provider cannot be changed after creation.
</p>
)}
{currentProvider?.docs_url && (
<a
href={currentProvider.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 underline"
>
{currentProvider.display_name} docs <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{!isEdit && (
<div className="flex items-center justify-between rounded border p-3">
<div>
<Label className="text-sm">Set as default for outbound calls</Label>
<p className="text-xs text-muted-foreground">
Used by test calls and campaigns when no specific config is selected.
</p>
</div>
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
</div>
)}
{currentProvider && (
<div className="space-y-3 border-t pt-3">
{currentProvider.fields.map((field) => (
<div className="space-y-1" key={field.name}>
<Label htmlFor={`cfg-field-${field.name}`}>
{field.label}
{!field.required && (
<span className="ml-1 text-xs text-muted-foreground">
(optional)
</span>
)}
</Label>
<FieldInput
field={field}
value={values[field.name]}
onChange={(v) => updateField(field.name, v)}
isEdit={isEdit}
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting || !currentProvider}>
{submitting ? "Saving..." : isEdit ? "Save changes" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface FieldInputProps {
field: TelephonyProviderMetadata["fields"][number];
value: string | number | undefined;
onChange: (v: string | number) => void;
isEdit: boolean;
}
// Skip from_numbers in the metadata-driven form — phone numbers are managed
// via the dedicated phone-numbers endpoints and a different UI.
function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
if (field.name === "from_numbers") {
return (
<p className="text-xs text-muted-foreground">
Phone numbers are managed separately on the configuration page.
</p>
);
}
const placeholder =
field.placeholder ??
(field.sensitive && isEdit ? "Leave masked to keep existing" : "");
if (field.type === "textarea") {
return (
<Textarea
id={`cfg-field-${field.name}`}
placeholder={placeholder}
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value)}
rows={6}
className="field-sizing-fixed resize-y break-all font-mono text-xs"
/>
);
}
if (field.type === "number") {
return (
<Input
id={`cfg-field-${field.name}`}
type="number"
placeholder={placeholder}
value={value as number | string | undefined ?? ""}
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
/>
);
}
return (
<Input
id={`cfg-field-${field.name}`}
type={field.type === "password" || field.sensitive ? "password" : "text"}
placeholder={placeholder}
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value)}
autoComplete={field.sensitive ? "current-password" : undefined}
/>
);
}
// FastAPI error responses come back as { detail: string } or
// { detail: [{loc, msg, ...}] }. Surface a useful message either way.
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 configuration";
}

View file

@ -0,0 +1,278 @@
"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__";
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<string>(NO_WORKFLOW);
const [workflows, setWorkflows] = useState<{ id: number; name: string }[]>([]);
const [submitting, setSubmitting] = 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,
);
}, [open, existing]);
// 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 && !address.trim()) {
toast.error("Address is required");
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit phone number" : "Add phone number"}
</DialogTitle>
<DialogDescription>
PSTN numbers (E.164), SIP URIs (sip:user@host), and SIP extensions are all supported.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="pn-address">Address</Label>
<Input
id="pn-address"
placeholder="+19781899185, sip:101@asterisk.local, or 101"
value={address}
onChange={(e) => setAddress(e.target.value)}
disabled={isEdit}
/>
{isEdit && (
<p className="text-xs text-muted-foreground">
Address cannot be changed. Delete this number and create a new one to
change it.
</p>
)}
{isEdit && (
<p className="text-xs text-muted-foreground">
Stored as <code>{existing?.address_normalized}</code> ({existing?.address_type})
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="pn-country">Country (ISO-2)</Label>
<Input
id="pn-country"
placeholder="US"
maxLength={2}
value={countryCode}
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
/>
</div>
<div className="space-y-1">
<Label htmlFor="pn-label">Label</Label>
<Input
id="pn-label"
placeholder="e.g. Boston caller ID"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="pn-workflow">Inbound workflow</Label>
<Select value={inboundWorkflowId} onValueChange={setInboundWorkflowId}>
<SelectTrigger id="pn-workflow">
<SelectValue placeholder="(none)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_WORKFLOW}>(none)</SelectItem>
{workflows.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
#{w.id} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Used when per-number inbound routing is enabled. Today, inbound calls still
route by the workflow_id in the webhook URL.
</p>
</div>
<div className="flex items-center justify-between rounded border p-3">
<Label className="text-sm">Active</Label>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
{!isEdit && (
<div className="flex items-center justify-between rounded border p-3">
<div>
<Label className="text-sm">Default caller ID for this configuration</Label>
<p className="text-xs text-muted-foreground">
Used as the from-number for test calls when set.
</p>
</div>
<Switch
checked={isDefaultCallerId}
onCheckedChange={setIsDefaultCallerId}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving..." : isEdit ? "Save changes" : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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";
}