Import error handling and subscribe state
This commit is contained in:
@@ -96,16 +96,26 @@ export function createImportWorker() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract custom data (all fields except email)
|
// Extract subscribed field if present
|
||||||
const {email: _, ...customData} = record;
|
const subscribedValue = record.subscribed;
|
||||||
|
let subscribed: boolean | undefined;
|
||||||
|
|
||||||
|
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||||
|
// Handle various truthy/falsy values
|
||||||
|
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||||
|
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract custom data (all fields except email and subscribed)
|
||||||
|
const {email: _, subscribed: __, ...customData} = record;
|
||||||
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
||||||
|
|
||||||
// Check if contact exists before upserting
|
// Check if contact exists before upserting
|
||||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||||
const isUpdate = !!existingContact;
|
const isUpdate = !!existingContact;
|
||||||
|
|
||||||
// Upsert contact
|
// Upsert contact with subscribed value from CSV if provided, otherwise default to true
|
||||||
await ContactService.upsert(projectId, email, data, true);
|
await ContactService.upsert(projectId, email, data, subscribed ?? true);
|
||||||
|
|
||||||
result.successCount++;
|
result.successCount++;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export class QueueService {
|
|||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
|
const failedReason = job.failedReason;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
@@ -323,6 +324,7 @@ export class QueueService {
|
|||||||
progress,
|
progress,
|
||||||
result: returnValue,
|
result: returnValue,
|
||||||
data: job.data,
|
data: job.data,
|
||||||
|
failedReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
<CardDescription>Your project is fully set up</CardDescription>
|
<CardDescription>Your project is fully set up</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start gap-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
<div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||||
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
|
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-700" />
|
<CheckCircle2 className="h-5 w-5 text-green-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ReactFlow,
|
ReactFlow,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useReactFlow,
|
useReactFlow
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import type {WorkflowStep} from '@plunk/db';
|
import type {WorkflowStep} from '@plunk/db';
|
||||||
@@ -702,7 +702,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-[800px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner relative">
|
<div className="w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -821,7 +821,8 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{stepToDelete && (() => {
|
{stepToDelete &&
|
||||||
|
(() => {
|
||||||
const affectedSteps = getAffectedSteps(stepToDelete);
|
const affectedSteps = getAffectedSteps(stepToDelete);
|
||||||
const stepToDeleteData = steps.find(s => s.id === stepToDelete);
|
const stepToDeleteData = steps.find(s => s.id === stepToDelete);
|
||||||
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
|
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import type {WorkflowStep} from '@plunk/db';
|
import type {WorkflowStep} from '@plunk/db';
|
||||||
@@ -469,7 +469,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[700px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner">
|
<div className="w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
StickySaveBar,
|
StickySaveBar
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign, Segment} from '@plunk/db';
|
import type {Campaign, Segment} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
@@ -36,7 +36,20 @@ import {EmailEditor} from '../../components/EmailEditor';
|
|||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
|
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
|
||||||
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
||||||
import {ArrowLeft, Calendar, ChevronDown, Mail, MousePointer, Save, Send, TestTube, Trash2, TrendingUp, Users, XCircle} from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
Mail,
|
||||||
|
MousePointer,
|
||||||
|
Save,
|
||||||
|
Send,
|
||||||
|
TestTube,
|
||||||
|
Trash2,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -775,7 +788,7 @@ export default function CampaignDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-3 rounded-full transition-all duration-500"
|
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
|
||||||
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
|
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -308,7 +308,11 @@ export default function ContactsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handlePreviousPage} disabled={currentPage === 0 || isLoading}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
disabled={currentPage === 0 || isLoading}
|
||||||
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
@@ -462,10 +466,24 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
|
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
|
||||||
const [result, setResult] = useState<ImportResult | null>(null);
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to truncate long file names from the middle
|
||||||
|
const truncateFileName = (fileName: string, maxLength: number = 30) => {
|
||||||
|
if (fileName.length <= maxLength) return fileName;
|
||||||
|
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf('.'));
|
||||||
|
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||||
|
const charsToShow = maxLength - extension.length - 3; // 3 for "..."
|
||||||
|
const frontChars = Math.ceil(charsToShow / 2);
|
||||||
|
const backChars = Math.floor(charsToShow / 2);
|
||||||
|
|
||||||
|
return `${nameWithoutExt.substring(0, frontChars)}...${nameWithoutExt.substring(nameWithoutExt.length - backChars)}${extension}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Clean up polling on unmount or dialog close
|
// Clean up polling on unmount or dialog close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -480,6 +498,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setErrorMessage(null);
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -511,6 +530,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
state: string;
|
state: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
result: ImportResult | null;
|
result: ImportResult | null;
|
||||||
|
failedReason?: string;
|
||||||
}>('GET', `/contacts/import/${jobId}`);
|
}>('GET', `/contacts/import/${jobId}`);
|
||||||
|
|
||||||
setProgress(response.progress || 0);
|
setProgress(response.progress || 0);
|
||||||
@@ -541,7 +561,10 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
clearInterval(pollIntervalRef.current);
|
clearInterval(pollIntervalRef.current);
|
||||||
pollIntervalRef.current = null;
|
pollIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
toast.error('Import failed. Please try again.');
|
// Store and show the specific error message if available, otherwise show generic error
|
||||||
|
const errorMsg = response.failedReason || 'Import failed. Please check your CSV file and try again.';
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
} else if (response.state === 'active') {
|
} else if (response.state === 'active') {
|
||||||
setStatus('processing');
|
setStatus('processing');
|
||||||
}
|
}
|
||||||
@@ -579,7 +602,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
void pollJobStatus(data.jobId);
|
void pollJobStatus(data.jobId);
|
||||||
}, 1000); // Poll every second
|
}, 1000); // Poll every second
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to upload file');
|
const errorMsg = error instanceof Error ? error.message : 'Failed to upload file';
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
setStatus('failed');
|
setStatus('failed');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -615,6 +640,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
<li>
|
<li>
|
||||||
Required column: <code className="bg-blue-100 px-1 rounded">email</code>
|
Required column: <code className="bg-blue-100 px-1 rounded">email</code>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Optional: <code className="bg-blue-100 px-1 rounded">subscribed</code> (true/false, 1/0, yes/no)
|
||||||
|
</li>
|
||||||
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
|
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
|
||||||
<li>Maximum file size: 5MB</li>
|
<li>Maximum file size: 5MB</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -640,7 +668,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FileUp className="h-4 w-4 mr-2" />
|
<FileUp className="h-4 w-4 mr-2" />
|
||||||
{file ? file.name : 'Choose CSV File'}
|
{file ? truncateFileName(file.name) : 'Choose CSV File'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -725,7 +753,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
<XCircle className="h-5 w-5" />
|
<XCircle className="h-5 w-5" />
|
||||||
<span className="font-medium">Import failed</span>
|
<span className="font-medium">Import failed</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-red-800 mt-1">Please check your CSV file and try again.</p>
|
<p className="text-sm text-red-800 mt-1">
|
||||||
|
{errorMessage || 'Please check your CSV file and try again.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Danger Zone - Separate Card */}
|
{/* Danger Zone - Separate Card */}
|
||||||
<Card className="border-red-200 mt-6">
|
<Card className="border-red-200 mt-6">
|
||||||
<CardHeader className="border-b border-red-100 bg-gradient-to-r from-red-50 to-orange-50">
|
<CardHeader className="border-b border-red-100 bg-red-50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
|
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||||
|
|||||||
Reference in New Issue
Block a user