system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/drafts.py
Claude 81a444d3e1
feat(dealix): connect draft queue to real WhatsApp send via Ultramsg
- POST /drafts/{id}/send now uses Ultramsg first (existing outreach_engine),
  falls back to WhatsApp Business API if Ultramsg fails
- POST /drafts/send-approved-batch — bulk send up to N approved drafts
  via any channel (whatsapp/email/sms/linkedin-manual)
- WhatsApp sends use existing _send_via_ultramsg() with rate limiting
- Email uses existing SMTP integration
- SMS uses existing Unifonic integration
- LinkedIn returns manual_required (copy from dashboard)

The draft queue is now a fully functional outreach automation system:
daily-pipeline/run → drafts → approve → send-approved-batch → real messages

https://claude.ai/code/session_01W1rJthWDkasijTdXCfxVHs
2026-04-25 17:49:46 +00:00

386 lines
13 KiB
Python

"""Draft Queue API — review, approve, send, track outreach drafts.
All outreach starts as drafts. Sami reviews in dashboard, approves
batch, then system sends via existing Celery tasks.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("dealix.drafts")
router = APIRouter(prefix="/drafts", tags=["Draft Queue"])
async def _get_db():
from app.database import get_db
async for session in get_db():
yield session
class DraftFilter(BaseModel):
status: Optional[str] = "draft"
channel: Optional[str] = None
batch_id: Optional[str] = None
sector: Optional[str] = None
limit: int = 50
class ApproveBatchRequest(BaseModel):
batch_id: str
class LogReplyRequest(BaseModel):
reply_text: str
class EditDraftRequest(BaseModel):
subject: Optional[str] = None
body: Optional[str] = None
channel: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
@router.get("/")
async def list_drafts(
status: Optional[str] = Query("draft"),
channel: Optional[str] = Query(None),
batch_id: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(_get_db),
) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
stmt = select(OutreachDraft)
if status:
stmt = stmt.where(OutreachDraft.status == status)
if channel:
stmt = stmt.where(OutreachDraft.channel == channel)
if batch_id:
stmt = stmt.where(OutreachDraft.batch_id == batch_id)
stmt = stmt.order_by(OutreachDraft.created_at.desc()).limit(limit)
result = await db.execute(stmt)
rows = list(result.scalars().all())
return {
"drafts": [r.to_dict() for r in rows],
"count": len(rows),
"filter": {"status": status, "channel": channel, "batch_id": batch_id},
}
@router.get("/stats")
async def draft_stats(db: AsyncSession = Depends(_get_db)) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft.status, func.count(OutreachDraft.id))
.group_by(OutreachDraft.status)
)
counts = {row[0]: row[1] for row in result.all()}
return {
"total": sum(counts.values()),
"draft": counts.get("draft", 0),
"approved": counts.get("approved", 0),
"sent": counts.get("sent", 0),
"replied": counts.get("replied", 0),
"opted_out": counts.get("opted_out", 0),
"bounced": counts.get("bounced", 0),
"skipped": counts.get("skipped", 0),
}
@router.get("/{draft_id}")
async def get_draft(draft_id: str, db: AsyncSession = Depends(_get_db)) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
d = draft.to_dict()
d["body"] = draft.body
d["followup_2d"] = draft.followup_2d
d["followup_5d"] = draft.followup_5d
d["call_script"] = draft.call_script
return d
@router.post("/{draft_id}/approve")
async def approve_draft(draft_id: str, db: AsyncSession = Depends(_get_db)) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
if draft.status != "draft":
return {"id": str(draft.id), "status": draft.status, "message": "already processed"}
draft.status = "approved"
draft.approved_at = datetime.now(timezone.utc)
await db.commit()
return {"id": str(draft.id), "status": "approved"}
@router.post("/approve-batch")
async def approve_batch(
req: ApproveBatchRequest, db: AsyncSession = Depends(_get_db)
) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
update(OutreachDraft)
.where(OutreachDraft.batch_id == req.batch_id, OutreachDraft.status == "draft")
.values(status="approved", approved_at=datetime.now(timezone.utc))
)
await db.commit()
return {"batch_id": req.batch_id, "approved_count": result.rowcount}
@router.post("/{draft_id}/send")
async def send_draft(draft_id: str, db: AsyncSession = Depends(_get_db)) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
if draft.status not in ("approved", "draft"):
return {"id": str(draft.id), "status": draft.status, "message": "not sendable"}
send_result = {"channel": draft.channel, "status": "pending"}
if draft.channel == "email" and draft.contact_email:
try:
from app.integrations.email_sender import send_email
r = await send_email(draft.contact_email, draft.subject, draft.body)
send_result = {"channel": "email", "status": "sent", "result": r}
except Exception as exc:
send_result = {"channel": "email", "status": "failed", "error": str(exc)[:200]}
elif draft.channel == "whatsapp" and draft.contact_phone:
try:
from app.api.v1.outreach_engine import _send_via_ultramsg, _format_phone
r = await _send_via_ultramsg(draft.contact_phone, draft.body)
if "error" not in r:
send_result = {"channel": "whatsapp_ultramsg", "status": "sent", "result": r}
else:
from app.integrations.whatsapp import send_whatsapp_message
r2 = await send_whatsapp_message(draft.contact_phone, draft.body)
send_result = {"channel": "whatsapp_business_api", "status": "sent", "result": r2}
except Exception as exc:
send_result = {"channel": "whatsapp", "status": "failed", "error": str(exc)[:200]}
elif draft.channel == "sms" and draft.contact_phone:
try:
from app.integrations.sms import send_sms
r = await send_sms(draft.contact_phone, draft.body)
send_result = {"channel": "sms", "status": "sent", "result": r}
except Exception as exc:
send_result = {"channel": "sms", "status": "failed", "error": str(exc)[:200]}
elif draft.channel == "linkedin":
send_result = {
"channel": "linkedin",
"status": "manual_required",
"message": "Copy the message and send manually on LinkedIn",
}
if send_result.get("status") == "sent":
draft.status = "sent"
draft.sent_at = datetime.now(timezone.utc)
elif send_result.get("status") == "failed":
draft.next_action = f"send_failed: {send_result.get('error', '')[:100]}"
await db.commit()
return {"id": str(draft.id), **send_result}
@router.post("/{draft_id}/skip")
async def skip_draft(draft_id: str, db: AsyncSession = Depends(_get_db)) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
draft.status = "skipped"
await db.commit()
return {"id": str(draft.id), "status": "skipped"}
@router.patch("/{draft_id}")
async def edit_draft(
draft_id: str, req: EditDraftRequest, db: AsyncSession = Depends(_get_db)
) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
if draft.status != "draft":
raise HTTPException(status_code=400, detail="Can only edit drafts, not sent/approved")
for field, value in req.model_dump(exclude_none=True).items():
setattr(draft, field, value)
await db.commit()
return {"id": str(draft.id), "status": "edited", "updated_fields": list(req.model_dump(exclude_none=True).keys())}
@router.post("/send-approved-batch")
async def send_approved_batch(
channel: str = "whatsapp",
batch_size: int = 10,
db: AsyncSession = Depends(_get_db),
) -> Dict[str, Any]:
"""Send up to batch_size approved drafts via specified channel.
Uses Ultramsg for WhatsApp (fallback to Business API),
SMTP for email, Unifonic for SMS. LinkedIn = manual only.
"""
from app.models.outreach_draft import OutreachDraft
stmt = (
select(OutreachDraft)
.where(
OutreachDraft.status == "approved",
OutreachDraft.channel == channel,
)
.order_by(OutreachDraft.approved_at.asc())
.limit(batch_size)
)
result = await db.execute(stmt)
drafts = list(result.scalars().all())
sent = 0
failed = 0
results = []
for draft in drafts:
send_result = {}
if channel == "whatsapp" and draft.contact_phone:
try:
from app.api.v1.outreach_engine import _send_via_ultramsg
r = await _send_via_ultramsg(draft.contact_phone, draft.body)
if "error" not in r:
send_result = {"status": "sent", "provider": "ultramsg", "result": r}
draft.status = "sent"
draft.sent_at = datetime.now(timezone.utc)
sent += 1
else:
send_result = {"status": "failed", "error": str(r)}
failed += 1
except Exception as exc:
send_result = {"status": "failed", "error": str(exc)[:100]}
failed += 1
elif channel == "email" and draft.contact_email:
try:
from app.integrations.email_sender import send_email
r = await send_email(draft.contact_email, draft.subject, draft.body)
send_result = {"status": "sent", "provider": "smtp", "result": r}
draft.status = "sent"
draft.sent_at = datetime.now(timezone.utc)
sent += 1
except Exception as exc:
send_result = {"status": "failed", "error": str(exc)[:100]}
failed += 1
elif channel == "sms" and draft.contact_phone:
try:
from app.integrations.sms import send_sms
r = await send_sms(draft.contact_phone, draft.body)
send_result = {"status": "sent", "provider": "unifonic", "result": r}
draft.status = "sent"
draft.sent_at = datetime.now(timezone.utc)
sent += 1
except Exception as exc:
send_result = {"status": "failed", "error": str(exc)[:100]}
failed += 1
elif channel == "linkedin":
send_result = {"status": "manual_required", "message": "Copy from dashboard and send on LinkedIn"}
results.append({
"id": str(draft.id),
"company": draft.company,
**send_result,
})
await db.commit()
return {
"channel": channel,
"batch_size": batch_size,
"sent": sent,
"failed": failed,
"manual": len([r for r in results if r.get("status") == "manual_required"]),
"results": results,
}
@router.post("/{draft_id}/log-reply")
async def log_reply(
draft_id: str, req: LogReplyRequest, db: AsyncSession = Depends(_get_db)
) -> Dict[str, Any]:
from app.models.outreach_draft import OutreachDraft
from app.api.v1.automation import classify_reply, ClassifyReplyRequest
result = await db.execute(
select(OutreachDraft).where(OutreachDraft.id == draft_id)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
classification = await classify_reply(
ClassifyReplyRequest(
reply_text=req.reply_text,
company=draft.company,
original_sector=draft.sector,
)
)
draft.status = "replied"
draft.replied_at = datetime.now(timezone.utc)
draft.reply_text = req.reply_text
draft.reply_category = classification["category"]
draft.next_action = classification["next_action"]
if classification["category"] == "unsubscribe":
draft.status = "opted_out"
draft.next_action = "suppressed — no further contact"
await db.commit()
return {
"id": str(draft.id),
"reply_category": classification["category"],
"suggested_response": classification["suggested_response"],
"next_action": classification["next_action"],
"auto_reply_allowed": classification["auto_reply_allowed"],
}