system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/sequences.py
Claude 141f10db76
feat: Add conversation intelligence, message writer, sales agent, APIs, and templates
Continuing Phase 3-6 implementation:

- AI: conversation_intelligence.py (Arabic dialogue analysis, buying signals)
- AI: message_writer.py (Arabic/English multi-channel message generation)
- AI: sales_agent.py (autonomous WhatsApp qualification bot)
- API: compliance.py (PDPL consent & data rights endpoints)
- API: inbox.py (unified multi-channel inbox)
- API: proposals.py (CPQ quote management endpoints)
- API: sequences.py (multi-channel sequence management)
- Services: territory_manager.py (Saudi region-based lead routing)
- Seeds: contracting_template.json (Saudi contracting industry template)
- Updated: router.py, consent_manager.py, data_rights.py

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
2026-04-11 07:43:11 +00:00

240 lines
7.4 KiB
Python

"""Sequences API -- create, manage, and analyze multi-channel outreach sequences."""
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel as Schema
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.sequence import Sequence, SequenceStep, SequenceEnrollment
from app.services.sequence_engine import (
SequenceEngine, SequenceCreateInput, EnrollInput, SequenceAnalytics,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sequences", tags=["Sequences"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class StepSchema(Schema):
channel: str
delay_minutes: int = 0
template_content: str
template_content_ar: Optional[str] = None
variant: Optional[str] = None
conditions: dict = {}
class SequenceCreateRequest(Schema):
name: str
name_ar: Optional[str] = None
description: Optional[str] = None
trigger_event: Optional[str] = None
steps: list[StepSchema] = []
class SequenceUpdateRequest(Schema):
name: Optional[str] = None
name_ar: Optional[str] = None
description: Optional[str] = None
trigger_event: Optional[str] = None
is_active: Optional[bool] = None
class SequenceResponse(Schema):
id: UUID
name: str
name_ar: Optional[str] = None
description: Optional[str] = None
trigger_event: Optional[str] = None
is_active: bool
created_at: object
step_count: int = 0
enrollment_count: int = 0
model_config = {"from_attributes": True}
class SequenceListResponse(Schema):
items: list[SequenceResponse]
total: int
class EnrollRequest(Schema):
lead_id: UUID
class EnrollmentResponse(Schema):
id: UUID
sequence_id: UUID
lead_id: UUID
current_step: int
status: str
enrolled_at: object
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=SequenceListResponse)
async def list_sequences(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List sequences with analytics summary."""
tid = current_user.tenant_id
total = (await db.execute(
select(func.count()).where(Sequence.tenant_id == tid)
)).scalar() or 0
rows = await db.execute(
select(Sequence)
.where(Sequence.tenant_id == tid)
.order_by(Sequence.created_at.desc())
.offset((page - 1) * per_page).limit(per_page)
)
sequences = rows.scalars().all()
items = []
for seq in sequences:
step_count = (await db.execute(
select(func.count()).where(SequenceStep.sequence_id == seq.id)
)).scalar() or 0
enroll_count = (await db.execute(
select(func.count()).where(SequenceEnrollment.sequence_id == seq.id)
)).scalar() or 0
resp = SequenceResponse.model_validate(seq)
resp.step_count = step_count
resp.enrollment_count = enroll_count
items.append(resp)
return SequenceListResponse(items=items, total=total)
@router.post("", response_model=SequenceResponse, status_code=status.HTTP_201_CREATED)
async def create_sequence(
data: SequenceCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new sequence with steps."""
engine = SequenceEngine(db)
seq = await engine.create_sequence(SequenceCreateInput(
tenant_id=current_user.tenant_id,
name=data.name,
name_ar=data.name_ar,
description=data.description,
trigger_event=data.trigger_event,
created_by=current_user.id,
steps=[s.model_dump() for s in data.steps],
))
resp = SequenceResponse.model_validate(seq)
resp.step_count = len(data.steps)
return resp
@router.put("/{sequence_id}", response_model=SequenceResponse)
async def update_sequence(
sequence_id: UUID,
data: SequenceUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update sequence metadata."""
seq = (await db.execute(
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
)).scalar_one_or_none()
if not seq:
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
for field, val in data.model_dump(exclude_none=True).items():
setattr(seq, field, val)
await db.flush()
await db.refresh(seq)
logger.info("Sequence updated: id=%s", sequence_id)
return SequenceResponse.model_validate(seq)
@router.post("/{sequence_id}/enroll", response_model=EnrollmentResponse, status_code=status.HTTP_201_CREATED)
async def enroll_lead(
sequence_id: UUID,
data: EnrollRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Enroll a lead into a sequence."""
seq = (await db.execute(
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
)).scalar_one_or_none()
if not seq:
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
if not seq.is_active:
raise HTTPException(status_code=400, detail="التسلسل غير نشط")
engine = SequenceEngine(db)
try:
enrollment = await engine.enroll_lead(EnrollInput(sequence_id=sequence_id, lead_id=data.lead_id))
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return EnrollmentResponse.model_validate(enrollment)
@router.delete("/{sequence_id}/enrollments/{enrollment_id}", status_code=status.HTTP_200_OK)
async def stop_enrollment(
sequence_id: UUID,
enrollment_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Stop an active enrollment."""
enrollment = (await db.execute(
select(SequenceEnrollment).where(
SequenceEnrollment.id == enrollment_id,
SequenceEnrollment.sequence_id == sequence_id,
)
)).scalar_one_or_none()
if not enrollment:
raise HTTPException(status_code=404, detail="التسجيل غير موجود")
engine = SequenceEngine(db)
await engine.stop_enrollment(enrollment_id)
return {"detail": "تم إيقاف التسجيل بنجاح", "enrollment_id": str(enrollment_id)}
@router.get("/{sequence_id}/analytics", response_model=SequenceAnalytics)
async def get_analytics(
sequence_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Detailed analytics for a sequence."""
seq = (await db.execute(
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
)).scalar_one_or_none()
if not seq:
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
engine = SequenceEngine(db)
return await engine.get_sequence_analytics(sequence_id)