Merge branch 'pr-486'

This commit is contained in:
ajaysi
2026-05-23 15:18:15 +05:30
13 changed files with 797 additions and 2 deletions

View File

@@ -351,3 +351,27 @@ If you encounter issues:
---
**Happy coding! 🎉**
## Backlink Outreach Migration Map
Canonical migrated backlinking module paths:
- Router: `backend/routers/backlink_outreach.py`
- Service: `backend/services/backlink_outreach_service.py`
- Frontend API client: `frontend/src/api/backlinkOutreachApi.ts`
- Frontend store: `frontend/src/stores/backlinkOutreachStore.ts`
- Frontend UI integration: `frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx`
Invoke from backend:
- `GET /api/backlink-outreach/modules`
- `GET /api/backlink-outreach/query-templates?keyword=<keyword>`
- `GET /api/backlink-outreach/migration-coverage`
- `POST /api/backlink-outreach/discover` with JSON body: `{ "keyword": "...", "max_results": 10 }`
- `POST /api/backlink-outreach/policy-validate` to enforce compliance/suppression/throttles before send
- `GET /api/backlink-outreach/reporting` for send-volume and conversion snapshot
- `POST /api/backlink-outreach/campaigns` and `GET /api/backlink-outreach/campaigns` for persisted campaign records (campaign-creator style storage flow)
The modules endpoint returns migration identifiers: `backlink`, `outreach`, and `guest_post`.
The query-template endpoint mirrors legacy `generate_search_queries(...)` behavior from `ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py`.
The migration-coverage endpoint summarizes what is already implemented vs planned from the legacy prototype roadmap.

View File

