mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add retry config during campaign creation
This commit is contained in:
parent
db75d90535
commit
6f41e91f67
14 changed files with 1036 additions and 221 deletions
|
|
@ -132,7 +132,9 @@ export default function CsvUploadSelector({ accessToken, onFileUploaded, selecte
|
|||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
|
||||
Upload a CSV file with contact data. Must include phone_number column.
|
||||
The columns can be accessed as initial_context in the workflow nodes. <br/>
|
||||
Max 10MB.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Pause, Play, RefreshCw } from 'lucide-react';
|
||||
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -16,6 +16,7 @@ import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -425,6 +426,76 @@ export default function CampaignDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaign Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Concurrency and retry configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Concurrency Setting */}
|
||||
<div>
|
||||
<dt className="text-sm font-medium">Max Concurrent Calls</dt>
|
||||
<dd className="mt-1">
|
||||
{campaign.max_concurrency ? (
|
||||
<span>{campaign.max_concurrency}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Using organization default</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Retry Configuration */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Retries Enabled</span>
|
||||
{campaign.retry_config.enabled ? (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<X className="h-3 w-3" />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{campaign.retry_config.enabled && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pl-4 border-l-2 border-muted">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Max Retries</dt>
|
||||
<dd className="mt-1 font-medium">{campaign.retry_config.max_retries}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Retry Delay</dt>
|
||||
<dd className="mt-1 font-medium">{campaign.retry_config.retry_delay_seconds}s</dd>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<dt className="text-sm text-muted-foreground">Retry On</dt>
|
||||
<dd className="mt-1 flex flex-wrap gap-1">
|
||||
{campaign.retry_config.retry_on_busy && (
|
||||
<Badge variant="outline" className="text-xs">Busy</Badge>
|
||||
)}
|
||||
{campaign.retry_config.retry_on_no_answer && (
|
||||
<Badge variant="outline" className="text-xs">No Answer</Badge>
|
||||
)}
|
||||
{campaign.retry_config.retry_on_voicemail && (
|
||||
<Badge variant="outline" className="text-xs">Voicemail</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Runs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { createCampaignApiV1CampaignCreatePost, getWorkflowsSummaryApiV1WorkflowSummaryGet } from '@/client/sdk.gen';
|
||||
import {
|
||||
createCampaignApiV1CampaignCreatePost,
|
||||
getCampaignLimitsApiV1OrganizationsCampaignLimitsGet,
|
||||
getWorkflowsSummaryApiV1WorkflowSummaryGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { 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';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
|
|
@ -18,6 +23,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import CsvUploadSelector from '../CsvUploadSelector';
|
||||
|
|
@ -34,12 +40,25 @@ export default function NewCampaignPage() {
|
|||
const [sourceId, setSourceId] = useState('');
|
||||
const [selectedFileName, setSelectedFileName] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [userAccessToken, setUserAccessToken] = useState<string>('');
|
||||
|
||||
// Workflows state
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
|
||||
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
|
||||
|
||||
// Advanced settings state
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
|
||||
const [maxConcurrency, setMaxConcurrency] = useState<string>('');
|
||||
// Retry config state
|
||||
const [retryEnabled, setRetryEnabled] = useState(true);
|
||||
const [maxRetries, setMaxRetries] = useState<string>('2');
|
||||
const [retryDelaySeconds, setRetryDelaySeconds] = useState<string>('120');
|
||||
const [retryOnBusy, setRetryOnBusy] = useState(true);
|
||||
const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true);
|
||||
const [retryOnVoicemail, setRetryOnVoicemail] = useState(true);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
|
|
@ -70,45 +89,111 @@ export default function NewCampaignPage() {
|
|||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchWorkflows();
|
||||
}
|
||||
}, [fetchWorkflows, user]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!campaignName || !selectedWorkflowId || !sourceId) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Fetch campaign limits
|
||||
const fetchCampaignLimits = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await createCampaignApiV1CampaignCreatePost({
|
||||
body: {
|
||||
name: campaignName,
|
||||
workflow_id: parseInt(selectedWorkflowId),
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
},
|
||||
const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setOrgConcurrentLimit(response.data.concurrent_call_limit);
|
||||
// Initialize retry config from defaults
|
||||
const retryConfig = response.data.default_retry_config;
|
||||
setRetryEnabled(retryConfig.enabled);
|
||||
setMaxRetries(String(retryConfig.max_retries));
|
||||
setRetryDelaySeconds(String(retryConfig.retry_delay_seconds));
|
||||
setRetryOnBusy(retryConfig.retry_on_busy);
|
||||
setRetryOnNoAnswer(retryConfig.retry_on_no_answer);
|
||||
setRetryOnVoicemail(retryConfig.retry_on_voicemail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign limits:', error);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchWorkflows();
|
||||
fetchCampaignLimits();
|
||||
}
|
||||
}, [fetchWorkflows, fetchCampaignLimits, user]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
|
||||
if (!campaignName || !selectedWorkflowId || !sourceId) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate max_concurrency if provided
|
||||
const maxConcurrencyValue = maxConcurrency ? parseInt(maxConcurrency) : null;
|
||||
if (maxConcurrencyValue !== null) {
|
||||
if (isNaN(maxConcurrencyValue) || maxConcurrencyValue < 1 || maxConcurrencyValue > 100) {
|
||||
toast.error('Max concurrent calls must be between 1 and 100');
|
||||
return;
|
||||
}
|
||||
if (maxConcurrencyValue > orgConcurrentLimit) {
|
||||
toast.error(`Max concurrent calls cannot exceed organization limit (${orgConcurrentLimit})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Build retry_config only if user has modified settings from defaults
|
||||
const retryConfig = {
|
||||
enabled: retryEnabled,
|
||||
max_retries: parseInt(maxRetries) || 2,
|
||||
retry_delay_seconds: parseInt(retryDelaySeconds) || 120,
|
||||
retry_on_busy: retryOnBusy,
|
||||
retry_on_no_answer: retryOnNoAnswer,
|
||||
retry_on_voicemail: retryOnVoicemail,
|
||||
};
|
||||
|
||||
const response = await createCampaignApiV1CampaignCreatePost({
|
||||
body: {
|
||||
name: campaignName,
|
||||
workflow_id: parseInt(selectedWorkflowId),
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
retry_config: retryConfig,
|
||||
max_concurrency: maxConcurrencyValue,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
// Extract error message from API response
|
||||
const errorDetail = (response.error as { detail?: string })?.detail;
|
||||
const errorMessage = errorDetail || 'Failed to create campaign';
|
||||
setCreateError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
toast.success('Campaign created successfully');
|
||||
router.push(`/campaigns/${response.data.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to create campaign:', error);
|
||||
toast.error('Failed to create campaign');
|
||||
const errorMessage = 'Failed to create campaign';
|
||||
setCreateError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -131,7 +216,7 @@ export default function NewCampaignPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
|
||||
<div className="container mx-auto p-6 pb-12 space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -194,7 +279,7 @@ export default function NewCampaignPage() {
|
|||
key={workflow.id}
|
||||
value={workflow.id.toString()}
|
||||
>
|
||||
{workflow.name}
|
||||
{workflow.name} (#{workflow.id})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
|
|
@ -243,6 +328,119 @@ export default function NewCampaignPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Collapsible
|
||||
open={showAdvancedSettings}
|
||||
onOpenChange={setShowAdvancedSettings}
|
||||
className="border rounded-lg"
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors">
|
||||
<span className="font-medium">Advanced Settings</span>
|
||||
{showAdvancedSettings ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4 space-y-6">
|
||||
{/* Max Concurrent Calls */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
|
||||
<Input
|
||||
id="max-concurrency"
|
||||
type="number"
|
||||
placeholder={`Organization limit: ${orgConcurrentLimit}`}
|
||||
value={maxConcurrency}
|
||||
onChange={(e) => setMaxConcurrency(e.target.value)}
|
||||
min={1}
|
||||
max={orgConcurrentLimit}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum number of simultaneous calls. Leave empty to use organization limit ({orgConcurrentLimit}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Retry Configuration */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="retry-enabled">Enable Retries</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically retry failed calls
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="retry-enabled"
|
||||
checked={retryEnabled}
|
||||
onCheckedChange={setRetryEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{retryEnabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-retries">Max Retries</Label>
|
||||
<Input
|
||||
id="max-retries"
|
||||
type="number"
|
||||
value={maxRetries}
|
||||
onChange={(e) => setMaxRetries(e.target.value)}
|
||||
min={0}
|
||||
max={10}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
|
||||
<Input
|
||||
id="retry-delay"
|
||||
type="number"
|
||||
value={retryDelaySeconds}
|
||||
onChange={(e) => setRetryDelaySeconds(e.target.value)}
|
||||
min={30}
|
||||
max={3600}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Retry On</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Busy Signal</span>
|
||||
<Switch
|
||||
checked={retryOnBusy}
|
||||
onCheckedChange={setRetryOnBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">No Answer</span>
|
||||
<Switch
|
||||
checked={retryOnNoAnswer}
|
||||
onCheckedChange={setRetryOnNoAnswer}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Voicemail</span>
|
||||
<Switch
|
||||
checked={retryOnVoicemail}
|
||||
onCheckedChange={setRetryOnVoicemail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{createError && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -50,8 +51,8 @@ interface TelephonyConfigForm {
|
|||
// Cloudonix fields
|
||||
bearer_token?: string;
|
||||
domain_id?: string;
|
||||
// Common field
|
||||
from_number: string;
|
||||
// Common field - multiple phone numbers
|
||||
from_numbers: string[];
|
||||
}
|
||||
|
||||
export default function ConfigureTelephonyPage() {
|
||||
|
|
@ -73,10 +74,28 @@ export default function ConfigureTelephonyPage() {
|
|||
} = useForm<TelephonyConfigForm>({
|
||||
defaultValues: {
|
||||
provider: "twilio",
|
||||
from_numbers: [""],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedProvider = watch("provider");
|
||||
const fromNumbers = watch("from_numbers") || [""];
|
||||
|
||||
const addPhoneNumber = () => {
|
||||
setValue("from_numbers", [...fromNumbers, ""]);
|
||||
};
|
||||
|
||||
const removePhoneNumber = (index: number) => {
|
||||
if (fromNumbers.length > 1) {
|
||||
setValue("from_numbers", fromNumbers.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updatePhoneNumber = (index: number, value: string) => {
|
||||
const newNumbers = [...fromNumbers];
|
||||
newNumbers[index] = value;
|
||||
setValue("from_numbers", newNumbers);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Don't fetch config while auth is still loading
|
||||
|
|
@ -99,9 +118,7 @@ export default function ConfigureTelephonyPage() {
|
|||
setValue("provider", "twilio");
|
||||
setValue("account_sid", response.data.twilio.account_sid);
|
||||
setValue("auth_token", response.data.twilio.auth_token);
|
||||
if (response.data.twilio.from_numbers?.length > 0) {
|
||||
setValue("from_number", response.data.twilio.from_numbers[0]);
|
||||
}
|
||||
setValue("from_numbers", response.data.twilio.from_numbers?.length > 0 ? response.data.twilio.from_numbers : [""]);
|
||||
} else if (response.data?.vonage) {
|
||||
setHasExistingConfig(true);
|
||||
setValue("provider", "vonage");
|
||||
|
|
@ -109,26 +126,20 @@ export default function ConfigureTelephonyPage() {
|
|||
setValue("private_key", response.data.vonage.private_key);
|
||||
setValue("api_key", response.data.vonage.api_key || "");
|
||||
setValue("api_secret", response.data.vonage.api_secret || "");
|
||||
if (response.data.vonage.from_numbers?.length > 0) {
|
||||
setValue("from_number", response.data.vonage.from_numbers[0]);
|
||||
}
|
||||
setValue("from_numbers", response.data.vonage.from_numbers?.length > 0 ? response.data.vonage.from_numbers : [""]);
|
||||
} else if (response.data?.vobiz) {
|
||||
setHasExistingConfig(true);
|
||||
setValue("provider", "vobiz");
|
||||
setValue("auth_id", response.data.vobiz.auth_id);
|
||||
setValue("vobiz_auth_token", response.data.vobiz.auth_token);
|
||||
if (response.data.vobiz.from_numbers?.length > 0) {
|
||||
setValue("from_number", response.data.vobiz.from_numbers[0]);
|
||||
}
|
||||
setValue("from_numbers", response.data.vobiz.from_numbers?.length > 0 ? response.data.vobiz.from_numbers : [""]);
|
||||
} else if ((response.data as TelephonyConfigurationResponse)?.cloudonix) {
|
||||
const cloudonixConfig = (response.data as TelephonyConfigurationResponse).cloudonix as CloudonixConfigurationResponse;
|
||||
setHasExistingConfig(true);
|
||||
setValue("provider", "cloudonix");
|
||||
setValue("bearer_token", cloudonixConfig.bearer_token);
|
||||
setValue("domain_id", cloudonixConfig.domain_id);
|
||||
if (cloudonixConfig.from_numbers?.length > 0) {
|
||||
setValue("from_number", cloudonixConfig.from_numbers[0]);
|
||||
}
|
||||
setValue("from_numbers", cloudonixConfig.from_numbers?.length > 0 ? cloudonixConfig.from_numbers : [""]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -152,17 +163,51 @@ export default function ConfigureTelephonyPage() {
|
|||
| VobizConfigurationRequest
|
||||
| CloudonixConfigurationRequest;
|
||||
|
||||
const filteredNumbers = data.from_numbers.filter(n => n.trim() !== "");
|
||||
|
||||
// Validate phone numbers are provided (except for Cloudonix where optional)
|
||||
if (data.provider !== "cloudonix" && filteredNumbers.length === 0) {
|
||||
toast.error("At least one phone number is required");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate phone number format based on provider
|
||||
const twilioPattern = /^\+[1-9]\d{1,14}$/;
|
||||
const vonageVobizPattern = /^[1-9]\d{1,14}$/;
|
||||
const cloudonixPattern = /^\+?[1-9]\d{1,14}$/;
|
||||
|
||||
let pattern: RegExp;
|
||||
let formatMessage: string;
|
||||
if (data.provider === "twilio") {
|
||||
pattern = twilioPattern;
|
||||
formatMessage = "with + prefix (e.g., +1234567890)";
|
||||
} else if (data.provider === "cloudonix") {
|
||||
pattern = cloudonixPattern;
|
||||
formatMessage = "(e.g., +1234567890)";
|
||||
} else {
|
||||
pattern = vonageVobizPattern;
|
||||
formatMessage = "without + prefix (e.g., 14155551234)";
|
||||
}
|
||||
|
||||
const invalidNumbers = filteredNumbers.filter(n => !pattern.test(n));
|
||||
if (invalidNumbers.length > 0) {
|
||||
toast.error(`Invalid phone number format. Please enter numbers ${formatMessage}`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.provider === "twilio") {
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
from_numbers: filteredNumbers,
|
||||
account_sid: data.account_sid,
|
||||
auth_token: data.auth_token,
|
||||
} as TwilioConfigurationRequest;
|
||||
} else if (data.provider === "vonage") {
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
from_numbers: filteredNumbers,
|
||||
application_id: data.application_id,
|
||||
private_key: data.private_key,
|
||||
api_key: data.api_key || undefined,
|
||||
|
|
@ -171,7 +216,7 @@ export default function ConfigureTelephonyPage() {
|
|||
} else if (data.provider === "vobiz") {
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
from_numbers: filteredNumbers,
|
||||
auth_id: data.auth_id,
|
||||
auth_token: data.vobiz_auth_token,
|
||||
} as VobizConfigurationRequest;
|
||||
|
|
@ -179,7 +224,7 @@ export default function ConfigureTelephonyPage() {
|
|||
// Cloudonix
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: data.from_number ? [data.from_number] : [],
|
||||
from_numbers: filteredNumbers,
|
||||
bearer_token: data.bearer_token!,
|
||||
domain_id: data.domain_id!,
|
||||
} as CloudonixConfigurationRequest;
|
||||
|
|
@ -416,23 +461,39 @@ export default function ConfigureTelephonyPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_number">From Phone Number</Label>
|
||||
<Input
|
||||
id="from_number"
|
||||
autoComplete="tel"
|
||||
placeholder="+1234567890"
|
||||
{...register("from_number", {
|
||||
required: "Phone number is required",
|
||||
pattern: {
|
||||
value: /^\+[1-9]\d{1,14}$/,
|
||||
message:
|
||||
"Enter a valid phone number with country code (e.g., +1234567890)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.from_number && (
|
||||
<Label>CLI Phone Numbers</Label>
|
||||
{fromNumbers.map((number, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
autoComplete="tel"
|
||||
placeholder="+1234567890"
|
||||
value={number}
|
||||
onChange={(e) => updatePhoneNumber(index, e.target.value)}
|
||||
/>
|
||||
{fromNumbers.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removePhoneNumber(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPhoneNumber}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Phone Number
|
||||
</Button>
|
||||
{fromNumbers.some(n => n.trim() !== "" && !/^\+[1-9]\d{1,14}$/.test(n)) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.from_number.message}
|
||||
Enter valid phone numbers with country code (e.g., +1234567890)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -497,23 +558,39 @@ export default function ConfigureTelephonyPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_number">From Phone Number</Label>
|
||||
<Input
|
||||
id="from_number"
|
||||
autoComplete="tel"
|
||||
placeholder="14155551234 (no + prefix for Vonage)"
|
||||
{...register("from_number", {
|
||||
required: "Phone number is required",
|
||||
pattern: {
|
||||
value: /^[1-9]\d{1,14}$/,
|
||||
message:
|
||||
"Enter a valid phone number without + prefix (e.g., 14155551234)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.from_number && (
|
||||
<Label>CLI Phone Numbers</Label>
|
||||
{fromNumbers.map((number, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
autoComplete="tel"
|
||||
placeholder="14155551234 (no + prefix for Vonage)"
|
||||
value={number}
|
||||
onChange={(e) => updatePhoneNumber(index, e.target.value)}
|
||||
/>
|
||||
{fromNumbers.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removePhoneNumber(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPhoneNumber}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Phone Number
|
||||
</Button>
|
||||
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.from_number.message}
|
||||
Enter valid phone numbers without + prefix (e.g., 14155551234)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -564,23 +641,39 @@ export default function ConfigureTelephonyPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_number">From Phone Number</Label>
|
||||
<Input
|
||||
id="from_number"
|
||||
autoComplete="tel"
|
||||
placeholder="14155551234 (no + prefix for Vobiz)"
|
||||
{...register("from_number", {
|
||||
required: "Phone number is required",
|
||||
pattern: {
|
||||
value: /^[1-9]\d{1,14}$/,
|
||||
message:
|
||||
"Enter a valid phone number without + prefix (e.g., 14155551234)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.from_number && (
|
||||
<Label>CLI Phone Numbers</Label>
|
||||
{fromNumbers.map((number, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
autoComplete="tel"
|
||||
placeholder="14155551234 (no + prefix for Vobiz)"
|
||||
value={number}
|
||||
onChange={(e) => updatePhoneNumber(index, e.target.value)}
|
||||
/>
|
||||
{fromNumbers.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removePhoneNumber(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPhoneNumber}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Phone Number
|
||||
</Button>
|
||||
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.from_number.message}
|
||||
Enter valid phone numbers without + prefix (e.g., 14155551234)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -635,24 +728,39 @@ export default function ConfigureTelephonyPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_number">
|
||||
From Phone Number (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="from_number"
|
||||
autoComplete="tel"
|
||||
placeholder="+1234567890"
|
||||
{...register("from_number", {
|
||||
pattern: {
|
||||
value: /^\+?[1-9]\d{1,14}$/,
|
||||
message:
|
||||
"Enter a valid phone number (e.g., +1234567890)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.from_number && (
|
||||
<Label>CLI Phone Numbers (Optional)</Label>
|
||||
{fromNumbers.map((number, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
autoComplete="tel"
|
||||
placeholder="+1234567890"
|
||||
value={number}
|
||||
onChange={(e) => updatePhoneNumber(index, e.target.value)}
|
||||
/>
|
||||
{fromNumbers.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removePhoneNumber(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPhoneNumber}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Phone Number
|
||||
</Button>
|
||||
{fromNumbers.some(n => n.trim() !== "" && !/^\+?[1-9]\d{1,14}$/.test(n)) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.from_number.message}
|
||||
Enter valid phone numbers (e.g., +1234567890)
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -43,6 +43,11 @@ export type AuthUserResponse = {
|
|||
|
||||
export type CallType = 'inbound' | 'outbound';
|
||||
|
||||
export type CampaignLimitsResponse = {
|
||||
concurrent_call_limit: number;
|
||||
default_retry_config: RetryConfigResponse;
|
||||
};
|
||||
|
||||
export type CampaignProgressResponse = {
|
||||
campaign_id: number;
|
||||
state: string;
|
||||
|
|
@ -72,6 +77,8 @@ export type CampaignResponse = {
|
|||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_config: RetryConfigResponse;
|
||||
max_concurrency?: number | null;
|
||||
};
|
||||
|
||||
export type CampaignSourceDownloadResponse = {
|
||||
|
|
@ -177,6 +184,8 @@ export type CreateCampaignRequest = {
|
|||
workflow_id: number;
|
||||
source_type: string;
|
||||
source_id: string;
|
||||
retry_config?: RetryConfigRequest | null;
|
||||
max_concurrency?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -689,6 +698,24 @@ export type ProcessDocumentRequestSchema = {
|
|||
embedding_service?: 'sentence_transformer' | 'openai';
|
||||
};
|
||||
|
||||
export type RetryConfigRequest = {
|
||||
enabled?: boolean;
|
||||
max_retries?: number;
|
||||
retry_delay_seconds?: number;
|
||||
retry_on_busy?: boolean;
|
||||
retry_on_no_answer?: boolean;
|
||||
retry_on_voicemail?: boolean;
|
||||
};
|
||||
|
||||
export type RetryConfigResponse = {
|
||||
enabled: boolean;
|
||||
max_retries: number;
|
||||
retry_delay_seconds: number;
|
||||
retry_on_busy: boolean;
|
||||
retry_on_no_answer: boolean;
|
||||
retry_on_voicemail: boolean;
|
||||
};
|
||||
|
||||
export type S3SignedUrlResponse = {
|
||||
url: string;
|
||||
expires_in: number;
|
||||
|
|
@ -3289,6 +3316,39 @@ export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostRespo
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/campaign-limits';
|
||||
};
|
||||
|
||||
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError = GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors[keyof GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors];
|
||||
|
||||
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: CampaignLimitsResponse;
|
||||
};
|
||||
|
||||
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse = GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses[keyof GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses];
|
||||
|
||||
export type GetSignedUrlApiV1S3SignedUrlGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
|||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isWorkflowEditor}>
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="flex min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex-1">
|
||||
{/* Optional header area for specific pages */}
|
||||
|
|
@ -60,7 +60,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
|||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue