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