Files
ALwrity/backend/services/backlink_outreach_storage.py
ajaysi 090d69761f 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
2026-05-23 17:07:33 +05:30

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,
}