"""Backlink outreach persistence service (campaign-creator style).""" from __future__ import annotations from datetime import datetime, date from uuid import uuid4 from typing import List, Optional, Tuple from urllib.parse import urlsplit, urlunsplit from sqlalchemy import text as sql_text, func as sa_func, or_ from sqlalchemy.exc import IntegrityError from services.database import get_session_for_user from models.backlink_outreach_models import ( Base, BacklinkCampaign, BacklinkLead, OutreachAttempt, OutreachReply, FollowUpSchedule, EmailTemplate, SuppressedRecipient, SentIdempotencyKey, AuditLogEntry, SendCounterUser, SendCounterDomain, ) class BacklinkOutreachStorageService: _NEW_LEAD_COLUMNS = [ "url", "page_title", "snippet", "confidence_score", "discovery_source", "notes" ] @staticmethod def _normalize_email(email: Optional[str]) -> Optional[str]: normalized = (email or "").strip().lower() return normalized or None @staticmethod def _normalize_domain(domain: Optional[str]) -> str: value = (domain or "").strip().lower() if not value: return "" if "://" not in value: value = f"//{value}" parsed = urlsplit(value) hostname = (parsed.hostname or value).strip().lower().rstrip(".") return hostname[4:] if hostname.startswith("www.") else hostname @classmethod def _normalize_url(cls, url: Optional[str]) -> str: value = (url or "").strip() if not value: return "" parse_value = value if "://" in value else f"https://{value}" parsed = urlsplit(parse_value) scheme = (parsed.scheme or "https").lower() hostname = (parsed.hostname or "").lower().rstrip(".") if hostname.startswith("www."): hostname = hostname[4:] if not hostname: return value.rstrip("/") try: port = parsed.port except ValueError: port = None netloc = hostname if port and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)): netloc = f"{hostname}:{port}" path = parsed.path or "" if path != "/": path = path.rstrip("/") query = parsed.query return urlunsplit((scheme, netloc, path, query, "")) @classmethod def _normalize_lead_identity( cls, url: Optional[str], domain: Optional[str], email: Optional[str] ) -> Tuple[str, str, Optional[str]]: normalized_url = cls._normalize_url(url) normalized_domain = cls._normalize_domain(domain) if not normalized_domain and normalized_url: normalized_domain = cls._normalize_domain(normalized_url) normalized_email = cls._normalize_email(email) return normalized_url, normalized_domain, normalized_email 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) self._migrate_lead_uniqueness_indexes(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: valid_columns = {"url", "page_title", "snippet", "confidence_score", "discovery_source", "notes"} for col in self._NEW_LEAD_COLUMNS: if col not in valid_columns: continue safe_col = col.replace('"', "").replace(";", "") db.execute(sql_text( f"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS \"{safe_col}\" TEXT" )) 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 _migrate_lead_uniqueness_indexes(self, db) -> None: """Create normalized lead uniqueness indexes when existing data allows it.""" index_statements = ( """ CREATE UNIQUE INDEX IF NOT EXISTS idx_backlink_lead_campaign_url_unique ON backlink_leads (campaign_id, url) WHERE url IS NOT NULL AND url != '' """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_backlink_lead_campaign_domain_email_unique ON backlink_leads (campaign_id, domain, email) WHERE email IS NOT NULL AND email != '' """, ) for statement in index_statements: try: db.execute(sql_text(statement)) db.commit() except Exception: # Existing duplicate historical data should not block app startup; # service-level duplicate checks still prevent new duplicates. 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 _find_existing_lead(self, db, campaign_id: str, url: str, domain: str, email: Optional[str]): duplicate_filters = [] if url: duplicate_filters.append(BacklinkLead.url == url) if domain and email: duplicate_filters.append((BacklinkLead.domain == domain) & (BacklinkLead.email == email)) if not duplicate_filters: return None existing = ( db.query(BacklinkLead) .filter(BacklinkLead.campaign_id == campaign_id) .filter(or_(*duplicate_filters)) .order_by(BacklinkLead.created_at.asc()) .first() ) if existing: return existing # Historical leads may have been stored before normalization. Normalize # candidates in Python so those records are also treated as duplicates. candidates = ( db.query(BacklinkLead) .filter(BacklinkLead.campaign_id == campaign_id) .order_by(BacklinkLead.created_at.asc()) .all() ) for candidate in candidates: candidate_url, candidate_domain, candidate_email = self._normalize_lead_identity( candidate.url, candidate.domain, candidate.email ) if url and candidate_url == url: return candidate if domain and email and candidate_domain == domain and candidate_email == email: return candidate return None 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: normalized_url, normalized_domain, normalized_email = self._normalize_lead_identity(url, domain, email) existing = self._find_existing_lead(db, campaign_id, normalized_url, normalized_domain, normalized_email) if existing: result = self._lead_to_dict(existing) result["duplicate"] = True result["skipped"] = True return result lead = BacklinkLead( id=f"bl_{uuid4().hex[:16]}", campaign_id=campaign_id, url=normalized_url, domain=normalized_domain, page_title=page_title, snippet=snippet, email=normalized_email, confidence_score=confidence_score, discovery_source=discovery_source, status="discovered", notes=notes, created_at=datetime.utcnow(), ) db.add(lead) try: db.commit() except IntegrityError: db.rollback() existing = self._find_existing_lead(db, campaign_id, normalized_url, normalized_domain, normalized_email) if existing: result = self._lead_to_dict(existing) result["duplicate"] = True result["skipped"] = True return result raise result = self._lead_to_dict(lead) result["duplicate"] = False result["skipped"] = False return result 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: results = [] for data in leads_data: normalized_url, normalized_domain, normalized_email = self._normalize_lead_identity( data.get("url"), data.get("domain"), data.get("email") ) existing = self._find_existing_lead(db, campaign_id, normalized_url, normalized_domain, normalized_email) if existing: result = self._lead_to_dict(existing) result["duplicate"] = True result["skipped"] = True results.append(result) continue lead = BacklinkLead( id=f"bl_{uuid4().hex[:16]}", campaign_id=campaign_id, url=normalized_url, domain=normalized_domain, page_title=data.get("page_title", ""), snippet=data.get("snippet", ""), email=normalized_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) try: db.commit() except IntegrityError: db.rollback() existing = self._find_existing_lead(db, campaign_id, normalized_url, normalized_domain, normalized_email) if existing: result = self._lead_to_dict(existing) result["duplicate"] = True result["skipped"] = True results.append(result) continue raise result = self._lead_to_dict(lead) result["duplicate"] = False result["skipped"] = False results.append(result) return results 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]: self._ensure_tables(user_id) 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, } # -- Outreach Attempt CRUD -- def add_attempt( self, lead_id: str, campaign_id: str, idempotency_key: str, sender_email: str = "", subject: str = "", body: str = "", status: str = "queued", decision_reason: Optional[str] = None, user_id: str = "default", ) -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: attempt = OutreachAttempt( id=f"att_{uuid4().hex[:16]}", lead_id=lead_id, campaign_id=campaign_id, idempotency_key=idempotency_key, sender_email=sender_email, subject=subject, body=body, status=status, decision_reason=decision_reason, created_at=datetime.utcnow(), ) db.add(attempt) db.commit() return self._attempt_to_dict(attempt) finally: db.close() def list_attempts(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(OutreachAttempt) .filter(OutreachAttempt.campaign_id == campaign_id) .order_by(OutreachAttempt.created_at.desc()) .limit(limit) .all() ) return [self._attempt_to_dict(r) for r in rows] finally: db.close() def update_attempt_status(self, attempt_id: str, status: str, decision_reason: Optional[str] = None, 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.status = status if decision_reason is not None: attempt.decision_reason = decision_reason if status == "sent": attempt.sent_at = datetime.utcnow() db.commit() return self._attempt_to_dict(attempt) finally: db.close() @staticmethod def _attempt_to_dict(attempt) -> dict: return { "attempt_id": attempt.id, "lead_id": attempt.lead_id, "campaign_id": attempt.campaign_id, "idempotency_key": attempt.idempotency_key, "sender_email": attempt.sender_email or "", "subject": attempt.subject or "", "status": attempt.status, "decision_reason": attempt.decision_reason, "sent_at": attempt.sent_at.isoformat() if attempt.sent_at else None, "created_at": attempt.created_at.isoformat() if attempt.created_at else None, } def find_attempt_by_from_email(self, from_email: str, user_id: str = "default") -> Optional[str]: """Find the most recent attempt_id for a given sender email (lead).""" self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return None try: from sqlalchemy import desc attempt = ( db.query(OutreachAttempt) .join(BacklinkLead, OutreachAttempt.lead_id == BacklinkLead.id) .filter(BacklinkLead.email == from_email) .order_by(desc(OutreachAttempt.created_at)) .first() ) return attempt.id if attempt else None finally: db.close() # -- Outreach Reply CRUD -- def reply_exists(self, from_email: str, subject: str, user_id: str = "default") -> bool: """Check if a reply with this from_email+subject already exists.""" db = get_session_for_user(user_id) if not db: return False try: exists = ( db.query(OutreachReply.id) .filter(OutreachReply.from_email == from_email, OutreachReply.subject == subject) .first() ) return exists is not None finally: db.close() def add_reply( self, attempt_id: str, from_email: str = "", subject: str = "", body: str = "", classification: str = "replied", user_id: str = "default", ) -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: reply = OutreachReply( id=f"rep_{uuid4().hex[:16]}", attempt_id=attempt_id, from_email=from_email, subject=subject, body=body, classification=classification, received_at=datetime.utcnow(), ) db.add(reply) db.commit() return self._reply_to_dict(reply) finally: db.close() def list_replies(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]: """List replies by joining through attempts to filter by campaign.""" self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(OutreachReply) .join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id) .filter(OutreachAttempt.campaign_id == campaign_id) .order_by(OutreachReply.received_at.desc()) .limit(limit) .all() ) return [self._reply_to_dict(r) for r in rows] finally: db.close() @staticmethod def _reply_to_dict(reply) -> dict: return { "reply_id": reply.id, "attempt_id": reply.attempt_id, "from_email": reply.from_email or "", "subject": reply.subject or "", "received_at": reply.received_at.isoformat() if reply.received_at else None, "classification": reply.classification, "body": reply.body or "", } # -- Follow-Up Schedule CRUD -- def schedule_followup( self, attempt_id: str, scheduled_for: str, subject: str = "", body: str = "", user_id: str = "default", ) -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: sched = FollowUpSchedule( id=f"fu_{uuid4().hex[:16]}", attempt_id=attempt_id, subject=subject or None, body=body or None, scheduled_for=datetime.fromisoformat(scheduled_for) if isinstance(scheduled_for, str) else scheduled_for, sent=False, ) db.add(sched) db.commit() return self._followup_to_dict(sched) finally: db.close() def list_followups(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]: """List follow-ups by joining through attempts to filter by campaign.""" self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(FollowUpSchedule) .join(OutreachAttempt, FollowUpSchedule.attempt_id == OutreachAttempt.id) .filter(OutreachAttempt.campaign_id == campaign_id) .order_by(FollowUpSchedule.scheduled_for.asc()) .limit(limit) .all() ) return [self._followup_to_dict(r) for r in rows] finally: db.close() def mark_followup_sent(self, schedule_id: str, user_id: str = "default") -> Optional[dict]: db = get_session_for_user(user_id) if not db: return None try: sched = db.query(FollowUpSchedule).filter(FollowUpSchedule.id == schedule_id).first() if not sched: return None sched.sent = True db.commit() return self._followup_to_dict(sched) finally: db.close() @staticmethod def _followup_to_dict(sched) -> dict: return { "schedule_id": sched.id, "attempt_id": sched.attempt_id, "subject": sched.subject or "", "scheduled_for": sched.scheduled_for.isoformat() if sched.scheduled_for else None, "sent": sched.sent, } # -- Email Template CRUD -- def create_template( self, user_id: str, name: str, subject_template: str, body_template: str, variables: Optional[List[str]] = None, ) -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: tmpl = EmailTemplate( id=f"tpl_{uuid4().hex[:16]}", user_id=user_id, name=name, subject_template=subject_template, body_template=body_template, variables=",".join(variables) if variables else None, created_at=datetime.utcnow(), ) db.add(tmpl) db.commit() return self._template_to_dict(tmpl) finally: db.close() def list_templates(self, user_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(EmailTemplate) .filter(EmailTemplate.user_id == user_id) .order_by(EmailTemplate.created_at.desc()) .limit(limit) .all() ) return [self._template_to_dict(r) for r in rows] finally: db.close() def get_template(self, template_id: str, user_id: str) -> Optional[dict]: db = get_session_for_user(user_id) if not db: return None try: tmpl = ( db.query(EmailTemplate) .filter(EmailTemplate.id == template_id, EmailTemplate.user_id == user_id) .first() ) if not tmpl: return None return self._template_to_dict(tmpl) finally: db.close() def delete_template(self, template_id: str, user_id: str) -> bool: db = get_session_for_user(user_id) if not db: return False try: tmpl = ( db.query(EmailTemplate) .filter(EmailTemplate.id == template_id, EmailTemplate.user_id == user_id) .first() ) if not tmpl: return False db.delete(tmpl) db.commit() return True finally: db.close() @staticmethod def _template_to_dict(tmpl) -> dict: return { "template_id": tmpl.id, "user_id": tmpl.user_id, "name": tmpl.name, "subject_template": tmpl.subject_template, "body_template": tmpl.body_template, "variables": tmpl.variables.split(",") if tmpl.variables else [], "created_at": tmpl.created_at.isoformat() if tmpl.created_at else None, } # -- Suppression List -- def add_suppressed(self, email: str, user_id: str = "default", domain: str = "", reason: str = "") -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: entry = SuppressedRecipient( id=f"sup_{uuid4().hex[:16]}", email=email.lower(), domain=domain.lower() if domain else email.split("@")[-1].lower(), reason=reason, user_id=user_id, created_at=datetime.utcnow(), ) db.add(entry) db.commit() return {"id": entry.id, "email": entry.email, "reason": entry.reason} finally: db.close() def is_suppressed(self, email: str, domain: str = "", user_id: str = "default") -> bool: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return False try: email_lower = email.lower() domain_lower = domain.lower() if domain else email.split("@")[-1].lower() exists = ( db.query(SuppressedRecipient.id) .filter( (SuppressedRecipient.email == email_lower) | (SuppressedRecipient.domain == domain_lower) ) .first() ) return exists is not None finally: db.close() def list_suppressed(self, user_id: str = "default", limit: int = 100) -> List[dict]: db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(SuppressedRecipient) .order_by(SuppressedRecipient.created_at.desc()) .limit(limit) .all() ) return [{"id": r.id, "email": r.email, "domain": r.domain, "reason": r.reason, "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows] finally: db.close() # -- Idempotency -- def check_idempotency(self, idempotency_key: str, user_id: str = "default") -> bool: """Returns True if key already exists (duplicate).""" self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return False try: exists = ( db.query(SentIdempotencyKey.id) .filter(SentIdempotencyKey.idempotency_key == idempotency_key) .first() ) return exists is not None finally: db.close() def mark_idempotency(self, idempotency_key: str, user_id: str = "default") -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: entry = SentIdempotencyKey( id=f"idm_{uuid4().hex[:16]}", idempotency_key=idempotency_key, user_id=user_id, created_at=datetime.utcnow(), ) db.add(entry) db.commit() return {"idempotency_key": idempotency_key} finally: db.close() # -- Send Counters -- def _today(self) -> date: 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: db = get_session_for_user(user_id) if not db: return 0 try: today = self._today() row = ( db.query(SendCounterUser.count) .filter(SendCounterUser.user_id == user_id, SendCounterUser.date == today) .first() ) return row[0] if row else 0 finally: 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: db = get_session_for_user(user_id) if not db: return 0 try: today = self._today() row = ( db.query(SendCounterDomain.count) .filter(SendCounterDomain.domain == domain.lower(), SendCounterDomain.date == today) .first() ) return row[0] if row else 0 finally: db.close() # -- Audit Log -- def add_audit_log( self, event: str, user_id: str, campaign_id: str = "", recipient: str = "", allowed: bool = False, reasons: Optional[List[str]] = None, override: bool = False, ) -> dict: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: raise RuntimeError("Database session unavailable") try: entry = AuditLogEntry( id=f"aud_{uuid4().hex[:16]}", user_id=user_id, campaign_id=campaign_id or None, event=event, recipient=recipient or None, allowed=allowed, reasons=";".join(reasons) if reasons else None, override=override, created_at=datetime.utcnow(), ) db.add(entry) db.commit() return {"id": entry.id, "event": entry.event, "allowed": entry.allowed} finally: db.close() def list_audit_logs(self, campaign_id: Optional[str] = None, limit: int = 100, user_id: str = "default") -> List[dict]: db = get_session_for_user(user_id) if not db: return [] try: q = db.query(AuditLogEntry) if campaign_id: q = q.filter(AuditLogEntry.campaign_id == campaign_id) rows = q.order_by(AuditLogEntry.created_at.desc()).limit(limit).all() return [ { "id": r.id, "event": r.event, "recipient": r.recipient, "allowed": r.allowed, "reasons": r.reasons.split(";") if r.reasons else [], "override": r.override, "created_at": r.created_at.isoformat() if r.created_at else None, } for r in rows ] finally: db.close() # -- Analytics -- def get_send_volume_by_day(self, campaign_id: str, days: int = 30, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: from datetime import timedelta cutoff = datetime.utcnow() - timedelta(days=days) rows = ( db.query(sa_func.date(OutreachAttempt.sent_at).label("date"), sa_func.count(OutreachAttempt.id).label("count")) .filter(OutreachAttempt.campaign_id == campaign_id, OutreachAttempt.status == "sent", OutreachAttempt.sent_at >= cutoff) .group_by(sa_func.date(OutreachAttempt.sent_at)) .order_by(sa_func.date(OutreachAttempt.sent_at).asc()) .all() ) return [{"date": str(r.date), "count": r.count} for r in rows] finally: db.close() def get_lead_status_counts(self, campaign_id: str, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(BacklinkLead.status, sa_func.count(BacklinkLead.id).label("count")) .filter(BacklinkLead.campaign_id == campaign_id) .group_by(BacklinkLead.status) .order_by(BacklinkLead.status.asc()) .all() ) return [{"status": r.status, "count": r.count} for r in rows] finally: db.close() def list_attempts_all(self, campaign_id: str, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(OutreachAttempt) .filter(OutreachAttempt.campaign_id == campaign_id) .order_by(OutreachAttempt.created_at.desc()) .all() ) return [self._attempt_to_dict(r) for r in rows] finally: db.close() def list_replies_all(self, campaign_id: str, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(OutreachReply) .join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id) .filter(OutreachAttempt.campaign_id == campaign_id) .order_by(OutreachReply.received_at.desc()) .all() ) return [self._reply_to_dict(r) for r in rows] finally: db.close() def count_replies(self, campaign_id: str, user_id: str = "default") -> int: db = get_session_for_user(user_id) if not db: return 0 try: return ( db.query(OutreachReply.id) .join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id) .filter(OutreachAttempt.campaign_id == campaign_id) .count() ) finally: db.close() def list_leads_all(self, campaign_id: str, user_id: str = "default") -> List[dict]: self._ensure_tables(user_id) db = get_session_for_user(user_id) if not db: return [] try: rows = ( db.query(BacklinkLead) .filter(BacklinkLead.campaign_id == campaign_id) .order_by(BacklinkLead.created_at.desc()) .all() ) return [self._lead_to_dict(r) for r in rows] finally: db.close() # -- Policy Helpers (composite checks) -- def get_lead(self, lead_id: str, user_id: str = "default") -> 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 return self._lead_to_dict(lead) finally: db.close()