#3 — Duplicate prospect handling: add_lead now checks (campaign_id, url) before insert; bulk_add_leads skips existing URLs. #8 — Atomic rate limiting: try_increment_* methods atomically check cap and increment in a single session; router uses these before send. #10 — Reply matching via Message-ID: sender generates Message-ID header, stored on OutreachAttempt; reply monitor parses In-Reply-To/References; poll_replies matches by message_id first, falls back to from_email. #11 — Save-to-campaign uses existing store results instead of re-running expensive deepDiscover. #12 — Lead status Literal type: Pydantic models enforce valid status values; backend validates via LEAD_VALID_STATUSES frozenset; frontend API typed as LeadStatus union.
This commit is contained in:
@@ -158,7 +158,7 @@ export interface LeadRecord {
|
||||
email: string | null;
|
||||
confidence_score: number;
|
||||
discovery_source: string;
|
||||
status: string;
|
||||
status: LeadStatus;
|
||||
notes: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
@@ -179,8 +179,10 @@ export interface LeadCreateRequest {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type LeadStatus = 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed';
|
||||
|
||||
export interface LeadStatusUpdateRequest {
|
||||
status: string;
|
||||
status: LeadStatus;
|
||||
notes?: string;
|
||||
campaign_id?: string;
|
||||
}
|
||||
@@ -335,7 +337,7 @@ export interface FollowUpRequest {
|
||||
|
||||
export interface BulkStatusUpdateRequest {
|
||||
lead_ids: string[];
|
||||
status: string;
|
||||
status: LeadStatus;
|
||||
notes?: string;
|
||||
campaign_id?: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GenerateEmailRequest,
|
||||
bulkUpdateLeadStatus,
|
||||
updateLeadStatus,
|
||||
addLeadToCampaign,
|
||||
fetchCampaignAnalyticsVolume,
|
||||
fetchCampaignAnalyticsFunnel,
|
||||
CampaignVolumePoint,
|
||||
@@ -25,7 +26,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as
|
||||
|
||||
type Tab = 'campaigns' | 'discover' | 'leads' | 'composer' | 'analytics';
|
||||
|
||||
const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed'];
|
||||
const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed'] as const;
|
||||
|
||||
const STATUS_EXPLANATIONS: Record<string, string> = {
|
||||
discovered: 'Lead found but not yet contacted',
|
||||
@@ -139,7 +140,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
|
||||
const [selectedLeadIds, setSelectedLeadIds] = useState<Set<string>>(new Set());
|
||||
const [bulkStatus, setBulkStatus] = useState('contacted');
|
||||
const [bulkStatus, setBulkStatus] = useState<'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed'>('contacted');
|
||||
|
||||
const [volumeData, setVolumeData] = useState<CampaignVolumePoint[]>([]);
|
||||
const [funnelData, setFunnelData] = useState<FunnelStage[]>([]);
|
||||
@@ -203,9 +204,24 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
}, [keyword, deepDiscover]);
|
||||
|
||||
const handleDiscoverAndSave = useCallback(async () => {
|
||||
if (!keyword.trim() || !discoverCampaignId) return;
|
||||
await deepDiscover(keyword.trim(), 15, discoverCampaignId);
|
||||
}, [keyword, discoverCampaignId, deepDiscover]);
|
||||
if (!keyword.trim() || !discoverCampaignId || discoveredOpportunities.length === 0) return;
|
||||
for (const opp of discoveredOpportunities) {
|
||||
try {
|
||||
await addLeadToCampaign(discoverCampaignId, {
|
||||
campaign_id: discoverCampaignId,
|
||||
url: opp.url,
|
||||
domain: opp.domain,
|
||||
page_title: opp.page_title,
|
||||
snippet: opp.snippet,
|
||||
email: opp.email ?? undefined,
|
||||
confidence_score: opp.confidence_score,
|
||||
});
|
||||
} catch (e) {
|
||||
// skip duplicates
|
||||
}
|
||||
}
|
||||
showToastNotification(`Saved ${discoveredOpportunities.length} leads to campaign`, 'success');
|
||||
}, [keyword, discoverCampaignId, discoveredOpportunities]);
|
||||
|
||||
const handleSelectCampaign = useCallback(async (campaignId: string) => {
|
||||
await selectCampaign(campaignId);
|
||||
@@ -324,7 +340,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSingleStatusUpdate = async (leadId: string, status: string) => {
|
||||
const handleSingleStatusUpdate = async (leadId: string, status: 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed') => {
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
await updateLeadStatus(leadId, {
|
||||
@@ -681,7 +697,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
{selectedLeadIds.size > 0 && (
|
||||
<>
|
||||
<TooltipWrap text="Choose the new status for all selected leads">
|
||||
<select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value)}
|
||||
<select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value as typeof bulkStatus)}
|
||||
style={{ ...selectSx, padding: '6px 10px', fontSize: '12px', minWidth: '130px' }}>
|
||||
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user