- 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
232 lines
8.2 KiB
Python
232 lines
8.2 KiB
Python
"""Backlink outreach persistence service (campaign-creator style)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
from typing import List, Optional
|
|
from sqlalchemy import text as sql_text
|
|
|
|
from services.database import get_session_for_user
|
|
from models.backlink_outreach_models import Base, BacklinkCampaign, BacklinkLead
|
|
|
|
|
|
class BacklinkOutreachStorageService:
|
|
_NEW_LEAD_COLUMNS = [
|
|
"url", "page_title", "snippet", "confidence_score", "discovery_source", "notes"
|
|
]
|
|
|
|
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)
|
|
self._migrate_lead_columns(db)
|
|
finally:
|
|
db.close()
|
|
|
|
def _migrate_lead_columns(self, db) -> None:
|
|
"""Add new columns to backlink_leads if they don't exist (dev migration)."""
|
|
try:
|
|
for col in self._NEW_LEAD_COLUMNS:
|
|
db.execute(sql_text(
|
|
f"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS {col} TEXT"
|
|
))
|
|
# confidence_score is Float, add separately
|
|
db.execute(sql_text(
|
|
"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS confidence_score FLOAT DEFAULT 0.0"
|
|
))
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
|
|
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()
|
|
|
|
def get_campaign(self, campaign_id: str, user_id: str) -> Optional[dict]:
|
|
self._ensure_tables(user_id)
|
|
db = get_session_for_user(user_id)
|
|
if not db:
|
|
return None
|
|
try:
|
|
campaign = (
|
|
db.query(BacklinkCampaign)
|
|
.filter(BacklinkCampaign.id == campaign_id, BacklinkCampaign.user_id == user_id)
|
|
.first()
|
|
)
|
|
if not campaign:
|
|
return None
|
|
lead_count = db.query(BacklinkLead).filter(BacklinkLead.campaign_id == campaign_id).count()
|
|
leads = (
|
|
db.query(BacklinkLead)
|
|
.filter(BacklinkLead.campaign_id == campaign_id)
|
|
.order_by(BacklinkLead.created_at.desc())
|
|
.limit(50)
|
|
.all()
|
|
)
|
|
return {
|
|
"campaign_id": campaign.id,
|
|
"name": campaign.name,
|
|
"status": campaign.status,
|
|
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
|
"lead_count": lead_count,
|
|
"leads": [self._lead_to_dict(l) for l in leads],
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
# -- Lead CRUD --
|
|
|
|
def add_lead(
|
|
self,
|
|
campaign_id: str,
|
|
user_id: str,
|
|
url: str,
|
|
domain: str,
|
|
page_title: str = "",
|
|
snippet: str = "",
|
|
email: Optional[str] = None,
|
|
confidence_score: float = 0.0,
|
|
discovery_source: str = "duckduckgo",
|
|
notes: Optional[str] = None,
|
|
) -> dict:
|
|
self._ensure_tables(user_id)
|
|
db = get_session_for_user(user_id)
|
|
if not db:
|
|
raise RuntimeError("Database session unavailable")
|
|
try:
|
|
lead = BacklinkLead(
|
|
id=f"bl_{uuid4().hex[:16]}",
|
|
campaign_id=campaign_id,
|
|
url=url,
|
|
domain=domain,
|
|
page_title=page_title,
|
|
snippet=snippet,
|
|
email=email,
|
|
confidence_score=confidence_score,
|
|
discovery_source=discovery_source,
|
|
status="discovered",
|
|
notes=notes,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(lead)
|
|
db.commit()
|
|
return self._lead_to_dict(lead)
|
|
finally:
|
|
db.close()
|
|
|
|
def bulk_add_leads(self, campaign_id: str, user_id: str, leads_data: List[dict]) -> List[dict]:
|
|
self._ensure_tables(user_id)
|
|
db = get_session_for_user(user_id)
|
|
if not db:
|
|
raise RuntimeError("Database session unavailable")
|
|
try:
|
|
added = []
|
|
for data in leads_data:
|
|
lead = BacklinkLead(
|
|
id=f"bl_{uuid4().hex[:16]}",
|
|
campaign_id=campaign_id,
|
|
url=data.get("url", ""),
|
|
domain=data.get("domain", ""),
|
|
page_title=data.get("page_title", ""),
|
|
snippet=data.get("snippet", ""),
|
|
email=data.get("email"),
|
|
confidence_score=data.get("confidence_score", 0.0),
|
|
discovery_source=data.get("discovery_source", "duckduckgo"),
|
|
status="discovered",
|
|
notes=data.get("notes"),
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(lead)
|
|
added.append(lead)
|
|
db.commit()
|
|
return [self._lead_to_dict(l) for l in added]
|
|
finally:
|
|
db.close()
|
|
|
|
def list_leads(
|
|
self, campaign_id: str, user_id: str, status: Optional[str] = None, limit: int = 50
|
|
) -> List[dict]:
|
|
self._ensure_tables(user_id)
|
|
db = get_session_for_user(user_id)
|
|
if not db:
|
|
return []
|
|
try:
|
|
q = db.query(BacklinkLead).filter(BacklinkLead.campaign_id == campaign_id)
|
|
if status:
|
|
q = q.filter(BacklinkLead.status == status)
|
|
rows = q.order_by(BacklinkLead.created_at.desc()).limit(limit).all()
|
|
return [self._lead_to_dict(r) for r in rows]
|
|
finally:
|
|
db.close()
|
|
|
|
def update_lead_status(
|
|
self, lead_id: str, user_id: str, status: str, notes: Optional[str] = None
|
|
) -> Optional[dict]:
|
|
db = get_session_for_user(user_id)
|
|
if not db:
|
|
return None
|
|
try:
|
|
lead = db.query(BacklinkLead).filter(BacklinkLead.id == lead_id).first()
|
|
if not lead:
|
|
return None
|
|
lead.status = status
|
|
if notes is not None:
|
|
lead.notes = notes
|
|
db.commit()
|
|
return self._lead_to_dict(lead)
|
|
finally:
|
|
db.close()
|
|
|
|
@staticmethod
|
|
def _lead_to_dict(lead) -> dict:
|
|
return {
|
|
"lead_id": lead.id,
|
|
"campaign_id": lead.campaign_id,
|
|
"url": lead.url,
|
|
"domain": lead.domain,
|
|
"page_title": lead.page_title or "",
|
|
"snippet": lead.snippet or "",
|
|
"email": lead.email,
|
|
"confidence_score": lead.confidence_score or 0.0,
|
|
"discovery_source": lead.discovery_source or "duckduckgo",
|
|
"status": lead.status,
|
|
"notes": lead.notes,
|
|
"created_at": lead.created_at.isoformat() if lead.created_at else None,
|
|
}
|