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

@ -1,6 +1,7 @@
"use client";
import { Plus, X } from 'lucide-react';
import Link from 'next/link';
import { useId } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
@ -137,12 +138,12 @@ export default function CampaignAdvancedSettings({
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
No phone numbers configured. Add CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link> before running the campaign.
</p>
)}
</div>

View file

@ -178,7 +178,7 @@ export default function EditCampaignPage() {
}
if (maxConcurrencyValue > effectiveLimit) {
if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured add more CLIs to increase concurrency.`);
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured - add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import type { ITimezoneOption } from 'react-timezone-select';
@ -9,9 +10,10 @@ import { toast } from 'sonner';
import {
createCampaignApiV1CampaignCreatePost,
getCampaignDefaultsApiV1OrganizationsCampaignDefaultsGet,
getWorkflowsSummaryApiV1WorkflowSummaryGet
getWorkflowsSummaryApiV1WorkflowSummaryGet,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { WorkflowSummaryResponse } from '@/client/types.gen';
import type { TelephonyConfigurationListItem, WorkflowSummaryResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@ -48,6 +50,11 @@ export default function NewCampaignPage() {
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
// Telephony configurations state
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedTelephonyConfigId, setSelectedTelephonyConfigId] = useState<string>('');
const [isLoadingTelephonyConfigs, setIsLoadingTelephonyConfigs] = useState(true);
// Advanced settings state
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
@ -94,7 +101,10 @@ export default function NewCampaignPage() {
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
},
query: {
status: 'active',
},
});
if (response.data) {
@ -108,6 +118,33 @@ export default function NewCampaignPage() {
}
}, [user, getAccessToken]);
// Fetch telephony configurations
const fetchTelephonyConfigs = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
const configs = response.data.configurations ?? [];
setTelephonyConfigs(configs);
const defaultConfig = configs.find((c) => c.is_default_outbound) ?? configs[0];
if (defaultConfig) {
setSelectedTelephonyConfigId(String(defaultConfig.id));
}
}
} catch (error) {
console.error('Failed to fetch telephony configurations:', error);
toast.error('Failed to load telephony configurations');
} finally {
setIsLoadingTelephonyConfigs(false);
}
}, [user, getAccessToken]);
// Fetch campaign limits
const fetchCampaignDefaults = useCallback(async () => {
if (!user) return;
@ -183,12 +220,21 @@ export default function NewCampaignPage() {
if (user) {
fetchWorkflows();
fetchCampaignDefaults();
fetchTelephonyConfigs();
}
}, [fetchWorkflows, fetchCampaignDefaults, user]);
}, [fetchWorkflows, fetchCampaignDefaults, fetchTelephonyConfigs, user]);
// Phone-number count for the selected telephony config drives concurrency
// bounds. Falls back to the campaign-defaults endpoint's count (org default
// config) until the configs list resolves.
const selectedTelephonyConfig = telephonyConfigs.find(
(c) => String(c.id) === selectedTelephonyConfigId,
);
const availableFromNumbersCount = selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount;
// Effective concurrency limit considering both org limit and available CLIs
const effectiveLimit = fromNumbersCount > 0
? Math.min(orgConcurrentLimit, fromNumbersCount)
const effectiveLimit = availableFromNumbersCount > 0
? Math.min(orgConcurrentLimit, availableFromNumbersCount)
: orgConcurrentLimit;
// Handle form submission
@ -196,7 +242,7 @@ export default function NewCampaignPage() {
e.preventDefault();
setCreateError(null);
if (!campaignName || !selectedWorkflowId || !sourceId) {
if (!campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId) {
toast.error('Please fill in all fields');
return;
}
@ -209,8 +255,8 @@ export default function NewCampaignPage() {
return;
}
if (maxConcurrencyValue > effectiveLimit) {
if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`);
if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) — add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}
@ -257,6 +303,7 @@ export default function NewCampaignPage() {
workflow_id: parseInt(selectedWorkflowId),
source_type: sourceType,
source_id: sourceId,
telephony_configuration_id: parseInt(selectedTelephonyConfigId),
retry_config: retryConfig,
max_concurrency: maxConcurrencyValue,
schedule_config: scheduleConfig,
@ -383,6 +430,52 @@ export default function NewCampaignPage() {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="telephony-config">Telephony Configuration</Label>
{!isLoadingTelephonyConfigs && telephonyConfigs.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No telephony configurations yet.{' '}
<Link
href="/telephony-configurations"
className="underline text-foreground"
>
Add one
</Link>{' '}
to create a campaign.
</div>
) : (
<Select
value={selectedTelephonyConfigId}
onValueChange={setSelectedTelephonyConfigId}
required
>
<SelectTrigger id="telephony-config">
<SelectValue placeholder="Select a telephony configuration" />
</SelectTrigger>
<SelectContent>
{isLoadingTelephonyConfigs ? (
<SelectItem value="loading" disabled>
Loading configurations...
</SelectItem>
) : (
telephonyConfigs.map((config) => (
<SelectItem
key={config.id}
value={config.id.toString()}
>
{config.name} ({config.provider})
{config.is_default_outbound ? ' — default' : ''}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
<p className="text-sm text-muted-foreground">
Outbound calls for this campaign will use this configuration&apos;s caller IDs
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source-type">Data Source Type</Label>
<Select
@ -480,7 +573,7 @@ export default function NewCampaignPage() {
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId}
>
{isSubmitting ? 'Creating...' : 'Create Campaign'}
</Button>

View file

@ -0,0 +1,419 @@
"use client";
import {
ArrowLeft,
ExternalLink,
Pencil,
Plus,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete,
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet,
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost,
setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost,
} from "@/client/sdk.gen";
import type {
PhoneNumberResponse,
TelephonyConfigurationDetail,
} from "@/client/types.gen";
import { ConfigFormDialog } from "@/components/telephony/ConfigFormDialog";
import { PhoneNumberDialog } from "@/components/telephony/PhoneNumberDialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAuth } from "@/lib/auth";
export default function TelephonyConfigurationDetailPage() {
const router = useRouter();
const params = useParams<{ configId: string }>();
const configId = Number(params.configId);
const { user, getAccessToken, loading: authLoading } = useAuth();
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [loading, setLoading] = useState(true);
const [editConfigOpen, setEditConfigOpen] = useState(false);
const [phoneDialogOpen, setPhoneDialogOpen] = useState(false);
const [phoneEditTarget, setPhoneEditTarget] = useState<PhoneNumberResponse | null>(
null,
);
const [phoneDeleteTarget, setPhoneDeleteTarget] = useState<PhoneNumberResponse | null>(
null,
);
const fetchAll = useCallback(async () => {
if (authLoading || !user || !configId) return;
setLoading(true);
try {
const token = await getAccessToken();
const [cfgRes, numbersRes] = await Promise.all([
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet({
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId },
}),
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet({
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId },
}),
]);
if (cfgRes.error) throw new Error(detailFromError(cfgRes.error));
if (numbersRes.error) throw new Error(detailFromError(numbersRes.error));
setConfig(cfgRes.data ?? null);
setPhoneNumbers(numbersRes.data?.phone_numbers ?? []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load configuration");
} finally {
setLoading(false);
}
}, [authLoading, user, configId, getAccessToken]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const onSetDefaultOutbound = async () => {
if (!config) return;
try {
const token = await getAccessToken();
const res = await setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: config.id },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Set as default outbound");
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to set default");
}
};
const onSetDefaultCaller = async (n: PhoneNumberResponse) => {
try {
const token = await getAccessToken();
const res = await setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId, phone_number_id: n.id },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success(`${n.address} is now the default caller ID`);
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to set default caller");
}
};
const onConfirmDeletePhone = async () => {
if (!phoneDeleteTarget) return;
try {
const token = await getAccessToken();
const res = await deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete(
{
headers: { Authorization: `Bearer ${token}` },
path: {
config_id: configId,
phone_number_id: phoneDeleteTarget.id,
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Phone number deleted");
setPhoneDeleteTarget(null);
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete phone number");
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8 space-y-3">
<Skeleton className="h-10 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!config) {
return (
<div className="container mx-auto px-4 py-8">
<Button variant="ghost" onClick={() => router.push("/telephony-configurations")}>
<ArrowLeft className="h-4 w-4 mr-2" /> Back
</Button>
<p className="mt-4 text-muted-foreground">Configuration not found.</p>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 space-y-6">
<div>
<Link
href="/telephony-configurations"
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" /> All configurations
</Link>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="truncate">{config.name}</CardTitle>
<Badge variant="secondary">{config.provider}</Badge>
{config.is_default_outbound && (
<Badge className="gap-1">
<Star className="h-3 w-3 fill-current" />
Default
</Badge>
)}
</div>
<CardDescription>
Updated {new Date(config.updated_at).toLocaleString()}
</CardDescription>
</div>
<div className="flex items-center gap-2 shrink-0">
{!config.is_default_outbound && (
<Button variant="outline" size="sm" onClick={onSetDefaultOutbound}>
<Star className="h-4 w-4 mr-2" /> Set as default
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setEditConfigOpen(true)}>
<Pencil className="h-4 w-4 mr-2" /> Edit credentials
</Button>
</div>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{Object.entries(config.credentials ?? {}).map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-mono text-right truncate max-w-[60%]">
{String(v ?? "")}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>Phone numbers</CardTitle>
<CardDescription>
Numbers used as caller ID for outbound and accepted for inbound matching.
SIP URIs and extensions are supported alongside PSTN numbers.{" "}
<a
href="https://docs.dograh.com/integrations/telephony/inbound"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Inbound docs <ExternalLink className="h-3 w-3" />
</a>
</CardDescription>
</div>
<Button
size="sm"
onClick={() => {
setPhoneEditTarget(null);
setPhoneDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" /> Add phone number
</Button>
</CardHeader>
<CardContent>
{phoneNumbers.length === 0 ? (
<p className="text-sm text-muted-foreground">
No phone numbers yet. Add one to start placing or receiving calls on this
configuration.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Type</TableHead>
<TableHead>Label</TableHead>
<TableHead>Status</TableHead>
<TableHead>Inbound workflow</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{phoneNumbers.map((n) => (
<TableRow key={n.id}>
<TableCell className="font-mono">{n.address}</TableCell>
<TableCell>
<Badge variant="outline">{n.address_type}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{n.label ?? "-"}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{n.is_active ? (
<Badge variant="secondary">Active</Badge>
) : (
<Badge variant="outline">Inactive</Badge>
)}
{n.is_default_caller_id && (
<Badge className="gap-1">
<Star className="h-3 w-3 fill-current" /> Default caller
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{n.inbound_workflow_id ? (
<Link
href={`/workflow/${n.inbound_workflow_id}`}
className="inline-flex items-center gap-1 hover:underline hover:text-foreground"
>
<span>#{n.inbound_workflow_id}</span>
{n.inbound_workflow_name && (
<span
className="truncate max-w-[160px]"
title={n.inbound_workflow_name}
>
{n.inbound_workflow_name.length > 24
? `${n.inbound_workflow_name.slice(0, 24)}`
: n.inbound_workflow_name}
</span>
)}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{!n.is_default_caller_id && n.is_active && (
<Button
variant="ghost"
size="sm"
onClick={() => onSetDefaultCaller(n)}
title="Set as default caller ID"
>
<Star className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
setPhoneEditTarget(n);
setPhoneDialogOpen(true);
}}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPhoneDeleteTarget(n)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ConfigFormDialog
open={editConfigOpen}
onOpenChange={setEditConfigOpen}
existing={config}
onSaved={fetchAll}
/>
<PhoneNumberDialog
open={phoneDialogOpen}
onOpenChange={setPhoneDialogOpen}
configId={configId}
existing={phoneEditTarget}
onSaved={fetchAll}
/>
<AlertDialog
open={!!phoneDeleteTarget}
onOpenChange={(o) => !o && setPhoneDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete phone number?</AlertDialogTitle>
<AlertDialogDescription>
{phoneDeleteTarget?.address} will no longer accept inbound calls or be
available as a caller ID for this configuration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirmDeletePhone}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
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 "Request failed";
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

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";
}