mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
250 lines
8.0 KiB
Python
250 lines
8.0 KiB
Python
"""Unified inbox API -- aggregate messages from WhatsApp, Email, SMS."""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
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, and_
|
|
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.message import Message
|
|
from app.models.lead import Lead
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/inbox", tags=["Inbox"])
|
|
|
|
|
|
class MessageResponse(Schema):
|
|
id: UUID
|
|
lead_id: Optional[UUID] = None
|
|
channel: str
|
|
direction: str
|
|
content: Optional[str] = None
|
|
status: str
|
|
sent_at: Optional[datetime] = None
|
|
created_at: datetime
|
|
extra_metadata: Optional[dict] = None
|
|
lead_name: Optional[str] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class MessageListResponse(Schema):
|
|
items: list[MessageResponse]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
|
|
|
|
class ThreadResponse(Schema):
|
|
lead_id: UUID
|
|
lead_name: Optional[str] = None
|
|
messages: list[MessageResponse]
|
|
|
|
|
|
class ReplyInput(Schema):
|
|
lead_id: UUID
|
|
channel: str
|
|
content: str
|
|
|
|
|
|
class AssignInput(Schema):
|
|
lead_id: UUID
|
|
assigned_to: UUID
|
|
|
|
|
|
class InboxStats(Schema):
|
|
total_unread: int
|
|
whatsapp_unread: int
|
|
email_unread: int
|
|
sms_unread: int
|
|
avg_response_minutes: Optional[float] = None
|
|
|
|
|
|
@router.get("", response_model=MessageListResponse)
|
|
async def list_inbox(
|
|
channel: Optional[str] = Query(None),
|
|
status_filter: Optional[str] = Query(None, alias="status"),
|
|
assigned_to: Optional[UUID] = Query(None),
|
|
search: Optional[str] = Query(None),
|
|
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 all messages across channels with filters."""
|
|
|
|
query = select(Message).where(Message.tenant_id == current_user.tenant_id)
|
|
|
|
if channel:
|
|
query = query.where(Message.channel == channel)
|
|
if status_filter:
|
|
query = query.where(Message.status == status_filter)
|
|
if assigned_to:
|
|
query = query.join(Lead, Lead.id == Message.lead_id).where(Lead.assigned_to == assigned_to)
|
|
if search:
|
|
query = query.where(Message.content.ilike(f"%{search}%"))
|
|
|
|
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0
|
|
rows = await db.execute(
|
|
query.order_by(Message.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
|
)
|
|
messages = rows.scalars().all()
|
|
|
|
# Batch-load lead names
|
|
lead_ids = {m.lead_id for m in messages if m.lead_id}
|
|
lead_map: dict[UUID, str] = {}
|
|
if lead_ids:
|
|
leads_q = await db.execute(select(Lead.id, Lead.name).where(Lead.id.in_(lead_ids)))
|
|
lead_map = {row[0]: row[1] for row in leads_q.all()}
|
|
|
|
items = []
|
|
for m in messages:
|
|
resp = MessageResponse.model_validate(m)
|
|
resp.lead_name = lead_map.get(m.lead_id)
|
|
items.append(resp)
|
|
|
|
return MessageListResponse(items=items, total=total, page=page, per_page=per_page)
|
|
|
|
|
|
@router.get("/stats", response_model=InboxStats)
|
|
async def inbox_stats(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Unread counts per channel and response-time metrics."""
|
|
|
|
tid = current_user.tenant_id
|
|
base = and_(Message.tenant_id == tid, Message.direction == "inbound", Message.status != "read")
|
|
|
|
total = (await db.execute(select(func.count()).where(base))).scalar() or 0
|
|
wa = (await db.execute(
|
|
select(func.count()).where(base, Message.channel == "whatsapp")
|
|
)).scalar() or 0
|
|
em = (await db.execute(
|
|
select(func.count()).where(base, Message.channel == "email")
|
|
)).scalar() or 0
|
|
sm = (await db.execute(
|
|
select(func.count()).where(base, Message.channel == "sms")
|
|
)).scalar() or 0
|
|
|
|
return InboxStats(
|
|
total_unread=total,
|
|
whatsapp_unread=wa,
|
|
email_unread=em,
|
|
sms_unread=sm,
|
|
avg_response_minutes=None,
|
|
)
|
|
|
|
|
|
@router.get("/{message_id}", response_model=ThreadResponse)
|
|
async def get_thread(
|
|
message_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get full conversation thread for a message."""
|
|
|
|
msg = (await db.execute(
|
|
select(Message).where(Message.id == message_id, Message.tenant_id == current_user.tenant_id)
|
|
)).scalar_one_or_none()
|
|
if not msg:
|
|
raise HTTPException(status_code=404, detail="الرسالة غير موجودة")
|
|
|
|
if not msg.lead_id:
|
|
return ThreadResponse(lead_id=message_id, messages=[MessageResponse.model_validate(msg)])
|
|
|
|
lead = (await db.execute(select(Lead).where(Lead.id == msg.lead_id))).scalar_one_or_none()
|
|
thread_q = await db.execute(
|
|
select(Message)
|
|
.where(Message.lead_id == msg.lead_id, Message.tenant_id == current_user.tenant_id)
|
|
.order_by(Message.created_at.asc())
|
|
)
|
|
messages = [MessageResponse.model_validate(m) for m in thread_q.scalars().all()]
|
|
|
|
return ThreadResponse(
|
|
lead_id=msg.lead_id,
|
|
lead_name=lead.name if lead else None,
|
|
messages=messages,
|
|
)
|
|
|
|
|
|
@router.post("/reply", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
|
async def reply_message(
|
|
data: ReplyInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Reply to a conversation -- auto-routes to the correct channel."""
|
|
|
|
lead = (await db.execute(
|
|
select(Lead).where(Lead.id == data.lead_id, Lead.tenant_id == current_user.tenant_id)
|
|
)).scalar_one_or_none()
|
|
if not lead:
|
|
raise HTTPException(status_code=404, detail="العميل المحتمل غير موجود")
|
|
|
|
if data.channel not in ("whatsapp", "email", "sms"):
|
|
raise HTTPException(status_code=400, detail="قناة غير مدعومة")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
message = Message(
|
|
tenant_id=current_user.tenant_id,
|
|
lead_id=data.lead_id,
|
|
channel=data.channel,
|
|
direction="outbound",
|
|
content=data.content,
|
|
status="pending",
|
|
sent_at=now,
|
|
extra_metadata={"sent_by": str(current_user.id)},
|
|
)
|
|
db.add(message)
|
|
await db.flush()
|
|
await db.refresh(message)
|
|
|
|
logger.info("Inbox reply sent: lead=%s channel=%s by=%s", data.lead_id, data.channel, current_user.id)
|
|
return MessageResponse.model_validate(message)
|
|
|
|
|
|
@router.post("/assign", status_code=status.HTTP_200_OK)
|
|
async def assign_conversation(
|
|
data: AssignInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Assign a conversation (lead) to a team member."""
|
|
|
|
lead = (await db.execute(
|
|
select(Lead).where(Lead.id == data.lead_id, Lead.tenant_id == current_user.tenant_id)
|
|
)).scalar_one_or_none()
|
|
if not lead:
|
|
raise HTTPException(status_code=404, detail="العميل المحتمل غير موجود")
|
|
|
|
lead.assigned_to = data.assigned_to
|
|
await db.flush()
|
|
logger.info("Conversation assigned: lead=%s to=%s", data.lead_id, data.assigned_to)
|
|
return {"detail": "تم تعيين المحادثة بنجاح", "lead_id": str(data.lead_id), "assigned_to": str(data.assigned_to)}
|
|
|
|
|
|
@router.put("/{message_id}/read", status_code=status.HTTP_200_OK)
|
|
async def mark_read(
|
|
message_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Mark a message as read."""
|
|
|
|
msg = (await db.execute(
|
|
select(Message).where(Message.id == message_id, Message.tenant_id == current_user.tenant_id)
|
|
)).scalar_one_or_none()
|
|
if not msg:
|
|
raise HTTPException(status_code=404, detail="الرسالة غير موجودة")
|
|
|
|
msg.status = "read"
|
|
await db.flush()
|
|
return {"detail": "تم تحديد الرسالة كمقروءة", "message_id": str(message_id)}
|