feat: Sprint 1 - Deep discovery, lead persistence, and dashboard nav

- Add BacklinkOutreachScraper (Exa + DuckDuckGo deep scraping)
- Extend DB and Pydantic models for lead enrichment columns
- Add StorageService methods for lead CRUD with auto-migration
- Add backend endpoints: deep discover, campaign detail, lead management
- Extend frontend API client and store with discovery + lead actions
- Create BacklinkOutreachDashboard component with campaigns/discover/leads tabs
- Register route at /backlink-outreach under SEO feature flag
- Add nav entry under Enterprise & Advanced in tool categories
This commit is contained in:
ajaysi
2026-05-23 17:07:33 +05:30
parent 816d59a30a
commit 090d69761f
22 changed files with 3494 additions and 48 deletions

View File

@@ -78,6 +78,9 @@ const ProductAnimationStudio = React.lazy(() => import('./components/ProductMark
const ProductVideoStudio = React.lazy(() => import('./components/ProductMarketing').then(m => ({ default: m.ProductVideoStudio })));
const ProductAvatarStudio = React.lazy(() => import('./components/ProductMarketing').then(m => ({ default: m.ProductAvatarStudio })));
// BacklinkOutreach barrel (1 export)
const BacklinkOutreachDashboard = React.lazy(() => import('./components/BacklinkOutreach').then(m => ({ default: m.BacklinkOutreachDashboard })));
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
const RootRoute: React.FC = () => {
const { isSignedIn } = useAuth();
@@ -189,6 +192,7 @@ const App: React.FC = () => {
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
<Route path="/seo" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/seo-dashboard" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/backlink-outreach" element={<ProtectedRoute><FeatureRoute feature="seo"><BacklinkOutreachDashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/content-planning" element={<ProtectedRoute><FeatureRoute feature="content-planning"><ContentPlanningDashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/facebook-writer" element={<ProtectedRoute><FeatureRoute feature="social"><FacebookWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/linkedin-writer" element={<ProtectedRoute><FeatureRoute feature="social"><LinkedInWriter /></FeatureRoute></ProtectedRoute>} />

View File

@@ -104,3 +104,87 @@ export const fetchBacklinkReportingSnapshot = async (): Promise<BacklinkReportin
export const createBacklinkCampaign = async (payload: BacklinkCampaignCreateRequest): Promise<BacklinkCampaignCreateResponse> => (await apiClient.post('/api/backlink-outreach/campaigns', payload)).data;
export const listBacklinkCampaigns = async (user_id: string, workspace_id: string): Promise<BacklinkCampaignListResponse> => (await apiClient.get('/api/backlink-outreach/campaigns', { params: { user_id, workspace_id } })).data;
// -- Deep Discovery --
export interface EnrichedOpportunity {
url: string;
domain: string;
page_title: string;
snippet: string;
full_text: string;
email: string | null;
contact_page: string | null;
confidence_score: number;
quality_score: number;
word_count: number;
has_guest_post_guidelines: boolean;
discovery_source: string;
}
export interface DeepDiscoveryRequest {
keyword: string;
max_results?: number;
campaign_id?: string;
}
export interface DeepDiscoveryResponse {
keyword: string;
source: string;
total_found: number;
opportunities: EnrichedOpportunity[];
}
export const discoverDeepBacklinkOpportunities = async (payload: DeepDiscoveryRequest): Promise<DeepDiscoveryResponse> => (await apiClient.post('/api/backlink-outreach/discover/deep', payload)).data;
// -- Leads --
export interface LeadRecord {
lead_id: string;
campaign_id: string;
url: string | null;
domain: string;
page_title: string;
snippet: string;
email: string | null;
confidence_score: number;
discovery_source: string;
status: string;
notes: string | null;
created_at: string | null;
}
export interface LeadListResponse {
leads: LeadRecord[];
total: number;
}
export interface LeadCreateRequest {
campaign_id: string;
url: string;
domain: string;
email?: string;
page_title?: string;
snippet?: string;
confidence_score?: number;
notes?: string;
}
export interface LeadStatusUpdateRequest {
status: string;
notes?: string;
}
export interface CampaignDetailResponse {
campaign_id: string;
name: string;
status: string;
created_at: string | null;
lead_count: number;
leads: LeadRecord[];
}
export const fetchCampaignDetail = async (campaign_id: string, user_id: string): Promise<CampaignDetailResponse> => (await apiClient.get(`/api/backlink-outreach/campaigns/${campaign_id}`, { params: { user_id } })).data;
export const fetchCampaignLeads = async (campaign_id: string, user_id: string, status?: string): Promise<LeadListResponse> => (await apiClient.get(`/api/backlink-outreach/campaigns/${campaign_id}/leads`, { params: { user_id, status } })).data;
export const addLeadToCampaign = async (campaign_id: string, payload: LeadCreateRequest): Promise<LeadRecord> => (await apiClient.post(`/api/backlink-outreach/campaigns/${campaign_id}/leads`, payload)).data;
export const updateLeadStatus = async (lead_id: string, payload: LeadStatusUpdateRequest): Promise<LeadRecord> => (await apiClient.patch(`/api/backlink-outreach/leads/${lead_id}/status`, payload)).data;

View File

@@ -0,0 +1,65 @@
import { apiClient } from './client';
export interface BlogAsset {
id: number;
title: string | null;
description: string | null;
tags: string[];
phase: string;
research_keywords: string | null;
topic: string | null;
selected_title: string | null;
word_count_target: number | null;
has_research: boolean;
has_outline: boolean;
has_content: boolean;
has_seo: boolean;
has_publish: boolean;
created_at: string | null;
updated_at: string | null;
}
export interface BlogAssetFull extends BlogAsset {
research_data?: any;
outline_data?: any;
content_data?: any;
seo_data?: any;
publish_data?: any;
}
export interface CreateAssetParams {
research_keywords: string;
topic?: string;
word_count_target?: number;
}
export interface UpdateAssetParams {
phase?: 'research' | 'outline' | 'content' | 'seo' | 'publish';
topic?: string;
selected_title?: string;
word_count_target?: number;
research_data?: any;
outline_data?: any;
content_data?: any;
seo_data?: any;
publish_data?: any;
}
class BlogAssetAPI {
async create(params: CreateAssetParams): Promise<{ success: boolean; asset: BlogAsset; existing: boolean }> {
const res = await apiClient.post('/api/blog/asset', params);
return res.data;
}
async update(assetId: number, params: UpdateAssetParams): Promise<{ success: boolean; asset: BlogAsset }> {
const res = await apiClient.put(`/api/blog/asset/${assetId}`, params);
return res.data;
}
async get(assetId: number): Promise<{ success: boolean; asset: BlogAssetFull }> {
const res = await apiClient.get(`/api/blog/asset/${assetId}`);
return res.data;
}
}
export const blogAssetAPI = new BlogAssetAPI();

View File

@@ -0,0 +1,240 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useBacklinkOutreachStore } from '../../stores/backlinkOutreachStore';
const BacklinkOutreachDashboard: React.FC = () => {
const {
campaigns, selectedCampaign, discoveredOpportunities,
isLoading, isDiscovering, error,
fetchCampaigns, createCampaign, selectCampaign,
deepDiscover, clearDiscoveries,
} = useBacklinkOutreachStore();
const [activeTab, setActiveTab] = useState<'campaigns' | 'discover' | 'leads'>('campaigns');
const [newCampaignName, setNewCampaignName] = useState('');
const [keyword, setKeyword] = useState('');
useEffect(() => {
fetchCampaigns('default', 'default');
}, [fetchCampaigns]);
const handleCreateCampaign = useCallback(async () => {
if (!newCampaignName.trim()) return;
const id = await createCampaign('default', 'default', newCampaignName.trim());
if (id) {
setNewCampaignName('');
setActiveTab('discover');
}
}, [newCampaignName, createCampaign]);
const handleDiscover = useCallback(async () => {
if (!keyword.trim()) return;
await deepDiscover(keyword.trim(), 15);
}, [keyword, deepDiscover]);
const handleDiscoverAndSave = useCallback(async (campaignId: string) => {
if (!keyword.trim()) return;
await deepDiscover(keyword.trim(), 15, campaignId);
}, [keyword, deepDiscover]);
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<h1>Backlink Outreach</h1>
<p style={{ color: '#666', marginBottom: '24px' }}>
Discover guest post opportunities, manage campaigns, and track outreach.
</p>
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', borderBottom: '2px solid #eee', paddingBottom: '8px' }}>
{(['campaigns', 'discover', 'leads'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '8px 20px',
border: 'none',
background: activeTab === tab ? '#1976D2' : 'transparent',
color: activeTab === tab ? '#fff' : '#666',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: activeTab === tab ? 600 : 400,
}}
>
{tab === 'campaigns' ? 'Campaigns' : tab === 'discover' ? 'Discover' : 'Leads'}
</button>
))}
</div>
{error && (
<div style={{ padding: '12px', background: '#ffebee', color: '#c62828', borderRadius: '6px', marginBottom: '16px' }}>
{error}
</div>
)}
{/* Tab: Campaigns */}
{activeTab === 'campaigns' && (
<div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
<input
type="text"
value={newCampaignName}
onChange={(e) => setNewCampaignName(e.target.value)}
placeholder="Campaign name"
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px' }}
/>
<button
onClick={handleCreateCampaign}
disabled={!newCampaignName.trim() || isLoading}
style={{
padding: '10px 24px', background: '#1976D2', color: '#fff',
border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
}}
>
{isLoading ? 'Creating...' : 'Create Campaign'}
</button>
</div>
{campaigns.length === 0 && !isLoading && (
<p style={{ color: '#999' }}>No campaigns yet. Create one to get started.</p>
)}
{campaigns.map((c) => (
<div
key={c.campaign_id}
onClick={() => { selectCampaign(c.campaign_id, 'default'); setActiveTab('leads'); }}
style={{
padding: '16px', marginBottom: '8px', border: '1px solid #e0e0e0',
borderRadius: '8px', cursor: 'pointer', background: '#fafafa',
}}
>
<div style={{ fontWeight: 600 }}>{c.name}</div>
<div style={{ fontSize: '13px', color: '#888', marginTop: '4px' }}>
Status: {c.status}
{c.created_at && <> &middot; Created: {new Date(c.created_at).toLocaleDateString()}</>}
</div>
</div>
))}
{isLoading && <p style={{ color: '#999' }}>Loading campaigns...</p>}
</div>
)}
{/* Tab: Discover */}
{activeTab === 'discover' && (
<div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Enter keyword (e.g. 'AI marketing')"
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px' }}
/>
<button
onClick={handleDiscover}
disabled={!keyword.trim() || isDiscovering}
style={{
padding: '10px 24px', background: '#2e7d32', color: '#fff',
border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
}}
>
{isDiscovering ? 'Searching...' : 'Discover'}
</button>
</div>
{isDiscovering && <p style={{ color: '#666' }}>Searching for opportunities using Exa + DuckDuckGo...</p>}
{discoveredOpportunities.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontWeight: 600 }}>Found {discoveredOpportunities.length} opportunities</span>
<button
onClick={clearDiscoveries}
style={{ padding: '6px 16px', background: 'transparent', border: '1px solid #ccc', borderRadius: '4px', cursor: 'pointer' }}
>
Clear
</button>
</div>
{discoveredOpportunities.map((opp, i) => (
<div
key={`${opp.url}-${i}`}
style={{
padding: '14px', marginBottom: '8px', border: '1px solid #e0e0e0',
borderRadius: '8px', background: '#fff',
}}
>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
<a href={opp.url} target="_blank" rel="noopener noreferrer" style={{ color: '#1976D2', textDecoration: 'none' }}>
{opp.page_title || opp.domain}
</a>
</div>
<div style={{ fontSize: '13px', color: '#666', marginBottom: '4px' }}>{opp.domain}</div>
{opp.snippet && (
<div style={{ fontSize: '13px', color: '#555', marginBottom: '6px' }}>{opp.snippet.slice(0, 200)}...</div>
)}
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: '#888' }}>
<span>Quality: {(opp.quality_score * 100).toFixed(0)}%</span>
<span>Confidence: {(opp.confidence_score * 100).toFixed(0)}%</span>
<span>Words: {opp.word_count}</span>
{opp.has_guest_post_guidelines && <span style={{ color: '#2e7d32' }}>Has guidelines</span>}
{opp.email && <span style={{ color: '#1565c0' }}>Email found</span>}
</div>
<div style={{ marginTop: '8px' }}>
<button
onClick={() => campaigns.length > 0 && handleDiscoverAndSave(campaigns[0].campaign_id)}
disabled={campaigns.length === 0}
style={{
padding: '6px 14px', fontSize: '12px', background: '#f5f5f5',
border: '1px solid #ddd', borderRadius: '4px', cursor: campaigns.length > 0 ? 'pointer' : 'not-allowed',
}}
>
Save to first campaign
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Tab: Leads */}
{activeTab === 'leads' && (
<div>
{selectedCampaign ? (
<div>
<h3 style={{ marginBottom: '8px' }}>{selectedCampaign.name}</h3>
<p style={{ fontSize: '14px', color: '#666', marginBottom: '16px' }}>
Status: {selectedCampaign.status} &middot; {selectedCampaign.lead_count} leads
</p>
{selectedCampaign.leads.length === 0 && (
<p style={{ color: '#999' }}>No leads yet. Go to Discover tab to find opportunities.</p>
)}
{selectedCampaign.leads.map((lead) => (
<div
key={lead.lead_id}
style={{
padding: '14px', marginBottom: '8px', border: '1px solid #e0e0e0',
borderRadius: '8px', background: '#fff',
}}
>
<div style={{ fontWeight: 600 }}>{lead.page_title || lead.domain}</div>
<div style={{ fontSize: '13px', color: '#888', marginTop: '4px' }}>
{lead.url && <a href={lead.url} target="_blank" rel="noopener noreferrer" style={{ color: '#1976D2' }}>{lead.url}</a>}
</div>
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: '#888', marginTop: '6px' }}>
<span>Status: <strong>{lead.status}</strong></span>
{lead.email && <span>Email: {lead.email}</span>}
<span>Source: {lead.discovery_source}</span>
</div>
</div>
))}
</div>
) : (
<p style={{ color: '#999' }}>Select a campaign from the Campaigns tab to view its leads.</p>
)}
</div>
)}
</div>
);
};
export default BacklinkOutreachDashboard;

View File

@@ -0,0 +1 @@
export { default as BacklinkOutreachDashboard } from './BacklinkOutreachDashboard';

View File

@@ -18,7 +18,8 @@ import {
CalendarMonth as CalendarIcon,
AudioFile as AudioIcon,
Image as ImageIcon,
VideoLibrary as VideoIcon
VideoLibrary as VideoIcon,
Link as LinkIcon
} from '@mui/icons-material';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import { ToolCategories } from '../components/shared/types';
@@ -127,6 +128,16 @@ export const toolCategories: ToolCategories = {
isPinned: true,
isHighlighted: true
},
{
name: 'Backlink Outreach',
description: 'Discover guest post opportunities with AI-powered deep scraping',
icon: React.createElement(LinkIcon),
status: 'beta',
path: '/backlink-outreach',
features: ['AI Discovery', 'Guest Post Opportunities', 'Campaign Management'],
isPinned: true,
isHighlighted: true
},
{
name: 'AI Content Strategy Generator',
description: 'Comprehensive content planning with market intelligence',

View File

@@ -0,0 +1,105 @@
import { useState, useCallback, useRef } from 'react';
import { blogAssetAPI, BlogAssetFull, BlogAsset } from '../api/blogAsset';
import { debug } from '../utils/debug';
export function useBlogAsset() {
const [assetId, setAssetId] = useState<number | null>(null);
const [asset, setAsset] = useState<BlogAssetFull | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createInProgressRef = useRef(false);
const createAsset = useCallback(async (
researchKeywords: string,
topic?: string,
wordCountTarget?: number,
): Promise<number | null> => {
if (createInProgressRef.current) return assetId;
createInProgressRef.current = true;
setLoading(true);
setError(null);
try {
const result = await blogAssetAPI.create({
research_keywords: researchKeywords,
topic,
word_count_target: wordCountTarget,
});
const newId = result.asset.id;
setAssetId(newId);
setAsset(result.asset as BlogAssetFull);
debug.log('[BlogAsset] Created:', newId, 'existing:', result.existing);
return newId;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create asset';
setError(msg);
debug.error('[BlogAsset] Create failed:', msg);
return null;
} finally {
setLoading(false);
createInProgressRef.current = false;
}
}, [assetId]);
const updatePhase = useCallback(async (
phase: 'research' | 'outline' | 'content' | 'seo' | 'publish',
data?: any,
extra?: Record<string, any>,
) => {
if (assetId === null || assetId === undefined) return;
setLoading(true);
try {
const payload: any = { phase };
if (data) payload[`${phase}_data`] = data;
if (extra) Object.assign(payload, extra);
const result = await blogAssetAPI.update(assetId, payload);
setAsset((prev: BlogAssetFull | null) => ({
...(prev || {}),
...result.asset,
...(data ? { [`${phase}_data`]: data } : {}),
}) as BlogAssetFull);
debug.log('[BlogAsset] Updated phase:', phase, 'asset_id:', assetId);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update asset';
setError(msg);
debug.error('[BlogAsset] Update failed:', msg);
} finally {
setLoading(false);
}
}, [assetId]);
const loadAsset = useCallback(async (id: number): Promise<BlogAssetFull | null> => {
setLoading(true);
setError(null);
try {
const result = await blogAssetAPI.get(id);
setAssetId(id);
setAsset(result.asset);
debug.log('[BlogAsset] Loaded:', id, 'phase:', result.asset.phase);
return result.asset;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load asset';
setError(msg);
debug.error('[BlogAsset] Load failed:', msg);
return null;
} finally {
setLoading(false);
}
}, []);
const resetAsset = useCallback(() => {
setAssetId(null);
setAsset(null);
setError(null);
}, []);
return {
assetId,
asset,
loading,
error,
createAsset,
updatePhase,
loadAsset,
resetAsset,
};
}

View File

@@ -4,9 +4,14 @@ import {
BacklinkCampaignRecord,
BacklinkCoverageResponse,
BacklinkModuleRecord,
CampaignDetailResponse,
createBacklinkCampaign,
discoverDeepBacklinkOpportunities,
EnrichedOpportunity,
fetchBacklinkMigrationCoverage,
fetchBacklinkModuleRegistry,
fetchCampaignDetail,
LeadRecord,
listBacklinkCampaigns,
} from '../api/backlinkOutreachApi';
@@ -14,18 +19,29 @@ interface BacklinkOutreachStore {
modules: BacklinkModuleRecord[];
coverage: BacklinkCoverageResponse | null;
campaigns: BacklinkCampaignRecord[];
selectedCampaign: CampaignDetailResponse | null;
discoveredOpportunities: EnrichedOpportunity[];
leads: LeadRecord[];
isLoading: boolean;
isDiscovering: boolean;
error: string | null;
refreshBacklinkRegistry: () => Promise<void>;
fetchCampaigns: (userId: string, workspaceId: string) => Promise<void>;
createCampaign: (userId: string, workspaceId: string, name: string) => Promise<string | null>;
selectCampaign: (campaignId: string, userId: string) => Promise<void>;
deepDiscover: (keyword: string, maxResults?: number, campaignId?: string) => Promise<EnrichedOpportunity[]>;
clearDiscoveries: () => void;
}
export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => ({
modules: [],
coverage: null,
campaigns: [],
selectedCampaign: null,
discoveredOpportunities: [],
leads: [],
isLoading: false,
isDiscovering: false,
error: null,
refreshBacklinkRegistry: async () => {
set({ isLoading: true, error: null });
@@ -71,4 +87,31 @@ export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => (
return null;
}
},
selectCampaign: async (campaignId: string, userId: string) => {
set({ isLoading: true, error: null });
try {
const detail = await fetchCampaignDetail(campaignId, userId);
set({ selectedCampaign: detail, leads: detail.leads, isLoading: false });
} catch (error: any) {
set({
isLoading: false,
error: error?.message ?? 'Failed to load campaign',
});
}
},
deepDiscover: async (keyword: string, maxResults?: number, campaignId?: string) => {
set({ isDiscovering: true, error: null });
try {
const result = await discoverDeepBacklinkOpportunities({ keyword, max_results: maxResults, campaign_id: campaignId });
set({ discoveredOpportunities: result.opportunities, isDiscovering: false });
return result.opportunities;
} catch (error: any) {
set({
isDiscovering: false,
error: error?.message ?? 'Discovery failed',
});
return [];
}
},
clearDiscoveries: () => set({ discoveredOpportunities: [] }),
}));