@@ -138,6 +138,7 @@ if _is_full_mode():
from routers.image_studio import router as image_studio_router
from routers.product_marketing import router as product_marketing_router
from routers.campaign_creator import router as campaign_creator_router
from routers.backlink_outreach import router as backlink_outreach_router
else:
# In feature-only modes, only load essential assets router
from api.assets_serving import router as assets_serving_router
@@ -146,6 +147,7 @@ else:
image_studio_router = None
product_marketing_router = None
campaign_creator_router = None
backlink_outreach_router = None
# Import hallucination detector router
try:
@@ -678,6 +680,8 @@ if _is_full_mode():
app.include_router(product_marketing_router)
if campaign_creator_router:
app.include_router(campaign_creator_router)
if backlink_outreach_router:
app.include_router(backlink_outreach_router)
router_group_status["platform_extensions"] = {
"mounted": True,

View File

@@ -0,0 +1,31 @@
# Backlink Migration Audit (Legacy vs Current)
Legacy prototype reference:
- `ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py`
- `ToBeMigrated/ai_marketing_tools/ai_backlinker/backlinking_ui_streamlit.py`
## Implemented in current branch
- Canonical backend entrypoint with backlink-specific naming:
- `backend/routers/backlink_outreach.py`
- `backend/services/backlink_outreach_service.py`
- Legacy-style guest-post query template generation exposed over API:
- `GET /api/backlink-outreach/query-templates?keyword=<keyword>`
- Migration traceability metadata endpoints:
- `GET /api/backlink-outreach/modules`
- `GET /api/backlink-outreach/migration-coverage`
- Frontend integration points with backlink-specific naming:
- `frontend/src/api/backlinkOutreachApi.ts`
- `frontend/src/stores/backlinkOutreachStore.ts`
- `frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx`
## Not yet migrated (planned)
- Live web prospect discovery / scraping execution loop (`find_backlink_opportunities`).
- Outreach email sending + reply monitoring loop (`send_email`, IMAP checks).
- End-to-end campaign orchestration from keyword batch -> outreach -> follow-up.
## Notes
This branch intentionally provides a clean migration seam and auditable entrypoints first.
Feature-complete parity can now be implemented incrementally behind these stable backend and frontend contracts.

View File

@@ -77,6 +77,7 @@ from api.images import router as images_router
from routers.image_studio import router as image_studio_router
from routers.product_marketing import router as product_marketing_router
from routers.campaign_creator import router as campaign_creator_router
from routers.backlink_outreach import router as backlink_outreach_router
# Import hallucination detector router
from api.hallucination_detector import router as hallucination_detector_router
@@ -402,6 +403,7 @@ app.include_router(images_router)
app.include_router(image_studio_router)
app.include_router(product_marketing_router)
app.include_router(campaign_creator_router)
app.include_router(backlink_outreach_router)
# Include content assets router
from api.content_assets.router import router as content_assets_router

View File

@@ -0,0 +1,59 @@
"""DB models for production backlink outreach tracking."""
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index, Boolean
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class BacklinkCampaign(Base):
__tablename__ = "backlink_campaigns"
id = Column(String(64), primary_key=True)
user_id = Column(String(255), nullable=False, index=True)
workspace_id = Column(String(255), nullable=False, index=True)
name = Column(String(255), nullable=False)
status = Column(String(32), nullable=False, default="drafted", index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class BacklinkLead(Base):
__tablename__ = "backlink_leads"
id = Column(String(64), primary_key=True)
campaign_id = Column(String(64), ForeignKey("backlink_campaigns.id"), nullable=False, index=True)
domain = Column(String(255), nullable=False, index=True)
email = Column(String(255), nullable=True, index=True)
status = Column(String(32), nullable=False, default="drafted", index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class OutreachAttempt(Base):
__tablename__ = "backlink_outreach_attempts"
id = Column(String(64), primary_key=True)
lead_id = Column(String(64), ForeignKey("backlink_leads.id"), nullable=False, index=True)
campaign_id = Column(String(64), ForeignKey("backlink_campaigns.id"), nullable=False, index=True)
idempotency_key = Column(String(128), nullable=False, unique=True, index=True)
status = Column(String(32), nullable=False, default="queued", index=True)
decision_reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class OutreachReply(Base):
__tablename__ = "backlink_replies"
id = Column(String(64), primary_key=True)
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
received_at = Column(DateTime, default=datetime.utcnow, index=True)
classification = Column(String(32), nullable=False, default="replied")
body = Column(Text, nullable=True)
class FollowUpSchedule(Base):
__tablename__ = "backlink_followup_schedules"
id = Column(String(64), primary_key=True)
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
scheduled_for = Column(DateTime, nullable=False, index=True)
sent = Column(Boolean, default=False, index=True)
Index("idx_backlink_campaign_user_date", BacklinkCampaign.user_id, BacklinkCampaign.created_at)
Index("idx_backlink_attempt_campaign_date", OutreachAttempt.campaign_id, OutreachAttempt.created_at)

View File

@@ -0,0 +1,58 @@
"""Backlink outreach router."""
from fastapi import APIRouter, Query
from services.backlink_outreach_models import BacklinkDiscoveryResponse, BacklinkKeywordInput, PolicyValidationRequest, PolicyValidationResponse
from services.backlink_outreach_service import backlink_outreach_service
from services.backlink_outreach_storage import BacklinkOutreachStorageService
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/backlink-outreach", tags=["backlink-outreach"])
class BacklinkCampaignCreateRequest(BaseModel):
user_id: str = Field(..., min_length=1)
workspace_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=3)
@router.get("/modules")
async def get_backlink_module_registry():
return {"feature": "backlink_outreach", "modules": backlink_outreach_service.list_backlink_modules()}
@router.get("/query-templates")
async def get_backlink_query_templates(keyword: str = Query(..., min_length=1)):
return {"keyword": keyword, "queries": backlink_outreach_service.generate_guest_post_queries(keyword)}
@router.post("/discover", response_model=BacklinkDiscoveryResponse)
async def discover_backlink_opportunities(payload: BacklinkKeywordInput):
return backlink_outreach_service.discover_opportunities(payload.keyword, payload.max_results)
@router.post("/campaigns")
async def create_backlink_campaign(payload: BacklinkCampaignCreateRequest):
storage = BacklinkOutreachStorageService()
return storage.create_campaign(payload.user_id, payload.workspace_id, payload.name)
@router.get("/campaigns")
async def list_backlink_campaigns(user_id: str, workspace_id: str, limit: int = 50):
storage = BacklinkOutreachStorageService()
return {"campaigns": storage.list_campaigns(user_id, workspace_id, limit)}
@router.post("/policy-validate", response_model=PolicyValidationResponse)
async def validate_outreach_policy(payload: PolicyValidationRequest):
return backlink_outreach_service.validate_send_policy(payload)
@router.get("/reporting")
async def get_backlink_reporting_snapshot():
return backlink_outreach_service.get_reporting_snapshot()
@router.get("/migration-coverage")
async def get_backlink_migration_coverage():
return backlink_outreach_service.get_migration_coverage()

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from pydantic import BaseModel, Field, HttpUrl, EmailStr
from typing import Dict, List, Optional
class BacklinkKeywordInput(BaseModel):
keyword: str = Field(..., min_length=2, max_length=120)
max_results: int = Field(default=10, ge=1, le=50)
class OpportunityContactInfo(BaseModel):
email: Optional[EmailStr] = None
contact_page: Optional[HttpUrl] = None
class OpportunityRecord(BaseModel):
url: HttpUrl
title: str
snippet: str
metadata: Dict[str, str] = Field(default_factory=dict)
contact_info: OpportunityContactInfo = Field(default_factory=OpportunityContactInfo)
confidence_score: float = Field(..., ge=0.0, le=1.0)
class BacklinkDiscoveryResponse(BaseModel):
keyword: str
queries: List[str]
opportunities: List[OpportunityRecord]
class GeneratedEmailResponse(BaseModel):
subject: str
body: str
class OutreachStatusRecord(BaseModel):
opportunity_url: HttpUrl
status: str
notes: Optional[str] = None
class PolicyValidationRequest(BaseModel):
user_id: str = Field(..., min_length=1)
workspace_id: str = Field(..., min_length=1)
campaign_id: str = Field(..., min_length=1)
recipient_email: EmailStr
recipient_domain: str
recipient_region: str = Field(default="unknown")
legal_basis: str = Field(..., min_length=2)
approved_by_human: bool = False
unsubscribe_url: Optional[HttpUrl] = None
sender_identity: str = Field(..., min_length=3)
idempotency_key: str = Field(..., min_length=8)
class PolicyValidationResponse(BaseModel):
allowed: bool
reasons: List[str] = Field(default_factory=list)
final_status: str

View File

@@ -0,0 +1,222 @@
"""Canonical backlink outreach service entrypoint."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List
import re
import time
import requests
from bs4 import BeautifulSoup
from services.backlink_outreach_models import OpportunityContactInfo, OpportunityRecord, PolicyValidationRequest, PolicyValidationResponse
# Temporary in-memory control plane until DB wiring is complete
SUPPRESSION_LIST = set()
SENT_IDEMPOTENCY_KEYS = set()
AUDIT_LOGS: list[dict] = []
SEND_COUNTERS_BY_USER: dict[str, int] = {}
SEND_COUNTERS_BY_DOMAIN: dict[str, int] = {}
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
@dataclass
class SearchResult:
url: str
title: str
snippet: str
class BacklinkOutreachService:
def list_backlink_modules(self) -> List[Dict[str, Any]]:
return [
{"identifier": "backlink", "module_path": "backend/services/backlink_outreach_service.py", "purpose": "Canonical backlink service facade"},
{"identifier": "outreach", "module_path": "backend/routers/backlink_outreach.py", "purpose": "HTTP API entrypoint for backlink outreach"},
{"identifier": "guest_post", "module_path": "frontend/src/api/backlinkOutreachApi.ts", "purpose": "Frontend API integration for guest-post workflows"},
]
def generate_guest_post_queries(self, keyword: str) -> List[str]:
normalized = (keyword or "").strip()
if not normalized:
return []
return [
f"{normalized} + 'Guest Contributor'",
f"{normalized} + 'Add Guest Post'",
f"{normalized} + 'Guest Bloggers Wanted'",
f"{normalized} + 'Write for Us'",
f"{normalized} + 'Submit Guest Post'",
f"{normalized} + 'Become a Guest Blogger'",
f"{normalized} + 'guest post opportunities'",
f"{normalized} + 'Submit article'",
]
def search_for_urls(self, query: str, timeout_seconds: int = 12, retries: int = 2) -> List[SearchResult]:
encoded_query = requests.utils.quote(query)
url = f"https://duckduckgo.com/html/?q={encoded_query}"
headers = {"User-Agent": "Mozilla/5.0 ALwrityBacklinkBot/1.0"}
for attempt in range(retries + 1):
try:
response = requests.get(url, headers=headers, timeout=timeout_seconds)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
rows: List[SearchResult] = []
for result in soup.select("div.result")[:10]:
anchor = result.select_one("a.result__a")
snippet = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
if not anchor or not anchor.get("href"):
continue
rows.append(
SearchResult(
url=anchor.get("href"),
title=anchor.get_text(strip=True),
snippet=snippet.get_text(" ", strip=True) if snippet else "",
)
)
return rows
except Exception:
if attempt == retries:
return []
time.sleep(0.6 * (attempt + 1))
return []
def discover_opportunities(self, keyword: str, max_results: int = 10) -> Dict[str, Any]:
queries = self.generate_guest_post_queries(keyword)[:4]
dedup: Dict[str, SearchResult] = {}
for query in queries:
for result in self.search_for_urls(query):
normalized_url = self._normalize_url(result.url)
if not normalized_url or normalized_url in dedup:
continue
dedup[normalized_url] = result
if len(dedup) >= max_results:
break
if len(dedup) >= max_results:
break
time.sleep(0.4)
opportunities: List[OpportunityRecord] = []
for normalized_url, row in dedup.items():
contact = self._extract_contact_info(row.snippet)
score = self._score_confidence(row.title, row.snippet)
opportunities.append(
OpportunityRecord(
url=normalized_url,
title=row.title or "Untitled",
snippet=row.snippet,
metadata={"source": "duckduckgo_html", "query_keyword": keyword},
contact_info=contact,
confidence_score=score,
)
)
return {"keyword": keyword, "queries": queries, "opportunities": opportunities}
def _normalize_url(self, url: str) -> str:
u = (url or "").strip()
if not u:
return ""
if u.startswith("//"):
u = f"https:{u}"
if not re.match(r"^https?://", u):
return ""
return u.split("#")[0].rstrip("/")
def _extract_contact_info(self, text: str) -> OpportunityContactInfo:
if not text:
return OpportunityContactInfo()
email_match = re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", text)
return OpportunityContactInfo(email=email_match.group(0) if email_match else None)
def _score_confidence(self, title: str, snippet: str) -> float:
hay = f"{title} {snippet}".lower()
cues = ["write for us", "guest post", "submit", "contributor", "guest blogger"]
hits = sum(1 for cue in cues if cue in hay)
return min(1.0, 0.35 + (0.13 * hits))
def validate_send_policy(self, payload: PolicyValidationRequest) -> PolicyValidationResponse:
reasons: List[str] = []
if payload.workspace_id.startswith("new-") and not payload.approved_by_human:
reasons.append("human_review_required_for_new_workspace")
if payload.legal_basis.lower() not in {"legitimate_interest", "consent", "contract"}:
reasons.append("invalid_legal_basis")
if payload.recipient_region.lower() in {"eu", "eea"} and payload.legal_basis.lower() != "consent":
reasons.append("region_requires_explicit_consent")
if not payload.unsubscribe_url:
reasons.append("unsubscribe_url_required")
if len(payload.sender_identity.strip()) < 3:
reasons.append("sender_identity_required")
recipient_key = f"{payload.recipient_email.lower()}::{payload.recipient_domain.lower()}"
if recipient_key in SUPPRESSION_LIST:
reasons.append("recipient_suppressed")
if payload.idempotency_key in SENT_IDEMPOTENCY_KEYS:
reasons.append("duplicate_idempotency_key")
user_count = SEND_COUNTERS_BY_USER.get(payload.user_id, 0)
domain_count = SEND_COUNTERS_BY_DOMAIN.get(payload.recipient_domain.lower(), 0)
if user_count >= DEFAULT_USER_DAILY_CAP:
reasons.append("user_daily_cap_exceeded")
if domain_count >= DEFAULT_DOMAIN_DAILY_CAP:
reasons.append("domain_daily_cap_exceeded")
allowed = len(reasons) == 0
final_status = "approved" if allowed else "blocked"
AUDIT_LOGS.append({
"event": "policy_check",
"user_id": payload.user_id,
"campaign_id": payload.campaign_id,
"recipient": str(payload.recipient_email),
"allowed": allowed,
"reasons": reasons,
"override": payload.approved_by_human,
})
if allowed:
SENT_IDEMPOTENCY_KEYS.add(payload.idempotency_key)
SEND_COUNTERS_BY_USER[payload.user_id] = user_count + 1
SEND_COUNTERS_BY_DOMAIN[payload.recipient_domain.lower()] = domain_count + 1
return PolicyValidationResponse(allowed=allowed, reasons=reasons, final_status=final_status)
def get_reporting_snapshot(self) -> Dict[str, Any]:
total_decisions = len(AUDIT_LOGS)
approved = sum(1 for row in AUDIT_LOGS if row.get("allowed"))
return {
"send_volume": approved,
"decision_events": total_decisions,
"response_rate": 0.0,
"placement_conversion": 0.0,
}
def get_migration_coverage(self) -> Dict[str, Any]:
implemented = [
"discoverable backend router + service",
"frontend API/store/UI integration point",
"legacy guest-post search query generation templates",
"provider-backed URL discovery + normalization + deduplication",
"typed opportunity records and confidence score",
]
planned = [
"deep webpage scraping + contact-page extraction",
"email sending automation + response tracking",
"follow-up orchestration and campaign analytics",
]
return {
"legacy_reference": "ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py",
"implemented_count": len(implemented),
"planned_count": len(planned),
"implemented": implemented,
"planned": planned,
}
backlink_outreach_service = BacklinkOutreachService()

View File

@@ -0,0 +1,58 @@
"""Backlink outreach persistence service (campaign-creator style)."""
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from typing import List
from services.database import get_session_for_user
from models.backlink_outreach_models import Base, BacklinkCampaign
class BacklinkOutreachStorageService:
def _ensure_tables(self, user_id: str) -> None:
db = get_session_for_user(user_id)
if not db:
return
try:
Base.metadata.create_all(bind=db.get_bind(), checkfirst=True)
finally:
db.close()
def create_campaign(self, user_id: str, workspace_id: str, name: str) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
campaign = BacklinkCampaign(
id=f"bl_{uuid4().hex[:16]}",
user_id=user_id,
workspace_id=workspace_id,
name=name,
status="drafted",
created_at=datetime.utcnow(),
)
db.add(campaign)
db.commit()
return {"campaign_id": campaign.id, "name": campaign.name, "status": campaign.status}
finally:
db.close()
def list_campaigns(self, user_id: str, workspace_id: str, limit: int = 50) -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(BacklinkCampaign)
.filter(BacklinkCampaign.user_id == user_id, BacklinkCampaign.workspace_id == workspace_id)
.order_by(BacklinkCampaign.created_at.desc())
.limit(limit)
.all()
)
return [{"campaign_id": r.id, "name": r.name, "status": r.status, "created_at": r.created_at.isoformat()} for r in rows]
finally:
db.close()

View File

@@ -0,0 +1,106 @@
import { apiClient } from './client';
export interface BacklinkModuleRecord {
identifier: 'backlink' | 'outreach' | 'guest_post' | string;
module_path: string;
purpose: string;
}
export interface BacklinkModuleRegistryResponse {
feature: string;
modules: BacklinkModuleRecord[];
}
export interface BacklinkCoverageResponse {
legacy_reference: string;
implemented_count: number;
planned_count: number;
implemented: string[];
planned: string[];
}
export interface BacklinkQueryTemplatesResponse {
keyword: string;
queries: string[];
}
export interface BacklinkDiscoveryRequest {
keyword: string;
max_results?: number;
}
export interface BacklinkOpportunity {
url: string;
title: string;
snippet: string;
confidence_score: number;
}
export interface BacklinkPolicyValidationRequest {
user_id: string;
workspace_id: string;
campaign_id: string;
recipient_email: string;
recipient_domain: string;
recipient_region: string;
legal_basis: string;
approved_by_human: boolean;
unsubscribe_url?: string;
sender_identity: string;
idempotency_key: string;
}
export interface BacklinkPolicyValidationResponse {
allowed: boolean;
reasons: string[];
final_status: string;
}
export interface BacklinkReportingSnapshot {
send_volume: number;
decision_events: number;
response_rate: number;
placement_conversion: number;
}
export interface BacklinkDiscoveryResponse {
keyword: string;
queries: string[];
opportunities: BacklinkOpportunity[];
}
export interface BacklinkCampaignRecord {
campaign_id: string;
name: string;
status: string;
created_at?: string;
}
export interface BacklinkCampaignCreateRequest {
user_id: string;
workspace_id: string;
name: string;
}
export interface BacklinkCampaignCreateResponse {
campaign_id: string;
name: string;
status: string;
}
export interface BacklinkCampaignListResponse {
campaigns: BacklinkCampaignRecord[];
}
export const fetchBacklinkModuleRegistry = async (): Promise<BacklinkModuleRegistryResponse> => (await apiClient.get('/api/backlink-outreach/modules')).data;
export const fetchBacklinkMigrationCoverage = async (): Promise<BacklinkCoverageResponse> => (await apiClient.get('/api/backlink-outreach/migration-coverage')).data;
export const fetchBacklinkQueryTemplates = async (keyword: string): Promise<BacklinkQueryTemplatesResponse> => (await apiClient.get('/api/backlink-outreach/query-templates', { params: { keyword } })).data;
export const discoverBacklinkOpportunities = async (payload: BacklinkDiscoveryRequest): Promise<BacklinkDiscoveryResponse> => (await apiClient.post('/api/backlink-outreach/discover', payload)).data;
export const validateBacklinkPolicy = async (payload: BacklinkPolicyValidationRequest): Promise<BacklinkPolicyValidationResponse> => (await apiClient.post('/api/backlink-outreach/policy-validate', payload)).data;
export const fetchBacklinkReportingSnapshot = async (): Promise<BacklinkReportingSnapshot> => (await apiClient.get('/api/backlink-outreach/reporting')).data;
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;

View File

@@ -0,0 +1,96 @@
import React, { useCallback, useEffect, useState } from 'react';
import { fetchBacklinkQueryTemplates } from '../../api/backlinkOutreachApi';
import { useBacklinkOutreachStore } from '../../stores/backlinkOutreachStore';
const BacklinkOutreachModuleList: React.FC = () => {
const { modules, coverage, campaigns, isLoading, error, refreshBacklinkRegistry, fetchCampaigns, createCampaign } = useBacklinkOutreachStore();
const [queryPreview, setQueryPreview] = useState<string[]>([]);
const [newCampaignName, setNewCampaignName] = useState('');
useEffect(() => {
refreshBacklinkRegistry();
}, [refreshBacklinkRegistry]);
useEffect(() => {
const loadPreview = async () => {
const response = await fetchBacklinkQueryTemplates('seo');
setQueryPreview(response.queries.slice(0, 3));
};
loadPreview().catch(() => setQueryPreview([]));
}, []);
useEffect(() => {
fetchCampaigns('default', 'default').catch(() => {});
}, [fetchCampaigns]);
const handleCreateCampaign = useCallback(async () => {
if (!newCampaignName.trim()) return;
await createCampaign('default', 'default', newCampaignName.trim());
setNewCampaignName('');
}, [newCampaignName, createCampaign]);
return (
<section>
<h3>Backlink Outreach Modules</h3>
{isLoading && <p>Loading backlink module registry</p>}
{error && <p>{error}</p>}
{!isLoading && !error && (
<>
<ul>
{modules.map((module) => (
<li key={`${module.identifier}:${module.module_path}`}>
<strong>{module.identifier}</strong>: {module.module_path}
</li>
))}
</ul>
{coverage && (
<>
<p>
Legacy parity: {coverage.implemented_count} implemented / {coverage.planned_count} planned
</p>
<p>Legacy reference: {coverage.legacy_reference}</p>
</>
)}
{queryPreview.length > 0 && (
<>
<h4>Legacy query template preview (keyword: seo)</h4>
<ul>
{queryPreview.map((query) => (
<li key={query}>{query}</li>
))}
</ul>
</>
)}
<h4>Campaigns</h4>
{campaigns.length === 0 ? (
<p>No campaigns yet. Create one below.</p>
) : (
<ul>
{campaigns.map((c) => (
<li key={c.campaign_id}>
<strong>{c.name}</strong> ({c.status})
{c.created_at && <span> {new Date(c.created_at).toLocaleDateString()}</span>}
</li>
))}
</ul>
)}
<div>
<input
type="text"
value={newCampaignName}
onChange={(e) => setNewCampaignName(e.target.value)}
placeholder="Campaign name"
/>
<button onClick={handleCreateCampaign} disabled={!newCampaignName.trim()}>
Create Campaign
</button>
</div>
</>
)}
</section>
);
};
export default BacklinkOutreachModuleList;

View File

@@ -0,0 +1,74 @@
import { create } from 'zustand';
import {
BacklinkCampaignRecord,
BacklinkCoverageResponse,
BacklinkModuleRecord,
createBacklinkCampaign,
fetchBacklinkMigrationCoverage,
fetchBacklinkModuleRegistry,
listBacklinkCampaigns,
} from '../api/backlinkOutreachApi';
interface BacklinkOutreachStore {
modules: BacklinkModuleRecord[];
coverage: BacklinkCoverageResponse | null;
campaigns: BacklinkCampaignRecord[];
isLoading: boolean;
error: string | null;
refreshBacklinkRegistry: () => Promise<void>;
fetchCampaigns: (userId: string, workspaceId: string) => Promise<void>;
createCampaign: (userId: string, workspaceId: string, name: string) => Promise<string | null>;
}
export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => ({
modules: [],
coverage: null,
campaigns: [],
isLoading: false,
error: null,
refreshBacklinkRegistry: async () => {
set({ isLoading: true, error: null });
try {
const [registryPayload, coveragePayload] = await Promise.all([
fetchBacklinkModuleRegistry(),
fetchBacklinkMigrationCoverage(),
]);
set({ modules: registryPayload.modules, coverage: coveragePayload, isLoading: false });
} catch (error: any) {
set({
isLoading: false,
error: error?.message ?? 'Failed to load backlink module registry',
});
}
},
fetchCampaigns: async (userId: string, workspaceId: string) => {
set({ isLoading: true, error: null });
try {
const response = await listBacklinkCampaigns(userId, workspaceId);
set({ campaigns: response.campaigns, isLoading: false });
} catch (error: any) {
set({
isLoading: false,
error: error?.message ?? 'Failed to load campaigns',
});
}
},
createCampaign: async (userId: string, workspaceId: string, name: string) => {
set({ isLoading: true, error: null });
try {
const result = await createBacklinkCampaign({ user_id: userId, workspace_id: workspaceId, name });
set((state) => ({
campaigns: [...state.campaigns, { campaign_id: result.campaign_id, name: result.name, status: result.status }],
isLoading: false,
}));
return result.campaign_id;
} catch (error: any) {
set({
isLoading: false,
error: error?.message ?? 'Failed to create campaign',
});
return null;
}
},
}));

View File

@@ -9,3 +9,4 @@ export type { DashboardStore } from './dashboardStore';
export type { SEODashboardStore } from './seoDashboardStore';
export type { SharedDashboardState } from './sharedDashboardStore';
export type { SemanticDashboardStore } from './semanticDashboardStore';
export { useBacklinkOutreachStore } from './backlinkOutreachStore';