fix: number pool initialization in multi telephony setup

If there are multiple telephony configurations, the form number should be initialized from the campaigns given telephonic configuration rather than the organization default telephonic configuration.
This commit is contained in:
Abhishek Kumar 2026-05-08 14:48:53 +05:30
parent 81a363b06e
commit 6d93be3ef6
31 changed files with 1105 additions and 238 deletions

View file

@ -1,11 +1,12 @@
"use client";
import { ChevronLeft, ChevronRight, Globe } from 'lucide-react';
import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import { getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
@ -49,15 +50,21 @@ export default function UsagePage() {
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [isDownloadingReport, setIsDownloadingReport] = useState(false);
// Daily usage breakdown state (only for paid orgs)
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
const [isLoadingDaily, setIsLoadingDaily] = useState(false);
// Initialize filters from URL
// Initialize filters from URL. `activeFilters` tracks the in-progress
// edits in the FilterBuilder; `appliedFilters` is what's actually been
// committed via Apply (and what drives fetching + the download button).
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
});
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
});
// Media preview dialog
const mediaPreview = MediaPreviewDialog();
@ -83,51 +90,50 @@ export default function UsagePage() {
}
}, [auth.isAuthenticated]);
// Translate the FilterBuilder state into the query-param shape the
// backend expects. Shared between the listing fetch and the CSV export
// so they stay in lockstep.
const buildUsageQueryParams = (filters?: ActiveFilter[]) => {
let filterParam: string | undefined;
let startDate = '';
let endDate = '';
if (filters && filters.length > 0) {
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
if (dateRangeFilter && dateRangeFilter.value) {
const dateValue = dateRangeFilter.value as DateRangeValue;
if (dateValue.from) startDate = dateValue.from.toISOString();
if (dateValue.to) endDate = dateValue.to.toISOString();
}
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
if (otherFilters.length > 0) {
const filterData = otherFilters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value,
}));
filterParam = JSON.stringify(filterData);
}
}
return {
...(startDate && { start_date: startDate }),
...(endDate && { end_date: endDate }),
...(filterParam && { filters: filterParam }),
};
};
// Fetch usage history
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!auth.isAuthenticated) return;
setIsLoadingHistory(true);
try {
let filterParam = undefined;
let startDate = '';
let endDate = '';
if (filters && filters.length > 0) {
// Extract date range filter if present
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
if (dateRangeFilter && dateRangeFilter.value) {
const dateValue = dateRangeFilter.value as DateRangeValue;
if (dateValue.from) {
// The dates are already in the user's local timezone
// Convert to UTC ISO string for the backend
startDate = dateValue.from.toISOString();
}
if (dateValue.to) {
// Convert to UTC ISO string for the backend
endDate = dateValue.to.toISOString();
}
}
// Process other filters (excluding dateRange)
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
if (otherFilters.length > 0) {
const filterData = otherFilters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value,
}));
filterParam = JSON.stringify(filterData);
}
}
const response = await getUsageHistoryApiV1OrganizationsUsageRunsGet({
query: {
page,
limit: 50,
...(startDate && { start_date: startDate }),
...(endDate && { end_date: endDate }),
...(filterParam && { filters: filterParam })
...buildUsageQueryParams(filters),
},
});
@ -161,6 +167,37 @@ export default function UsagePage() {
}
}, [auth.isAuthenticated, organizationPricing]);
// Download a CSV of all runs matching the current filters.
const handleDownloadReport = async () => {
if (!auth.isAuthenticated) return;
setIsDownloadingReport(true);
try {
const response = await downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet({
query: buildUsageQueryParams(appliedFilters),
parseAs: 'blob',
});
if (response.data) {
const blob = response.data as Blob;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'usage_runs_report.csv';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
toast.error('Failed to download report');
}
} catch (error) {
console.error('Failed to download usage report:', error);
toast.error('Failed to download report');
} finally {
setIsDownloadingReport(false);
}
};
// Handle timezone change
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
setSelectedTimezone(timezone);
@ -195,9 +232,9 @@ export default function UsagePage() {
useEffect(() => {
if (auth.isAuthenticated) {
fetchMpsCredits();
fetchUsageHistory(currentPage, activeFilters);
fetchUsageHistory(currentPage, appliedFilters);
}
}, [auth.isAuthenticated, currentPage, activeFilters, fetchUsageHistory, fetchMpsCredits]);
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
// Fetch daily usage when organizationPricing becomes available
useEffect(() => {
@ -229,6 +266,7 @@ export default function UsagePage() {
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
setAppliedFilters(activeFilters);
updateUrlParams({ page: 1, filters: activeFilters });
await fetchUsageHistory(1, activeFilters);
setIsExecutingFilters(false);
@ -241,6 +279,8 @@ export default function UsagePage() {
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
setActiveFilters([]);
setAppliedFilters([]);
updateUrlParams({ page: 1, filters: [] }); // Clear filters from URL
await fetchUsageHistory(1, []); // Fetch all runs without filters
setIsExecutingFilters(false);
@ -249,8 +289,8 @@ export default function UsagePage() {
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
updateUrlParams({ page: newPage, filters: activeFilters });
fetchUsageHistory(newPage, activeFilters);
updateUrlParams({ page: newPage, filters: appliedFilters });
fetchUsageHistory(newPage, appliedFilters);
};
// Handle row click to navigate to workflow run
@ -289,8 +329,8 @@ export default function UsagePage() {
<div>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold mb-2">Usage Dashboard</h1>
<p className="text-muted-foreground">Monitor your Dograh Token usage and quota</p>
<h1 className="text-3xl font-bold mb-2">Agent Runs</h1>
<p className="text-muted-foreground">See all your Agent Runs across all Voice Agents. You can use filters to filter out required Agent Runs.</p>
</div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
@ -419,7 +459,7 @@ export default function UsagePage() {
)}
{/* Filter Builder */}
<div className="mb-6">
<div className="mb-6 space-y-3">
<FilterBuilder
availableAttributes={usageFilterAttributes}
activeFilters={activeFilters}
@ -428,6 +468,19 @@ export default function UsagePage() {
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
{appliedFilters.length > 0 && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleDownloadReport}
disabled={isDownloadingReport}
>
<Download className="h-4 w-4 mr-2" />
{isDownloadingReport ? 'Preparing...' : 'Download Filtered Results'}
</Button>
</div>
)}
</div>
{/* Usage History */}
@ -435,9 +488,9 @@ export default function UsagePage() {
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-1.5">
<CardTitle>Usage History</CardTitle>
<CardTitle>All Runs</CardTitle>
<CardDescription>
View detailed usage by workflow run
Every agent run across your organization, with usage details
</CardDescription>
</div>
</div>
@ -463,7 +516,7 @@ export default function UsagePage() {
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold text-right">Duration</TableHead>
<TableHead className="font-semibold text-right">
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Dograh Tokens'}
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
@ -490,7 +543,9 @@ export default function UsagePage() {
)}
</TableCell>
<TableCell className="text-sm">
{run.phone_number || '-'}
{(run.call_type === 'inbound'
? run.caller_number
: run.called_number) || '-'}
</TableCell>
<TableCell>
{run.disposition ? (
@ -526,7 +581,7 @@ export default function UsagePage() {
</div>
{/* Summary */}
{activeFilters.length > 0 && (
{appliedFilters.length > 0 && (
<div className="mt-4 p-3 bg-muted rounded-md">
<p className="text-sm text-muted-foreground">
Total for filtered period: <span className="font-semibold text-foreground">
@ -570,7 +625,7 @@ export default function UsagePage() {
)}
</>
) : (
<p className="text-center py-8 text-muted-foreground">No usage history found</p>
<p className="text-center py-8 text-muted-foreground">No runs found</p>
)}
</CardContent>
</Card>

View file

@ -9,9 +9,10 @@ import { PhoneInput } from 'react-international-phone';
import {
initiateCallApiV1TelephonyInitiateCallPost,
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -58,6 +59,9 @@ export const PhoneCallDialog = ({
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
const [fromPhoneNumbers, setFromPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState<string>("");
const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
// Check telephony configuration when dialog opens
useEffect(() => {
@ -102,9 +106,49 @@ export const PhoneCallDialog = ({
setNeedsConfiguration(null);
setTelephonyConfigs([]);
setSelectedConfigId("");
setFromPhoneNumbers([]);
setSelectedFromPhoneNumberId("");
}
}, [open]);
// Fetch phone numbers whenever the selected telephony configuration changes.
useEffect(() => {
if (!open || !selectedConfigId) {
setFromPhoneNumbers([]);
setSelectedFromPhoneNumberId("");
return;
}
let cancelled = false;
const fetchPhoneNumbers = async () => {
setLoadingPhoneNumbers(true);
try {
const response = await listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet({
path: { config_id: Number(selectedConfigId) },
});
if (cancelled) return;
const all = response.data?.phone_numbers ?? [];
const active = all.filter((p) => p.is_active);
setFromPhoneNumbers(active);
const defaultPhone = active.find((p) => p.is_default_caller_id) ?? active[0];
setSelectedFromPhoneNumberId(defaultPhone ? String(defaultPhone.id) : "");
} catch (err) {
if (cancelled) return;
console.error("Failed to load phone numbers for config:", err);
setFromPhoneNumbers([]);
setSelectedFromPhoneNumberId("");
} finally {
if (!cancelled) setLoadingPhoneNumbers(false);
}
};
fetchPhoneNumbers();
return () => {
cancelled = true;
};
}, [open, selectedConfigId]);
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
@ -148,6 +192,7 @@ export const PhoneCallDialog = ({
workflow_id: workflowId,
phone_number: phoneNumber,
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
from_phone_number_id: selectedFromPhoneNumberId ? Number(selectedFromPhoneNumberId) : null,
},
});
@ -230,6 +275,38 @@ export const PhoneCallDialog = ({
</Select>
</div>
)}
{selectedConfigId && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="from-phone-number">Caller ID (from)</Label>
{loadingPhoneNumbers ? (
<div className="flex items-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading phone numbers...
</div>
) : fromPhoneNumbers.length > 0 ? (
<Select
value={selectedFromPhoneNumberId}
onValueChange={setSelectedFromPhoneNumberId}
>
<SelectTrigger id="from-phone-number" className="w-full">
<SelectValue placeholder="Select a phone number" />
</SelectTrigger>
<SelectContent>
{fromPhoneNumbers.map((phone) => (
<SelectItem key={phone.id} value={String(phone.id)}>
{phone.label ? `${phone.label}${phone.address}` : phone.address}
{phone.is_default_caller_id ? " — default" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="text-xs text-muted-foreground">
No phone numbers in this configuration. The provider will pick one automatically.
</div>
)}
</div>
)}
{sipMode ? (
<Input
value={phoneNumber}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1995,6 +1995,10 @@ export type InitiateCallRequest = {
* Telephony Configuration Id
*/
telephony_configuration_id?: number | null;
/**
* From Phone Number Id
*/
from_phone_number_id?: number | null;
};
/**
@ -3683,34 +3687,6 @@ export type TransferCallConfig = {
timeout?: number;
};
/**
* TransferCallRequest
*
* Request model for initiating a call transfer.
*/
export type TransferCallRequest = {
/**
* Destination
*/
destination: string;
/**
* Organization Id
*/
organization_id: number;
/**
* Transfer Id
*/
transfer_id: string;
/**
* Conference Name
*/
conference_name: string;
/**
* Timeout
*/
timeout?: number | null;
};
/**
* TransferCallToolDefinition
*
@ -4625,8 +4601,20 @@ export type WorkflowRunUsageResponse = {
transcript_url?: string | null;
/**
* Phone Number
*
* Deprecated. Use caller_number and called_number instead.
*
* @deprecated
*/
phone_number?: string | null;
/**
* Caller Number
*/
caller_number?: string | null;
/**
* Called Number
*/
called_number?: string | null;
/**
* Call Type
*/
@ -4882,33 +4870,6 @@ export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses =
200: unknown;
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostData = {
body: TransferCallRequest;
path?: never;
query?: never;
url: '/api/v1/telephony/call-transfer';
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostError = InitiateCallTransferApiV1TelephonyCallTransferPostErrors[keyof InitiateCallTransferApiV1TelephonyCallTransferPostErrors];
export type InitiateCallTransferApiV1TelephonyCallTransferPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData = {
body?: never;
path: {
@ -9559,13 +9520,13 @@ export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
/**
* Start Date
*
* ISO format date string
* ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.
*/
start_date?: string | null;
/**
* End Date
*
* ISO format date string
* ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.
*/
end_date?: string | null;
/**
@ -9579,7 +9540,28 @@ export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
/**
* Filters
*
* JSON string of filters
* JSON-encoded array of filter objects. Each object has the shape:
*
* ```json
* { "attribute": "<name>", "type": "<type>", "value": <value> }
* ```
*
* Supported `attribute` / `type` / `value` combinations:
*
* | attribute | type | value shape | matches |
* |-----------------|---------------|----------------------------------------------|------------------------------------------------------|
* | `runId` | `number` | `{ "value": 12345 }` | exact run id |
* | `workflowId` | `number` | `{ "value": 42 }` | exact agent (workflow) id |
* | `campaignId` | `number` | `{ "value": 7 }` | exact campaign id |
* | `callerNumber` | `text` | `{ "value": "415555" }` | substring match on `initial_context.caller_number` |
* | `calledNumber` | `text` | `{ "value": "9911848" }` | substring match on `initial_context.called_number` |
* | `dispositionCode` | `multiSelect` | `{ "codes": ["XFER", "DNC"] }` | any of the codes in `gathered_context.mapped_call_disposition` |
* | `duration` | `numberRange` | `{ "min": 60, "max": 300 }` | call duration (seconds), inclusive bounds |
*
* Unknown attributes and unsupported `type` values are silently ignored.
*
* Date filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.
*
*/
filters?: string | null;
};
@ -9608,6 +9590,83 @@ export type GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses = {
export type GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse = GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses[keyof GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses];
export type DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: {
/**
* Start Date
*
* ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.
*/
start_date?: string | null;
/**
* End Date
*
* ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.
*/
end_date?: string | null;
/**
* Filters
*
* JSON-encoded array of filter objects. Each object has the shape:
*
* ```json
* { "attribute": "<name>", "type": "<type>", "value": <value> }
* ```
*
* Supported `attribute` / `type` / `value` combinations:
*
* | attribute | type | value shape | matches |
* |-----------------|---------------|----------------------------------------------|------------------------------------------------------|
* | `runId` | `number` | `{ "value": 12345 }` | exact run id |
* | `workflowId` | `number` | `{ "value": 42 }` | exact agent (workflow) id |
* | `campaignId` | `number` | `{ "value": 7 }` | exact campaign id |
* | `callerNumber` | `text` | `{ "value": "415555" }` | substring match on `initial_context.caller_number` |
* | `calledNumber` | `text` | `{ "value": "9911848" }` | substring match on `initial_context.called_number` |
* | `dispositionCode` | `multiSelect` | `{ "codes": ["XFER", "DNC"] }` | any of the codes in `gathered_context.mapped_call_disposition` |
* | `duration` | `numberRange` | `{ "min": 60, "max": 300 }` | call duration (seconds), inclusive bounds |
*
* Unknown attributes and unsupported `type` values are silently ignored.
*
* Date filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.
*
*/
filters?: string | null;
};
url: '/api/v1/organizations/usage/runs/report';
};
export type DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetError = DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetErrors[keyof DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetErrors];
export type DownloadUsageRunsReportApiV1OrganizationsUsageRunsReportGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData = {
body?: never;
headers?: {

View file

@ -166,7 +166,7 @@ export function AppSidebar() {
const observeSection = [
{
title: "Usage",
title: "Agent Runs",
url: "/usage",
icon: TrendingUp,
},

View file

@ -85,14 +85,32 @@ export const baseFilterAttributes: Record<string, Omit<FilterAttribute, "id">> =
step: 1,
},
},
phoneNumber: {
callerNumber: {
type: "text",
label: "Phone Number",
label: "Caller Number",
config: {
placeholder: "Enter phone number (partial match)",
placeholder: "Enter caller number (partial match)",
maxLength: 20,
},
},
calledNumber: {
type: "text",
label: "Called Number",
config: {
placeholder: "Enter called number (partial match)",
maxLength: 20,
},
},
campaignId: {
type: "number",
label: "Campaign ID",
config: {
placeholder: "Enter campaign ID",
min: 1,
max: 9999999,
step: 1,
},
},
};
// Helper function to create filter attributes with proper IDs
@ -135,7 +153,8 @@ export const superadminFilterAttributes = createFilterAttributes(
"dateRange",
"runId",
"workflowId",
"phoneNumber",
"callerNumber",
"calledNumber",
"dispositionCode",
"status",
"duration",
@ -153,9 +172,19 @@ export const usageFilterAttributes = createFilterAttributes(
"dateRange",
"duration",
"dispositionCode",
"phoneNumber",
"callerNumber",
"calledNumber",
"runId",
"workflowId",
"campaignId",
],
{
runId: {
label: "Run ID",
},
workflowId: {
label: "Agent ID",
},
dateRange: {
label: "Date Range",
config: {