mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
Feat/campaign enhancements (#163)
* feat: add circuit breaker to safeguard * feat: Add Circuit breaker in campaigns to safeguard against telephony failures * feat: add schedules in campaigns
This commit is contained in:
parent
7552b6c819
commit
fe4ea648e4
17 changed files with 2037 additions and 149 deletions
300
ui/src/app/campaigns/CampaignAdvancedSettings.tsx
Normal file
300
ui/src/app/campaigns/CampaignAdvancedSettings.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export type TimeSlot = { day_of_week: number; start_time: string; end_time: string };
|
||||
|
||||
export interface CampaignAdvancedSettingsProps {
|
||||
// Concurrency
|
||||
maxConcurrency: string;
|
||||
onMaxConcurrencyChange: (value: string) => void;
|
||||
effectiveLimit: number;
|
||||
orgConcurrentLimit: number;
|
||||
fromNumbersCount: number;
|
||||
// Retry config
|
||||
retryEnabled: boolean;
|
||||
onRetryEnabledChange: (value: boolean) => void;
|
||||
maxRetries: string;
|
||||
onMaxRetriesChange: (value: string) => void;
|
||||
retryDelaySeconds: string;
|
||||
onRetryDelaySecondsChange: (value: string) => void;
|
||||
retryOnBusy: boolean;
|
||||
onRetryOnBusyChange: (value: boolean) => void;
|
||||
retryOnNoAnswer: boolean;
|
||||
onRetryOnNoAnswerChange: (value: boolean) => void;
|
||||
retryOnVoicemail: boolean;
|
||||
onRetryOnVoicemailChange: (value: boolean) => void;
|
||||
// Schedule config
|
||||
scheduleEnabled: boolean;
|
||||
onScheduleEnabledChange: (value: boolean) => void;
|
||||
scheduleTimezone: ITimezoneOption | string;
|
||||
onScheduleTimezoneChange: (value: ITimezoneOption | string) => void;
|
||||
timeSlots: TimeSlot[];
|
||||
onTimeSlotsChange: (value: TimeSlot[]) => void;
|
||||
}
|
||||
|
||||
/** Extract the string timezone value from ITimezoneOption | string */
|
||||
export function getTimezoneValue(tz: ITimezoneOption | string): string {
|
||||
return typeof tz === 'string' ? tz : tz.value;
|
||||
}
|
||||
|
||||
const timezoneSelectStyles = {
|
||||
control: (base: Record<string, unknown>, state: { isFocused: boolean }) => ({
|
||||
...base,
|
||||
minHeight: '36px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--background)',
|
||||
borderColor: state.isFocused ? 'var(--ring)' : 'var(--border)',
|
||||
boxShadow: state.isFocused ? '0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)' : 'none',
|
||||
'&:hover': { borderColor: 'var(--border)' },
|
||||
}),
|
||||
menu: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
zIndex: 9999,
|
||||
backgroundColor: 'var(--popover)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
}),
|
||||
menuList: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--popover)',
|
||||
padding: 0,
|
||||
}),
|
||||
option: (base: Record<string, unknown>, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected ? 'var(--accent)' : state.isFocused ? 'var(--accent)' : 'var(--popover)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
'&:active': { backgroundColor: 'var(--accent)' },
|
||||
}),
|
||||
singleValue: (base: Record<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
|
||||
input: (base: Record<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
|
||||
placeholder: (base: Record<string, unknown>) => ({ ...base, color: 'var(--muted-foreground)' }),
|
||||
indicatorSeparator: (base: Record<string, unknown>) => ({ ...base, backgroundColor: 'var(--border)' }),
|
||||
dropdownIndicator: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
color: 'var(--muted-foreground)',
|
||||
'&:hover': { color: 'var(--foreground)' },
|
||||
}),
|
||||
};
|
||||
|
||||
export default function CampaignAdvancedSettings({
|
||||
maxConcurrency, onMaxConcurrencyChange, effectiveLimit, orgConcurrentLimit, fromNumbersCount,
|
||||
retryEnabled, onRetryEnabledChange, maxRetries, onMaxRetriesChange,
|
||||
retryDelaySeconds, onRetryDelaySecondsChange,
|
||||
retryOnBusy, onRetryOnBusyChange, retryOnNoAnswer, onRetryOnNoAnswerChange,
|
||||
retryOnVoicemail, onRetryOnVoicemailChange,
|
||||
scheduleEnabled, onScheduleEnabledChange, scheduleTimezone, onScheduleTimezoneChange,
|
||||
timeSlots, onTimeSlotsChange,
|
||||
}: CampaignAdvancedSettingsProps) {
|
||||
const timezoneSelectId = useId();
|
||||
|
||||
return (
|
||||
<div className="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={`Default: ${effectiveLimit}`}
|
||||
value={maxConcurrency}
|
||||
onChange={(e) => onMaxConcurrencyChange(e.target.value)}
|
||||
min={1}
|
||||
max={effectiveLimit}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}.
|
||||
{fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`}
|
||||
</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>.
|
||||
</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.
|
||||
</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={onRetryEnabledChange}
|
||||
/>
|
||||
</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) => onMaxRetriesChange(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) => onRetryDelaySecondsChange(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={onRetryOnBusyChange} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">No Answer</span>
|
||||
<Switch checked={retryOnNoAnswer} onCheckedChange={onRetryOnNoAnswerChange} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Voicemail</span>
|
||||
<Switch checked={retryOnVoicemail} onCheckedChange={onRetryOnVoicemailChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Call Schedule */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="schedule-enabled">Call Schedule</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Restrict when calls are made
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="schedule-enabled"
|
||||
checked={scheduleEnabled}
|
||||
onCheckedChange={onScheduleEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{scheduleEnabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneSelect
|
||||
instanceId={timezoneSelectId}
|
||||
value={scheduleTimezone}
|
||||
onChange={onScheduleTimezoneChange}
|
||||
styles={timezoneSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Time Slots</Label>
|
||||
{timeSlots.map((slot, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(slot.day_of_week)}
|
||||
onValueChange={(val) => {
|
||||
const updated = [...timeSlots];
|
||||
updated[index] = { ...updated[index], day_of_week: parseInt(val) };
|
||||
onTimeSlotsChange(updated);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.start_time}
|
||||
onChange={(e) => {
|
||||
const updated = [...timeSlots];
|
||||
updated[index] = { ...updated[index], start_time: e.target.value };
|
||||
onTimeSlotsChange(updated);
|
||||
}}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">to</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.end_time}
|
||||
onChange={(e) => {
|
||||
const updated = [...timeSlots];
|
||||
updated[index] = { ...updated[index], end_time: e.target.value };
|
||||
onTimeSlotsChange(updated);
|
||||
}}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
{timeSlots.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onTimeSlotsChange(timeSlots.filter((_, i) => i !== index))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onTimeSlotsChange([...timeSlots, { day_of_week: 0, start_time: '09:00', end_time: '17:00' }])}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Time Slot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
ui/src/app/campaigns/[campaignId]/edit/page.tsx
Normal file
364
ui/src/app/campaigns/[campaignId]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
getCampaignApiV1CampaignCampaignIdGet,
|
||||
getCampaignLimitsApiV1OrganizationsCampaignLimitsGet,
|
||||
updateCampaignApiV1CampaignCampaignIdPatch,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { CampaignResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../../CampaignAdvancedSettings';
|
||||
|
||||
export default function EditCampaignPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const campaignId = parseInt(params.campaignId as string);
|
||||
|
||||
// Loading state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
|
||||
|
||||
// Form state
|
||||
const [campaignName, setCampaignName] = useState('');
|
||||
const [maxConcurrency, setMaxConcurrency] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Limits state
|
||||
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
|
||||
const [fromNumbersCount, setFromNumbersCount] = useState<number>(0);
|
||||
|
||||
// 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);
|
||||
|
||||
// Schedule config state
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false);
|
||||
const [scheduleTimezone, setScheduleTimezone] = useState<ITimezoneOption | string>('UTC');
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
|
||||
{ day_of_week: 0, start_time: '09:00', end_time: '17:00' },
|
||||
]);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Fetch campaign and populate form
|
||||
const fetchCampaign = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignApiV1CampaignCampaignIdGet({
|
||||
path: { campaign_id: campaignId },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const c = response.data;
|
||||
|
||||
// Redirect if campaign is completed or failed
|
||||
if (['completed', 'failed'].includes(c.state)) {
|
||||
router.replace(`/campaigns/${campaignId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setCampaign(c);
|
||||
|
||||
// Populate form state
|
||||
setCampaignName(c.name);
|
||||
setMaxConcurrency(c.max_concurrency ? String(c.max_concurrency) : '');
|
||||
|
||||
// Retry config
|
||||
setRetryEnabled(c.retry_config.enabled);
|
||||
setMaxRetries(String(c.retry_config.max_retries));
|
||||
setRetryDelaySeconds(String(c.retry_config.retry_delay_seconds));
|
||||
setRetryOnBusy(c.retry_config.retry_on_busy);
|
||||
setRetryOnNoAnswer(c.retry_config.retry_on_no_answer);
|
||||
setRetryOnVoicemail(c.retry_config.retry_on_voicemail);
|
||||
|
||||
// Schedule config
|
||||
if (c.schedule_config) {
|
||||
setScheduleEnabled(c.schedule_config.enabled);
|
||||
setScheduleTimezone(c.schedule_config.timezone);
|
||||
if (c.schedule_config.slots.length > 0) {
|
||||
setTimeSlots(c.schedule_config.slots.map(s => ({ ...s })));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign:', error);
|
||||
toast.error('Failed to load campaign');
|
||||
router.replace(`/campaigns/${campaignId}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken, campaignId, router]);
|
||||
|
||||
// Fetch campaign limits
|
||||
const fetchCampaignLimits = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setOrgConcurrentLimit(response.data.concurrent_call_limit);
|
||||
setFromNumbersCount(response.data.from_numbers_count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign limits:', error);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchCampaign();
|
||||
fetchCampaignLimits();
|
||||
}
|
||||
}, [fetchCampaign, fetchCampaignLimits, user]);
|
||||
|
||||
// Effective concurrency limit
|
||||
const effectiveLimit = fromNumbersCount > 0
|
||||
? Math.min(orgConcurrentLimit, fromNumbersCount)
|
||||
: orgConcurrentLimit;
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
|
||||
if (!campaignName.trim()) {
|
||||
toast.error('Campaign name is required');
|
||||
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 > 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.`);
|
||||
} else {
|
||||
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate schedule slots if enabled
|
||||
if (scheduleEnabled) {
|
||||
if (timeSlots.length === 0) {
|
||||
toast.error('Add at least one time slot');
|
||||
return;
|
||||
}
|
||||
for (const slot of timeSlots) {
|
||||
if (slot.start_time >= slot.end_time) {
|
||||
toast.error('Start time must be before end time for each slot');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
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 timezoneValue = getTimezoneValue(scheduleTimezone);
|
||||
const scheduleConfig = scheduleEnabled && timeSlots.length > 0
|
||||
? {
|
||||
enabled: true,
|
||||
timezone: timezoneValue,
|
||||
slots: timeSlots,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
timezone: timezoneValue,
|
||||
slots: [{ day_of_week: 0, start_time: '09:00', end_time: '17:00' }],
|
||||
};
|
||||
|
||||
const response = await updateCampaignApiV1CampaignCampaignIdPatch({
|
||||
path: { campaign_id: campaignId },
|
||||
body: {
|
||||
name: campaignName,
|
||||
retry_config: retryConfig,
|
||||
max_concurrency: maxConcurrencyValue,
|
||||
schedule_config: scheduleConfig,
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errorDetail = (response.error as { detail?: string })?.detail;
|
||||
const errorMessage = errorDetail || 'Failed to update campaign';
|
||||
setSubmitError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
toast.success('Campaign updated successfully');
|
||||
router.push(`/campaigns/${campaignId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update campaign:', error);
|
||||
const errorMessage = 'Failed to update campaign';
|
||||
setSubmitError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/campaigns/${campaignId}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-1/4 mb-4"></div>
|
||||
<div className="h-64 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
|
||||
<p className="text-center text-muted-foreground">Campaign not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 pb-12 space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleBack}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Campaign
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold mb-2">Edit Campaign</h1>
|
||||
<p className="text-muted-foreground">Modify campaign settings</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Update name, concurrency, retry, and schedule configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Campaign Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="campaign-name">Campaign Name</Label>
|
||||
<Input
|
||||
id="campaign-name"
|
||||
placeholder="Enter campaign name"
|
||||
value={campaignName}
|
||||
onChange={(e) => setCampaignName(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CampaignAdvancedSettings
|
||||
maxConcurrency={maxConcurrency}
|
||||
onMaxConcurrencyChange={setMaxConcurrency}
|
||||
effectiveLimit={effectiveLimit}
|
||||
orgConcurrentLimit={orgConcurrentLimit}
|
||||
fromNumbersCount={fromNumbersCount}
|
||||
retryEnabled={retryEnabled}
|
||||
onRetryEnabledChange={setRetryEnabled}
|
||||
maxRetries={maxRetries}
|
||||
onMaxRetriesChange={setMaxRetries}
|
||||
retryDelaySeconds={retryDelaySeconds}
|
||||
onRetryDelaySecondsChange={setRetryDelaySeconds}
|
||||
retryOnBusy={retryOnBusy}
|
||||
onRetryOnBusyChange={setRetryOnBusy}
|
||||
retryOnNoAnswer={retryOnNoAnswer}
|
||||
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
|
||||
retryOnVoicemail={retryOnVoicemail}
|
||||
onRetryOnVoicemailChange={setRetryOnVoicemail}
|
||||
scheduleEnabled={scheduleEnabled}
|
||||
onScheduleEnabledChange={setScheduleEnabled}
|
||||
scheduleTimezone={scheduleTimezone}
|
||||
onScheduleTimezoneChange={setScheduleTimezone}
|
||||
timeSlots={timeSlots}
|
||||
onTimeSlotsChange={setTimeSlots}
|
||||
/>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !campaignName.trim()}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { ArrowLeft, Check, Clock, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -10,7 +10,8 @@ import {
|
|||
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
|
||||
pauseCampaignApiV1CampaignCampaignIdPausePost,
|
||||
resumeCampaignApiV1CampaignCampaignIdResumePost,
|
||||
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
|
||||
startCampaignApiV1CampaignCampaignIdStartPost,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { CampaignResponse } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -236,31 +237,49 @@ export default function CampaignDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
|
||||
|
||||
// Render action button based on state
|
||||
const renderActionButton = () => {
|
||||
if (!campaign || isExecutingAction) return null;
|
||||
|
||||
const editButton = canEdit ? (
|
||||
<Button variant="outline" onClick={() => router.push(`/campaigns/${campaignId}/edit`)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Campaign
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
switch (campaign.state) {
|
||||
case 'created':
|
||||
return (
|
||||
<Button onClick={handleStart} disabled={isExecutingAction}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Start Campaign
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{editButton}
|
||||
<Button onClick={handleStart} disabled={isExecutingAction}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Start Campaign
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Button onClick={handlePause} disabled={isExecutingAction}>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Campaign
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{editButton}
|
||||
<Button onClick={handlePause} disabled={isExecutingAction}>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Campaign
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
case 'paused':
|
||||
return (
|
||||
<Button onClick={handleResume} disabled={isExecutingAction}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Resume Campaign
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{editButton}
|
||||
<Button onClick={handleResume} disabled={isExecutingAction}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Resume Campaign
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
|
|
@ -449,6 +468,51 @@ export default function CampaignDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Call Schedule (read-only) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Call Schedule</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{campaign.schedule_config?.enabled ? (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<X className="h-3 w-3" />
|
||||
Not configured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{campaign.schedule_config?.enabled && (
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Timezone</dt>
|
||||
<dd className="mt-1 font-medium">{campaign.schedule_config.timezone.replace(/_/g, ' ')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Time Slots</dt>
|
||||
<dd className="mt-1 flex flex-wrap gap-2">
|
||||
{campaign.schedule_config.slots.map((slot, index) => {
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs">{dayNames[slot.day_of_week]}</Badge>
|
||||
<span className="text-sm">{slot.start_time} - {slot.end_time}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
|
|
@ -23,9 +24,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings';
|
||||
import CsvUploadSelector from '../CsvUploadSelector';
|
||||
import GoogleSheetSelector from '../GoogleSheetSelector';
|
||||
|
||||
|
|
@ -59,6 +60,18 @@ export default function NewCampaignPage() {
|
|||
const [retryOnBusy, setRetryOnBusy] = useState(true);
|
||||
const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true);
|
||||
const [retryOnVoicemail, setRetryOnVoicemail] = useState(true);
|
||||
// Schedule config state
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false);
|
||||
const [scheduleTimezone, setScheduleTimezone] = useState<ITimezoneOption | string>(() => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
});
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
|
||||
{ day_of_week: 0, start_time: '09:00', end_time: '17:00' },
|
||||
]);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
|
|
@ -163,7 +176,6 @@ export default function NewCampaignPage() {
|
|||
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,
|
||||
|
|
@ -173,6 +185,16 @@ export default function NewCampaignPage() {
|
|||
retry_on_voicemail: retryOnVoicemail,
|
||||
};
|
||||
|
||||
// Build schedule_config if enabled
|
||||
const timezoneValue = getTimezoneValue(scheduleTimezone);
|
||||
const scheduleConfig = scheduleEnabled && timeSlots.length > 0
|
||||
? {
|
||||
enabled: true,
|
||||
timezone: timezoneValue,
|
||||
slots: timeSlots,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const response = await createCampaignApiV1CampaignCreatePost({
|
||||
body: {
|
||||
name: campaignName,
|
||||
|
|
@ -181,6 +203,7 @@ export default function NewCampaignPage() {
|
|||
source_id: sourceId,
|
||||
retry_config: retryConfig,
|
||||
max_concurrency: maxConcurrencyValue,
|
||||
schedule_config: scheduleConfig,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
|
|
@ -353,107 +376,32 @@ export default function NewCampaignPage() {
|
|||
<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={`Default: ${effectiveLimit}`}
|
||||
value={maxConcurrency}
|
||||
onChange={(e) => setMaxConcurrency(e.target.value)}
|
||||
min={1}
|
||||
max={effectiveLimit}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}.
|
||||
{fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`}
|
||||
</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>.
|
||||
</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.
|
||||
</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 className="px-4 pb-4">
|
||||
<CampaignAdvancedSettings
|
||||
maxConcurrency={maxConcurrency}
|
||||
onMaxConcurrencyChange={setMaxConcurrency}
|
||||
effectiveLimit={effectiveLimit}
|
||||
orgConcurrentLimit={orgConcurrentLimit}
|
||||
fromNumbersCount={fromNumbersCount}
|
||||
retryEnabled={retryEnabled}
|
||||
onRetryEnabledChange={setRetryEnabled}
|
||||
maxRetries={maxRetries}
|
||||
onMaxRetriesChange={setMaxRetries}
|
||||
retryDelaySeconds={retryDelaySeconds}
|
||||
onRetryDelaySecondsChange={setRetryDelaySeconds}
|
||||
retryOnBusy={retryOnBusy}
|
||||
onRetryOnBusyChange={setRetryOnBusy}
|
||||
retryOnNoAnswer={retryOnNoAnswer}
|
||||
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
|
||||
retryOnVoicemail={retryOnVoicemail}
|
||||
onRetryOnVoicemailChange={setRetryOnVoicemail}
|
||||
scheduleEnabled={scheduleEnabled}
|
||||
onScheduleEnabledChange={setScheduleEnabled}
|
||||
scheduleTimezone={scheduleTimezone}
|
||||
onScheduleTimezoneChange={setScheduleTimezone}
|
||||
timeSlots={timeSlots}
|
||||
onTimeSlotsChange={setTimeSlots}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -124,6 +124,7 @@ export type CampaignResponse = {
|
|||
completed_at: string | null;
|
||||
retry_config: RetryConfigResponse;
|
||||
max_concurrency?: number | null;
|
||||
schedule_config?: ScheduleConfigResponse | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -244,6 +245,7 @@ export type CreateCampaignRequest = {
|
|||
source_id: string;
|
||||
retry_config?: RetryConfigRequest | null;
|
||||
max_concurrency?: number | null;
|
||||
schedule_config?: ScheduleConfigRequest | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -777,6 +779,18 @@ export type S3SignedUrlResponse = {
|
|||
expires_in: number;
|
||||
};
|
||||
|
||||
export type ScheduleConfigRequest = {
|
||||
enabled?: boolean;
|
||||
timezone?: string;
|
||||
slots: Array<TimeSlotRequest>;
|
||||
};
|
||||
|
||||
export type ScheduleConfigResponse = {
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
slots: Array<TimeSlotResponse>;
|
||||
};
|
||||
|
||||
export type ServiceKeyResponse = {
|
||||
name: string;
|
||||
id: number;
|
||||
|
|
@ -862,6 +876,18 @@ export type TestSessionResponse = {
|
|||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type TimeSlotRequest = {
|
||||
day_of_week: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
};
|
||||
|
||||
export type TimeSlotResponse = {
|
||||
day_of_week: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A parameter that the tool accepts.
|
||||
*/
|
||||
|
|
@ -1013,6 +1039,13 @@ export type TwilioConfigurationResponse = {
|
|||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
export type UpdateCampaignRequest = {
|
||||
name?: string | null;
|
||||
retry_config?: RetryConfigRequest | null;
|
||||
max_concurrency?: number | null;
|
||||
schedule_config?: ScheduleConfigRequest | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for updating a webhook credential.
|
||||
*/
|
||||
|
|
@ -2727,6 +2760,41 @@ export type GetCampaignApiV1CampaignCampaignIdGetResponses = {
|
|||
|
||||
export type GetCampaignApiV1CampaignCampaignIdGetResponse = GetCampaignApiV1CampaignCampaignIdGetResponses[keyof GetCampaignApiV1CampaignCampaignIdGetResponses];
|
||||
|
||||
export type UpdateCampaignApiV1CampaignCampaignIdPatchData = {
|
||||
body: UpdateCampaignRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/campaign/{campaign_id}';
|
||||
};
|
||||
|
||||
export type UpdateCampaignApiV1CampaignCampaignIdPatchErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateCampaignApiV1CampaignCampaignIdPatchError = UpdateCampaignApiV1CampaignCampaignIdPatchErrors[keyof UpdateCampaignApiV1CampaignCampaignIdPatchErrors];
|
||||
|
||||
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: CampaignResponse;
|
||||
};
|
||||
|
||||
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponse = UpdateCampaignApiV1CampaignCampaignIdPatchResponses[keyof UpdateCampaignApiV1CampaignCampaignIdPatchResponses];
|
||||
|
||||
export type StartCampaignApiV1CampaignCampaignIdStartPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue