fix: resolve remaining 5 QA audit findings (#3, #8, #10, #11, #12)

#3 — Duplicate prospect handling: add_lead now checks (campaign_id, url)
     before insert; bulk_add_leads skips existing URLs.
#8 — Atomic rate limiting: try_increment_* methods atomically check cap
     and increment in a single session; router uses these before send.
#10 — Reply matching via Message-ID: sender generates Message-ID header,
     stored on OutreachAttempt; reply monitor parses In-Reply-To/References;
     poll_replies matches by message_id first, falls back to from_email.
#11 — Save-to-campaign uses existing store results instead of
      re-running expensive deepDiscover.
#12 — Lead status Literal type: Pydantic models enforce valid status
      values; backend validates via LEAD_VALID_STATUSES frozenset;
      frontend API typed as LeadStatus union.
This commit is contained in:
ajaysi
2026-06-03 20:06:11 +05:30
parent 259194c289
commit 8699ffc27d
9 changed files with 226 additions and 103 deletions

View File

@@ -46,6 +46,7 @@ class OutreachAttempt(Base):
decision_reason = Column(Text, nullable=True) decision_reason = Column(Text, nullable=True)
sent_at = Column(DateTime, nullable=True) sent_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True) created_at = Column(DateTime, default=datetime.utcnow, index=True)
message_id = Column(String(255), nullable=True, index=True)
class OutreachReply(Base): class OutreachReply(Base):

View File

@@ -368,26 +368,40 @@ async def send_outreach(
lead_email = (lead.get("email") or "") if lead else "" lead_email = (lead.get("email") or "") if lead else ""
if result.status == "approved" and result.policy_allowed and not result.duplicate and lead_email: if result.status == "approved" and result.policy_allowed and not result.duplicate and lead_email:
send_result = await backlink_outreach_sender.send_email( domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
to_email=lead_email,
subject=subject, user_within_cap, _ = storage.try_increment_user_send_counter(user_id)
body=body, domain_within_cap, _ = storage.try_increment_domain_send_counter(domain, user_id=user_id)
from_email=payload.sender_email, if not (user_within_cap and domain_within_cap):
) reasons = []
if send_result.success: if not user_within_cap:
storage.update_attempt_status(result.attempt_id, "sent", user_id=user_id) reasons.append("user_daily_cap_exceeded")
result.status = "sent" if not domain_within_cap:
result.effective_sender_email = send_result.effective_sender_email or result.effective_sender_email reasons.append("domain_daily_cap_exceeded")
storage.mark_idempotency(payload.idempotency_key, user_id) reason_str = f"rate_limit_hit; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.increment_user_send_counter(user_id) storage.update_attempt_status(result.attempt_id, "blocked", decision_reason=reason_str, user_id=user_id)
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown" result.status = "blocked"
storage.increment_domain_send_counter(domain, user_id=user_id) result.policy_reasons = reasons
else: else:
reason = f"smtp_send_failed; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}" send_result = await backlink_outreach_sender.send_email(
storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id) to_email=lead_email,
result.status = "failed" subject=subject,
result.policy_reasons = ["smtp_send_failed"] body=body,
result.retry_policy = backlink_outreach_service.SMTP_RETRY_POLICY from_email=payload.sender_email,
)
if send_result.success:
storage.update_attempt_status(result.attempt_id, "sent", user_id=user_id)
result.status = "sent"
result.effective_sender_email = send_result.effective_sender_email or result.effective_sender_email
if send_result.message_id:
storage.update_attempt_message_id(result.attempt_id, send_result.message_id, user_id=user_id)
storage.mark_idempotency(payload.idempotency_key, user_id)
else:
reason = f"smtp_send_failed; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id)
result.status = "failed"
result.policy_reasons = ["smtp_send_failed"]
result.retry_policy = backlink_outreach_service.SMTP_RETRY_POLICY
elif result.status == "approved" and result.policy_allowed and not result.duplicate and not lead_email: elif result.status == "approved" and result.policy_allowed and not result.duplicate and not lead_email:
reason = f"lead_has_no_email; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}" reason = f"lead_has_no_email; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id) storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id)
@@ -448,7 +462,18 @@ async def poll_replies(
if storage.reply_exists(from_email, subject, user_id=user_id): if storage.reply_exists(from_email, subject, user_id=user_id):
skipped += 1 skipped += 1
continue continue
attempt_id = storage.find_attempt_by_from_email(from_email, user_id=user_id) or ""
attempt_id = ""
in_reply_to = raw.get("in_reply_to", "")
references = raw.get("references", "")
if in_reply_to:
attempt_id = storage.find_attempt_by_message_id(in_reply_to, user_id=user_id) or ""
if not attempt_id and references:
mid = references.split()[-1]
attempt_id = storage.find_attempt_by_message_id(mid, user_id=user_id) or ""
if not attempt_id:
attempt_id = storage.find_attempt_by_from_email(from_email, user_id=user_id) or ""
reply = storage.add_reply( reply = storage.add_reply(
attempt_id=attempt_id, attempt_id=attempt_id,
from_email=from_email, from_email=from_email,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, HttpUrl from pydantic import BaseModel, Field, HttpUrl
from typing import Dict, List, Optional from typing import Dict, List, Optional
from typing_extensions import Literal
class BacklinkKeywordInput(BaseModel): class BacklinkKeywordInput(BaseModel):
@@ -93,7 +94,7 @@ class LeadListResponse(BaseModel):
class LeadStatusUpdateRequest(BaseModel): class LeadStatusUpdateRequest(BaseModel):
status: str = Field(..., min_length=1) status: Literal["discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"]
notes: Optional[str] = None notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1) campaign_id: Optional[str] = Field(default=None, min_length=1)
@@ -329,7 +330,7 @@ class ConversionFunnelResponse(BaseModel):
class BulkStatusUpdateRequest(BaseModel): class BulkStatusUpdateRequest(BaseModel):
lead_ids: List[str] = Field(..., min_length=1) lead_ids: List[str] = Field(..., min_length=1)
status: str = Field(..., min_length=1) status: Literal["discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"]
notes: Optional[str] = None notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1) campaign_id: Optional[str] = Field(default=None, min_length=1)

View File

@@ -104,6 +104,8 @@ class BacklinkOutreachReplyMonitor:
from_email = parsed_msg.get("From", "") from_email = parsed_msg.get("From", "")
subject = parsed_msg.get("Subject", "") subject = parsed_msg.get("Subject", "")
received_at = parsed_msg.get("Date", "") received_at = parsed_msg.get("Date", "")
in_reply_to = parsed_msg.get("In-Reply-To", "")
references = parsed_msg.get("References", "")
# Extract body # Extract body
body = "" body = ""
@@ -137,6 +139,8 @@ class BacklinkOutreachReplyMonitor:
"body": body[:5000], "body": body[:5000],
"classification": classification, "classification": classification,
"received_at": received_at_iso, "received_at": received_at_iso,
"in_reply_to": in_reply_to,
"references": references,
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to parse reply: {e}") logger.error(f"Failed to parse reply: {e}")

View File

@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from typing import List, Optional, Set from typing import List, Optional, Set
from uuid import uuid4
from loguru import logger from loguru import logger
@@ -35,6 +36,7 @@ class SenderAuthorizationResult:
class SendEmailResult: class SendEmailResult:
success: bool success: bool
effective_sender_email: str = "" effective_sender_email: str = ""
message_id: str = ""
failure_reasons: List[str] = field(default_factory=list) failure_reasons: List[str] = field(default_factory=list)
@@ -116,10 +118,12 @@ class BacklinkOutreachSender:
sender = sender_validation.effective_sender_email sender = sender_validation.effective_sender_email
msg_id = f"<{uuid4().hex}@{sender.split('@')[-1] if '@' in sender else 'outreach.local'}>"
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["From"] = sender msg["From"] = sender
msg["To"] = to_email msg["To"] = to_email
msg["Subject"] = subject msg["Subject"] = subject
msg["Message-ID"] = msg_id
msg.attach(MIMEText(body, "plain")) msg.attach(MIMEText(body, "plain"))
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -149,6 +153,7 @@ class BacklinkOutreachSender:
return SendEmailResult( return SendEmailResult(
success=success, success=success,
effective_sender_email=sender, effective_sender_email=sender,
message_id=msg_id if success else "",
failure_reasons=[] if success else ["smtp_send_failed"], failure_reasons=[] if success else ["smtp_send_failed"],
) )

View File

@@ -23,9 +23,6 @@ from services.backlink_outreach_models import (
) )
from services.backlink_outreach_storage import BacklinkOutreachStorageService from services.backlink_outreach_storage import BacklinkOutreachStorageService
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
@dataclass @dataclass
class SearchResult: class SearchResult:
url: str url: str
@@ -235,13 +232,6 @@ class BacklinkOutreachService:
if storage.check_idempotency(payload.idempotency_key, user_id=payload.user_id): if storage.check_idempotency(payload.idempotency_key, user_id=payload.user_id):
reasons.append("duplicate_idempotency_key") reasons.append("duplicate_idempotency_key")
user_count = storage.get_user_send_count(payload.user_id)
domain_count = storage.get_domain_send_count(payload.recipient_domain, user_id=payload.user_id)
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 allowed = len(reasons) == 0
final_status = "approved" if allowed else "blocked" final_status = "approved" if allowed else "blocked"

