fix: credit tracking, voice clone TTL, avatar upload ui, asset serving fallback, OAuth encryption, free plan video renders, backlink outreach sprint
This commit is contained in:
@@ -2,13 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy import text as sql_text, func as sa_func
|
||||
|
||||
from services.database import get_session_for_user
|
||||
from models.backlink_outreach_models import Base, BacklinkCampaign, BacklinkLead
|
||||
from models.backlink_outreach_models import (
|
||||
Base, BacklinkCampaign, BacklinkLead,
|
||||
OutreachAttempt, OutreachReply, FollowUpSchedule, EmailTemplate,
|
||||
SuppressedRecipient, SentIdempotencyKey, AuditLogEntry,
|
||||
SendCounterUser, SendCounterDomain,
|
||||
)
|
||||
|
||||
|
||||
class BacklinkOutreachStorageService:
|
||||
@@ -29,11 +34,14 @@ class BacklinkOutreachStorageService:
|
||||
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 {col} TEXT"
|
||||
f"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS \"{safe_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"
|
||||
))
|
||||
@@ -198,6 +206,7 @@ class BacklinkOutreachStorageService:
|
||||
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
|
||||
@@ -229,3 +238,696 @@ class BacklinkOutreachStorageService:
|
||||
"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()
|
||||
|
||||
Reference in New Issue
Block a user