diff --git a/salesflow-saas/backend/app/api/v1/admin.py b/salesflow-saas/backend/app/api/v1/admin.py index 3270f565..e8cefbaa 100644 --- a/salesflow-saas/backend/app/api/v1/admin.py +++ b/salesflow-saas/backend/app/api/v1/admin.py @@ -240,3 +240,16 @@ async def dlq_purge(queue_name: str) -> dict: async def circuit_breaker_states() -> dict: from app.utils.circuit_breaker import registry return {"breakers": registry.all_states()} + + +# ── Outreach Stats ─────────────────────────────────────────────── + + +@router.get("/outreach/stats") +async def outreach_stats() -> dict: + try: + from app.api.v1.drafts import draft_stats, _get_db + async for db in _get_db(): + return await draft_stats(db) + except Exception: + return {"total": 0, "draft": 0, "approved": 0, "sent": 0, "replied": 0, "opted_out": 0, "bounced": 0, "skipped": 0} diff --git a/salesflow-saas/backend/app/api/v1/followups.py b/salesflow-saas/backend/app/api/v1/followups.py new file mode 100644 index 00000000..f9fe04ae --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/followups.py @@ -0,0 +1,162 @@ +"""Follow-up Scheduler — generates follow-up drafts for unreplied outreach. + +Checks sent drafts that haven't received replies after 2/5/10 days +and creates new follow-up drafts linked to the original. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List +from uuid import uuid4 + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger("dealix.followups") + +router = APIRouter(prefix="/followups", tags=["Follow-ups"]) + + +async def _get_db(): + from app.database import get_db + async for session in get_db(): + yield session + + +@router.get("/due") +async def list_due_followups( + days_since_sent: int = Query(2, ge=1, le=30), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(_get_db), +) -> Dict[str, Any]: + from app.models.outreach_draft import OutreachDraft + + cutoff = datetime.now(timezone.utc) - timedelta(days=days_since_sent) + stmt = ( + select(OutreachDraft) + .where( + and_( + OutreachDraft.status == "sent", + OutreachDraft.sent_at <= cutoff, + OutreachDraft.reply_text.is_(None), + ) + ) + .order_by(OutreachDraft.sent_at.asc()) + .limit(limit) + ) + result = await db.execute(stmt) + rows = list(result.scalars().all()) + + due = [] + for row in rows: + days_elapsed = (datetime.now(timezone.utc) - row.sent_at).days if row.sent_at else 0 + followup_text = "" + followup_type = "" + if days_elapsed >= 10: + followup_text = row.followup_5d or "آخر متابعة — لو مو الوقت المناسب أفهم تماماً. شكراً." + followup_type = "day_10_breakup" + elif days_elapsed >= 5: + followup_text = row.followup_5d or row.followup_2d or "" + followup_type = "day_5_value" + elif days_elapsed >= 2: + followup_text = row.followup_2d or "" + followup_type = "day_2_reminder" + + if followup_text: + due.append({ + "original_draft_id": str(row.id), + "company": row.company, + "channel": row.channel, + "contact_email": row.contact_email, + "contact_phone": row.contact_phone, + "days_since_sent": days_elapsed, + "followup_type": followup_type, + "followup_text": followup_text, + "sector": row.sector, + }) + + return { + "due_count": len(due), + "cutoff_days": days_since_sent, + "followups": due, + } + + +class GenerateFollowupsRequest(BaseModel): + days_since_sent: int = 2 + max_followups: int = 20 + + +@router.post("/generate") +async def generate_followup_drafts( + req: GenerateFollowupsRequest, + db: AsyncSession = Depends(_get_db), +) -> Dict[str, Any]: + from app.models.outreach_draft import OutreachDraft + + cutoff = datetime.now(timezone.utc) - timedelta(days=req.days_since_sent) + stmt = ( + select(OutreachDraft) + .where( + and_( + OutreachDraft.status == "sent", + OutreachDraft.sent_at <= cutoff, + OutreachDraft.reply_text.is_(None), + ) + ) + .order_by(OutreachDraft.sent_at.asc()) + .limit(req.max_followups) + ) + result = await db.execute(stmt) + originals = list(result.scalars().all()) + + created = 0 + batch_id = f"followup_{datetime.now(timezone.utc).strftime('%Y%m%d')}_{str(uuid4())[:6]}" + + for orig in originals: + days_elapsed = (datetime.now(timezone.utc) - orig.sent_at).days if orig.sent_at else 0 + if days_elapsed >= 10: + body = orig.followup_5d or "آخر متابعة — لو مناسب نتكلم، أنا موجود. لو لا، شكراً على وقتكم." + subject = f"متابعة أخيرة: {orig.company}" + elif days_elapsed >= 5: + body = orig.followup_5d or orig.followup_2d or "" + subject = f"متابعة: {orig.company}" + else: + body = orig.followup_2d or "" + subject = f"متابعة سريعة: {orig.company}" + + if not body: + continue + + followup = OutreachDraft( + batch_id=batch_id, + company=orig.company, + contact_name=orig.contact_name, + contact_email=orig.contact_email, + contact_phone=orig.contact_phone, + channel=orig.channel, + subject=subject, + body=body, + sector=orig.sector, + city=orig.city, + fit_score=orig.fit_score, + risk_score=orig.risk_score, + status="draft", + approval_required=True, + source=f"followup_day_{days_elapsed}_of_{str(orig.id)[:8]}", + ) + db.add(followup) + created += 1 + + if created: + await db.commit() + + return { + "batch_id": batch_id, + "followups_created": created, + "originals_checked": len(originals), + } diff --git a/salesflow-saas/backend/app/api/v1/router.py b/salesflow-saas/backend/app/api/v1/router.py index 0806c2fd..29f1706f 100644 --- a/salesflow-saas/backend/app/api/v1/router.py +++ b/salesflow-saas/backend/app/api/v1/router.py @@ -144,3 +144,7 @@ api_router.include_router(automation_router.router) # ── Draft Queue — review, approve, send outreach drafts ──────── from app.api.v1 import drafts as drafts_router api_router.include_router(drafts_router.router) + +# ── Follow-ups — auto-generate follow-up drafts for unreplied ── +from app.api.v1 import followups as followups_router +api_router.include_router(followups_router.router) diff --git a/salesflow-saas/backend/tests/test_automation_system.py b/salesflow-saas/backend/tests/test_automation_system.py new file mode 100644 index 00000000..4d6cd68d --- /dev/null +++ b/salesflow-saas/backend/tests/test_automation_system.py @@ -0,0 +1,175 @@ +"""Tests for the full automation outreach system — drafts, pipeline, followups.""" + +import pytest +from fastapi.testclient import TestClient +from fastapi import FastAPI + + +@pytest.fixture +def app(): + app = FastAPI() + from app.api.v1.automation import router as auto_router + app.include_router(auto_router) + return app + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def test_email_generate(client): + resp = client.post("/automation/email/generate", json={ + "company": "Foodics", + "sector": "saas", + "city": "الرياض", + "contact_name": "أحمد", + }) + assert resp.status_code == 200 + data = resp.json() + assert "subject_ar" in data + assert "body_ar" in data + assert "followup_day_2" in data + assert "followup_day_5" in data + assert "call_script_ar" in data + assert "linkedin_manual_message" in data + assert data["opt_out_included"] is True + assert data["word_count"] > 0 + assert "Foodics" in data["subject_ar"] + + +def test_email_generate_with_signals(client): + resp = client.post("/automation/email/generate", json={ + "company": "TestCo", + "sector": "real_estate", + "signals": ["hubspot"], + }) + data = resp.json() + assert "HubSpot" in data["body_ar"] + + +def test_compliance_check_allowed(client): + resp = client.post("/automation/compliance/check", json={ + "email": "ahmed@company.sa", + "company": "TestCo", + "source": "linkedin", + }) + data = resp.json() + assert data["allowed"] is True + assert data["reason"] == "compliant" + + +def test_compliance_check_opt_out(client): + resp = client.post("/automation/compliance/check", json={ + "email": "ahmed@company.sa", + "opt_out": True, + }) + data = resp.json() + assert data["allowed"] is False + assert data["reason"] == "opt_out" + + +def test_compliance_check_bounced(client): + resp = client.post("/automation/compliance/check", json={ + "email": "bad@company.sa", + "bounced_before": True, + }) + data = resp.json() + assert data["allowed"] is False + assert data["reason"] == "bounced_before" + + +def test_compliance_check_high_risk(client): + resp = client.post("/automation/compliance/check", json={ + "email": "ceo@big.sa", + "risk_score": 80, + }) + data = resp.json() + assert data["allowed"] is False + assert data["reason"] == "high_risk" + + +def test_compliance_check_personal_email(client): + resp = client.post("/automation/compliance/check", json={ + "email": "ahmed@gmail.com", + "source": "linkedin", + }) + data = resp.json() + assert data["allowed"] is True + assert "personal_email" in data["reason"] + + +def test_compliance_check_no_source(client): + resp = client.post("/automation/compliance/check", json={ + "email": "ahmed@company.sa", + "source": "", + }) + data = resp.json() + assert data["allowed"] is False + assert data["reason"] == "no_source" + + +def test_reply_classify_interested(client): + resp = client.post("/automation/reply/classify", json={ + "reply_text": "مهتم جداً، أبي أجرب", + "company": "TestCo", + }) + data = resp.json() + assert data["category"] == "interested" + assert data["auto_reply_allowed"] is True + assert "calendly" in data["suggested_response"].lower() or "demo" in data["suggested_response"].lower() or "20 دقيقة" in data["suggested_response"] + + +def test_reply_classify_price(client): + resp = client.post("/automation/reply/classify", json={ + "reply_text": "كم السعر؟", + }) + data = resp.json() + assert data["category"] == "ask_price" + assert "499" in data["suggested_response"] + + +def test_reply_classify_unsubscribe(client): + resp = client.post("/automation/reply/classify", json={ + "reply_text": "إيقاف لا تتواصل معي", + }) + data = resp.json() + assert data["category"] == "unsubscribe" + assert data["auto_reply_allowed"] is False + + +def test_reply_classify_crm(client): + resp = client.post("/automation/reply/classify", json={ + "reply_text": "عندنا CRM وما نحتاج نظام ثاني", + }) + data = resp.json() + assert data["category"] == "already_has_crm" + assert "طبقة" in data["suggested_response"] or "CRM" in data["suggested_response"] + + +def test_daily_targeting_generate(client): + resp = client.post("/automation/daily-targeting/generate", json={ + "sectors": ["real_estate", "construction"], + "cities": ["الرياض"], + "daily_target_count": 5, + }) + data = resp.json() + assert data["total_generated"] > 0 + assert len(data["targets"]) <= 5 + assert data["targets"][0]["sector"] in ("real_estate", "construction") + assert data["approval_required"] is True + + +def test_sector_pain_map_coverage(client): + """Verify all 9 sectors produce valid emails.""" + sectors = ["real_estate", "construction", "hospitality", "food_beverage", + "logistics", "agency", "saas", "healthcare", "education"] + for sector in sectors: + resp = client.post("/automation/email/generate", json={ + "company": f"Test_{sector}", + "sector": sector, + }) + assert resp.status_code == 200, f"Failed for sector: {sector}" + data = resp.json() + assert len(data["body_ar"]) > 50, f"Empty body for {sector}" + assert "إيقاف" in data["body_ar"], f"Missing opt-out for {sector}"