View File

@@ -8,6 +8,8 @@ from typing import List, Optional
from sqlalchemy import text as sql_text, func as sa_func from sqlalchemy import text as sql_text, func as sa_func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
LEAD_VALID_STATUSES = frozenset({"discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"})
from services.database import get_session_for_user from services.database import get_session_for_user
from models.backlink_outreach_models import ( from models.backlink_outreach_models import (
Base, BacklinkCampaign, BacklinkLead, Base, BacklinkCampaign, BacklinkLead,
@@ -21,6 +23,10 @@ class BacklinkCampaignNotFoundError(RuntimeError):
"""Raised when a backlink campaign is missing or not owned by the user.""" """Raised when a backlink campaign is missing or not owned by the user."""
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
class BacklinkOutreachStorageService: class BacklinkOutreachStorageService:
_NEW_LEAD_COLUMNS = [ _NEW_LEAD_COLUMNS = [
"url", "page_title", "snippet", "confidence_score", "discovery_source", "notes" "url", "page_title", "snippet", "confidence_score", "discovery_source", "notes"
@@ -154,6 +160,14 @@ class BacklinkOutreachStorageService:
if not self._campaign_belongs_to_user(db, campaign_id, user_id): if not self._campaign_belongs_to_user(db, campaign_id, user_id):
raise BacklinkCampaignNotFoundError("Campaign not found") raise BacklinkCampaignNotFoundError("Campaign not found")
existing = (
db.query(BacklinkLead)
.filter(BacklinkLead.campaign_id == campaign_id, BacklinkLead.url == url)
.first()
)
if existing:
return self._lead_to_dict(existing)
lead = BacklinkLead( lead = BacklinkLead(
id=f"bl_{uuid4().hex[:16]}", id=f"bl_{uuid4().hex[:16]}",
campaign_id=campaign_id, campaign_id=campaign_id,
@@ -183,12 +197,22 @@ class BacklinkOutreachStorageService:
if not self._campaign_belongs_to_user(db, campaign_id, user_id): if not self._campaign_belongs_to_user(db, campaign_id, user_id):
raise BacklinkCampaignNotFoundError("Campaign not found") raise BacklinkCampaignNotFoundError("Campaign not found")
existing_urls = {
row[0]
for row in db.query(BacklinkLead.url)
.filter(BacklinkLead.campaign_id == campaign_id)
.all()
}
added = [] added = []
for data in leads_data: for data in leads_data:
url = data.get("url", "")
if url in existing_urls:
continue
lead = BacklinkLead( lead = BacklinkLead(
id=f"bl_{uuid4().hex[:16]}", id=f"bl_{uuid4().hex[:16]}",
campaign_id=campaign_id, campaign_id=campaign_id,
url=data.get("url", ""), url=url,
domain=data.get("domain", ""), domain=data.get("domain", ""),
page_title=data.get("page_title", ""), page_title=data.get("page_title", ""),
snippet=data.get("snippet", ""), snippet=data.get("snippet", ""),
@@ -201,6 +225,7 @@ class BacklinkOutreachStorageService:
) )
db.add(lead) db.add(lead)
added.append(lead) added.append(lead)
existing_urls.add(url)
db.commit() db.commit()
return [self._lead_to_dict(l) for l in added] return [self._lead_to_dict(l) for l in added]
finally: finally:
@@ -230,29 +255,27 @@ class BacklinkOutreachStorageService:
notes: Optional[str] = None, notes: Optional[str] = None,
campaign_id: Optional[str] = None, campaign_id: Optional[str] = None,
) -> Optional[dict]: ) -> Optional[dict]:
if status not in LEAD_VALID_STATUSES:
raise ValueError(f"Invalid status '{status}'. Valid values: {sorted(LEAD_VALID_STATUSES)}")
self._ensure_tables(user_id) self._ensure_tables(user_id)
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
if not db: if not db:
return None return None
try: try:
query = ( lead = db.query(BacklinkLead).filter(BacklinkLead.id == lead_id).first()
db.query(BacklinkLead)
.join(BacklinkCampaign, BacklinkLead.campaign_id == BacklinkCampaign.id)
.filter(
BacklinkLead.id == lead_id,
BacklinkCampaign.user_id == user_id,
)
)
if campaign_id:
query = query.filter(BacklinkCampaign.id == campaign_id)
lead = query.first()
if not lead: if not lead:
access = self._get_lead_access_rows(db, [lead_id]).get(lead_id) return None
if not access:
return None campaign = (
if access["user_id"] != user_id: db.query(BacklinkCampaign)
raise PermissionError("Lead does not belong to the current user") .filter(BacklinkCampaign.id == lead.campaign_id, BacklinkCampaign.user_id == user_id)
.first()
)
if not campaign:
raise PermissionError("Lead does not belong to the current user")
if campaign_id and lead.campaign_id != campaign_id:
return None return None
lead.status = status lead.status = status
@@ -491,6 +514,7 @@ class BacklinkOutreachStorageService:
"decision_reason": attempt.decision_reason, "decision_reason": attempt.decision_reason,
"sent_at": attempt.sent_at.isoformat() if attempt.sent_at else None, "sent_at": attempt.sent_at.isoformat() if attempt.sent_at else None,
"created_at": attempt.created_at.isoformat() if attempt.created_at else None, "created_at": attempt.created_at.isoformat() if attempt.created_at else None,
"message_id": attempt.message_id or "",
} }
def find_attempt_by_from_email(self, from_email: str, user_id: str = "default") -> Optional[str]: def find_attempt_by_from_email(self, from_email: str, user_id: str = "default") -> Optional[str]:
@@ -512,6 +536,37 @@ class BacklinkOutreachStorageService:
finally: finally:
db.close() db.close()
def update_attempt_message_id(self, attempt_id: str, message_id: str, user_id: str = "default") -> Optional[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
attempt = db.query(OutreachAttempt).filter(OutreachAttempt.id == attempt_id).first()
if not attempt:
return None
attempt.message_id = message_id
db.commit()
return self._attempt_to_dict(attempt)
finally:
db.close()
def find_attempt_by_message_id(self, message_id: str, user_id: str = "default") -> Optional[str]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
clean = message_id.strip()
attempt = (
db.query(OutreachAttempt)
.filter(OutreachAttempt.message_id == clean)
.first()
)
return attempt.id if attempt else None
finally:
db.close()
# -- Outreach Reply CRUD -- # -- Outreach Reply CRUD --
def reply_exists(self, from_email: str, subject: str, user_id: str = "default") -> bool: def reply_exists(self, from_email: str, subject: str, user_id: str = "default") -> bool:
@@ -855,27 +910,6 @@ class BacklinkOutreachStorageService:
def _today(self) -> date: def _today(self) -> date:
return date.today() return date.today()
def increment_user_send_counter(self, user_id: str) -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
row_id = f"scu_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_user (id, user_id, date, count) "
"VALUES (:id, :uid, :dt, 1) "
"ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "uid": user_id, "dt": today})
db.commit()
result = db.query(SendCounterUser.count).filter(
SendCounterUser.user_id == user_id, SendCounterUser.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_user_send_count(self, user_id: str) -> int: def get_user_send_count(self, user_id: str) -> int:
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
if not db: if not db:
@@ -891,28 +925,6 @@ class BacklinkOutreachStorageService:
finally: finally:
db.close() db.close()
def increment_domain_send_counter(self, domain: str, user_id: str = "default") -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
domain_lower = domain.lower()
row_id = f"scd_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_domain (id, domain, date, count) "
"VALUES (:id, :dom, :dt, 1) "
"ON CONFLICT (domain, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "dom": domain_lower, "dt": today})
db.commit()
result = db.query(SendCounterDomain.count).filter(
SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_domain_send_count(self, domain: str, user_id: str = "default") -> int: def get_domain_send_count(self, domain: str, user_id: str = "default") -> int:
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
if not db: if not db:
@@ -928,6 +940,73 @@ class BacklinkOutreachStorageService:
finally: finally:
db.close() db.close()
def try_increment_user_send_counter(self, user_id: str) -> tuple:
"""Atomically check cap and increment. Returns (within_cap, new_count)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return True, 0
try:
today = self._today()
current = (
db.query(SendCounterUser.count)
.filter(SendCounterUser.user_id == user_id, SendCounterUser.date == today)
.scalar()
) or 0
if current >= DEFAULT_USER_DAILY_CAP:
db.close()
return False, current
row_id = f"scu_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_user (id, user_id, date, count) "
"VALUES (:id, :uid, :dt, 1) "
"ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "uid": user_id, "dt": today})
db.commit()
result = db.query(SendCounterUser.count).filter(
SendCounterUser.user_id == user_id, SendCounterUser.date == today
).first()
return True, result[0] if result else 0
except Exception:
db.rollback()
return True, 0
finally:
db.close()
def try_increment_domain_send_counter(self, domain: str, user_id: str = "default") -> tuple:
"""Atomically check cap and increment. Returns (within_cap, new_count)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return True, 0
try:
today = self._today()
domain_lower = domain.lower()
current = (
db.query(SendCounterDomain.count)
.filter(SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today)
.scalar()
) or 0
if current >= DEFAULT_DOMAIN_DAILY_CAP:
db.close()
return False, current
row_id = f"scd_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_domain (id, domain, date, count) "
"VALUES (:id, :dom, :dt, 1) "
"ON CONFLICT (domain, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "dom": domain_lower, "dt": today})
db.commit()
result = db.query(SendCounterDomain.count).filter(
SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today
).first()
return True, result[0] if result else 0
except Exception:
db.rollback()
return True, 0
finally:
db.close()
# -- Audit Log -- # -- Audit Log --
def add_audit_log( def add_audit_log(

View File

@@ -158,7 +158,7 @@ export interface LeadRecord {
email: string | null; email: string | null;
confidence_score: number; confidence_score: number;
discovery_source: string; discovery_source: string;
status: string; status: LeadStatus;
notes: string | null; notes: string | null;
created_at: string | null; created_at: string | null;
} }
@@ -179,8 +179,10 @@ export interface LeadCreateRequest {
notes?: string; notes?: string;
} }
export type LeadStatus = 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed';
export interface LeadStatusUpdateRequest { export interface LeadStatusUpdateRequest {
status: string; status: LeadStatus;
notes?: string; notes?: string;
campaign_id?: string; campaign_id?: string;
} }
@@ -335,7 +337,7 @@ export interface FollowUpRequest {
export interface BulkStatusUpdateRequest { export interface BulkStatusUpdateRequest {
lead_ids: string[]; lead_ids: string[];
status: string; status: LeadStatus;
notes?: string; notes?: string;
campaign_id?: string; campaign_id?: string;
} }

View File

@@ -12,6 +12,7 @@ import {
GenerateEmailRequest, GenerateEmailRequest,
bulkUpdateLeadStatus, bulkUpdateLeadStatus,
updateLeadStatus, updateLeadStatus,
addLeadToCampaign,
fetchCampaignAnalyticsVolume, fetchCampaignAnalyticsVolume,
fetchCampaignAnalyticsFunnel, fetchCampaignAnalyticsFunnel,
CampaignVolumePoint, CampaignVolumePoint,
@@ -25,7 +26,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as
type Tab = 'campaigns' | 'discover' | 'leads' | 'composer' | 'analytics'; type Tab = 'campaigns' | 'discover' | 'leads' | 'composer' | 'analytics';
const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed']; const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed'] as const;
const STATUS_EXPLANATIONS: Record<string, string> = { const STATUS_EXPLANATIONS: Record<string, string> = {
discovered: 'Lead found but not yet contacted', discovered: 'Lead found but not yet contacted',
@@ -139,7 +140,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
const [templateName, setTemplateName] = useState(''); const [templateName, setTemplateName] = useState('');
const [selectedLeadIds, setSelectedLeadIds] = useState<Set<string>>(new Set()); const [selectedLeadIds, setSelectedLeadIds] = useState<Set<string>>(new Set());
const [bulkStatus, setBulkStatus] = useState('contacted'); const [bulkStatus, setBulkStatus] = useState<'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed'>('contacted');
const [volumeData, setVolumeData] = useState<CampaignVolumePoint[]>([]); const [volumeData, setVolumeData] = useState<CampaignVolumePoint[]>([]);
const [funnelData, setFunnelData] = useState<FunnelStage[]>([]); const [funnelData, setFunnelData] = useState<FunnelStage[]>([]);
@@ -203,9 +204,24 @@ const BacklinkOutreachDashboard: React.FC = () => {
}, [keyword, deepDiscover]); }, [keyword, deepDiscover]);
const handleDiscoverAndSave = useCallback(async () => { const handleDiscoverAndSave = useCallback(async () => {
if (!keyword.trim() || !discoverCampaignId) return; if (!keyword.trim() || !discoverCampaignId || discoveredOpportunities.length === 0) return;
await deepDiscover(keyword.trim(), 15, discoverCampaignId); for (const opp of discoveredOpportunities) {
}, [keyword, discoverCampaignId, deepDiscover]); try {
await addLeadToCampaign(discoverCampaignId, {
campaign_id: discoverCampaignId,
url: opp.url,
domain: opp.domain,
page_title: opp.page_title,
snippet: opp.snippet,
email: opp.email ?? undefined,
confidence_score: opp.confidence_score,
});
} catch (e) {
// skip duplicates
}
}
showToastNotification(`Saved ${discoveredOpportunities.length} leads to campaign`, 'success');
}, [keyword, discoverCampaignId, discoveredOpportunities]);
const handleSelectCampaign = useCallback(async (campaignId: string) => { const handleSelectCampaign = useCallback(async (campaignId: string) => {
await selectCampaign(campaignId); await selectCampaign(campaignId);
@@ -324,7 +340,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
); );
}; };
const handleSingleStatusUpdate = async (leadId: string, status: string) => { const handleSingleStatusUpdate = async (leadId: string, status: 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed') => {
setIsStatusUpdating(true); setIsStatusUpdating(true);
try { try {
await updateLeadStatus(leadId, { await updateLeadStatus(leadId, {
@@ -681,7 +697,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
{selectedLeadIds.size > 0 && ( {selectedLeadIds.size > 0 && (
<> <>
<TooltipWrap text="Choose the new status for all selected leads"> <TooltipWrap text="Choose the new status for all selected leads">
<select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value)} <select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value as typeof bulkStatus)}
style={{ ...selectSx, padding: '6px 10px', fontSize: '12px', minWidth: '130px' }}> style={{ ...selectSx, padding: '6px 10px', fontSize: '12px', minWidth: '130px' }}>
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)} {STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
</select> </select>