feat: add retry config during campaign creation

This commit is contained in:
Abhishek Kumar 2026-01-29 11:57:57 +05:30
parent db75d90535
commit 6f41e91f67
14 changed files with 1036 additions and 221 deletions

View file

@ -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>
);

View file

@ -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>

View file

@ -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"