mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 23:39:34 +00:00
- 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
386 lines
13 KiB
Python
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"],
|
|
}
|