diff --git a/backend/README.md b/backend/README.md index 2149a9ae..196cf00f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -350,4 +350,28 @@ If you encounter issues: --- -**Happy coding! 🎉** \ No newline at end of file +**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=` +- `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. diff --git a/backend/app.py b/backend/app.py index 422deb30..779fda35 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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, diff --git a/backend/docs/backlink_migration_audit.md b/backend/docs/backlink_migration_audit.md new file mode 100644 index 00000000..e43da790 --- /dev/null +++ b/backend/docs/backlink_migration_audit.md @@ -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=` +- 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. diff --git a/backend/main.py b/backend/main.py index 61536672..0c2dfb7b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/models/backlink_outreach_models.py b/backend/models/backlink_outreach_models.py new file mode 100644 index 00000000..e7f841c1 --- /dev/null +++ b/backend/models/backlink_outreach_models.py @@ -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) diff --git a/backend/routers/backlink_outreach.py b/backend/routers/backlink_outreach.py new file mode 100644 index 00000000..2da056af --- /dev/null +++ b/backend/routers/backlink_outreach.py @@ -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() diff --git a/backend/services/backlink_outreach_models.py b/backend/services/backlink_outreach_models.py new file mode 100644 index 00000000..e148b553 --- /dev/null +++ b/backend/services/backlink_outreach_models.py @@ -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 diff --git a/backend/services/backlink_outreach_service.py b/backend/services/backlink_outreach_service.py new file mode 100644 index 00000000..86bcfdf0 --- /dev/null +++ b/backend/services/backlink_outreach_service.py @@ -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() diff --git a/backend/services/backlink_outreach_storage.py b/backend/services/backlink_outreach_storage.py new file mode 100644 index 00000000..dd9a76f9 --- /dev/null +++ b/backend/services/backlink_outreach_storage.py @@ -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() diff --git a/frontend/src/api/backlinkOutreachApi.ts b/frontend/src/api/backlinkOutreachApi.ts new file mode 100644 index 00000000..b9fddf93 --- /dev/null +++ b/frontend/src/api/backlinkOutreachApi.ts @@ -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 => (await apiClient.get('/api/backlink-outreach/modules')).data; +export const fetchBacklinkMigrationCoverage = async (): Promise => (await apiClient.get('/api/backlink-outreach/migration-coverage')).data; +export const fetchBacklinkQueryTemplates = async (keyword: string): Promise => (await apiClient.get('/api/backlink-outreach/query-templates', { params: { keyword } })).data; +export const discoverBacklinkOpportunities = async (payload: BacklinkDiscoveryRequest): Promise => (await apiClient.post('/api/backlink-outreach/discover', payload)).data; + +export const validateBacklinkPolicy = async (payload: BacklinkPolicyValidationRequest): Promise => (await apiClient.post('/api/backlink-outreach/policy-validate', payload)).data; +export const fetchBacklinkReportingSnapshot = async (): Promise => (await apiClient.get('/api/backlink-outreach/reporting')).data; + +export const createBacklinkCampaign = async (payload: BacklinkCampaignCreateRequest): Promise => (await apiClient.post('/api/backlink-outreach/campaigns', payload)).data; +export const listBacklinkCampaigns = async (user_id: string, workspace_id: string): Promise => (await apiClient.get('/api/backlink-outreach/campaigns', { params: { user_id, workspace_id } })).data; diff --git a/frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx b/frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx new file mode 100644 index 00000000..4fb49cfe --- /dev/null +++ b/frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx @@ -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([]); + 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 ( +
+

Backlink Outreach Modules

+ {isLoading &&

Loading backlink module registry…

} + {error &&

{error}

} + {!isLoading && !error && ( + <> +
    + {modules.map((module) => ( +
  • + {module.identifier}: {module.module_path} +
  • + ))} +
+ {coverage && ( + <> +

+ Legacy parity: {coverage.implemented_count} implemented / {coverage.planned_count} planned +

+

Legacy reference: {coverage.legacy_reference}

+ + )} + {queryPreview.length > 0 && ( + <> +

Legacy query template preview (keyword: seo)

+
    + {queryPreview.map((query) => ( +
  • {query}
  • + ))} +
+ + )} + +

Campaigns

+ {campaigns.length === 0 ? ( +

No campaigns yet. Create one below.

+ ) : ( +
    + {campaigns.map((c) => ( +
  • + {c.name} ({c.status}) + {c.created_at && — {new Date(c.created_at).toLocaleDateString()}} +
  • + ))} +
+ )} +
+ setNewCampaignName(e.target.value)} + placeholder="Campaign name" + /> + +
+ + )} +
+ ); +}; + +export default BacklinkOutreachModuleList; diff --git a/frontend/src/stores/backlinkOutreachStore.ts b/frontend/src/stores/backlinkOutreachStore.ts new file mode 100644 index 00000000..b9a01fbc --- /dev/null +++ b/frontend/src/stores/backlinkOutreachStore.ts @@ -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; + fetchCampaigns: (userId: string, workspaceId: string) => Promise; + createCampaign: (userId: string, workspaceId: string, name: string) => Promise; +} + +export const useBacklinkOutreachStore = create((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; + } + }, +})); diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 3948db6b..54825b13 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -8,4 +8,5 @@ export { useSemanticDashboardStore } from './semanticDashboardStore'; export type { DashboardStore } from './dashboardStore'; export type { SEODashboardStore } from './seoDashboardStore'; export type { SharedDashboardState } from './sharedDashboardStore'; -export type { SemanticDashboardStore } from './semanticDashboardStore'; \ No newline at end of file +export type { SemanticDashboardStore } from './semanticDashboardStore'; +export { useBacklinkOutreachStore } from './backlinkOutreachStore';