Files
contexta_be/app/routers/inbox.py
belviskhoremk 92d4c2fc5e feat: add appointments, campaigns, admin, storage, tests and various updates
- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:11:58 +00:00

227 lines
8.0 KiB
Python

from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from app.models import InboxConversation, InboxMessage, ConversationStatusUpdate, AgentReplyCreate
from typing import List, Optional
import uuid
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/inbox", tags=["Inbox"])
def _check_inbox_access(user_id: str, supabase):
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
if plan not in ("starter", "business", "agency", "enterprise"):
raise HTTPException(status_code=402, detail="Conversation inbox requires Starter plan or higher")
return plan
def _get_user_company_id(user_id: str, supabase) -> str:
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Company not found")
return result.data[0]["id"]
@router.get("/conversations", response_model=List[InboxConversation])
async def list_inbox_conversations(
chatbot_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
user=Depends(get_current_user),
):
"""List conversations for all (or one) of the user's chatbots."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
# Get user's chatbots
chatbots_query = supabase.table("chatbots").select("id, name").eq("company_id", company_id)
if chatbot_id:
chatbots_query = chatbots_query.eq("id", chatbot_id)
chatbots_result = chatbots_query.execute()
chatbot_map = {c["id"]: c["name"] for c in (chatbots_result.data or [])}
if not chatbot_map:
return []
chatbot_ids = list(chatbot_map.keys())
offset = (page - 1) * limit
# Query conversations
convs = supabase.table("conversations").select("*") \
.in_("chatbot_id", chatbot_ids) \
.order("created_at", desc=True) \
.range(offset, offset + limit - 1) \
.execute()
results = []
for conv in (convs.data or []):
cid = conv["id"]
# Get first user message for preview
first_msg = supabase.table("messages").select("content") \
.eq("conversation_id", cid) \
.eq("role", "user") \
.order("created_at", desc=False) \
.limit(1) \
.execute()
first_message_text = first_msg.data[0]["content"] if first_msg.data else None
results.append(InboxConversation(
id=cid,
chatbot_id=conv["chatbot_id"],
chatbot_name=chatbot_map.get(conv["chatbot_id"], "Unknown"),
session_id=conv.get("session_id"),
language=conv.get("language", "en"),
message_count=conv.get("message_count", 0),
first_message=first_message_text,
status=conv.get("status", "open"),
last_agent_reply_at=conv.get("last_agent_reply_at"),
created_at=conv.get("created_at"),
))
return results
@router.get("/conversations/{conversation_id}")
async def get_inbox_conversation(
conversation_id: str,
user=Depends(get_current_user),
):
"""Get a full conversation thread with all messages."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
# Verify ownership
conv = supabase.table("conversations").select("*, chatbots(company_id, name)") \
.eq("id", conversation_id) \
.execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
c = conv.data[0]
chatbot_data = c.get("chatbots") or {}
if chatbot_data.get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
# Get messages
msgs = supabase.table("messages").select("*") \
.eq("conversation_id", conversation_id) \
.order("created_at", desc=False) \
.execute()
messages = [
InboxMessage(
id=m["id"],
role=m["role"],
content=m["content"],
sources=m.get("sources"),
confidence_score=m.get("confidence_score"),
is_handoff=m.get("is_handoff", False),
created_at=m.get("created_at"),
)
for m in (msgs.data or [])
]
return {
"conversation_id": conversation_id,
"chatbot_name": chatbot_data.get("name", "Unknown"),
"language": c.get("language", "en"),
"session_id": c.get("session_id"),
"created_at": c.get("created_at"),
"messages": [m.model_dump() for m in messages],
}
@router.patch("/conversations/{conversation_id}/status")
async def update_conversation_status(
conversation_id: str,
data: ConversationStatusUpdate,
user=Depends(get_current_user),
):
"""Update conversation status (open, agent_handling, resolved)."""
if data.status not in ("open", "agent_handling", "resolved"):
raise HTTPException(status_code=400, detail="Invalid status")
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
supabase.table("conversations").update({"status": data.status}).eq("id", conversation_id).execute()
return {"success": True, "status": data.status}
@router.post("/conversations/{conversation_id}/reply")
async def agent_reply(
conversation_id: str,
data: AgentReplyCreate,
user=Depends(get_current_user),
):
"""Send an agent reply to a conversation."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
msg_id = str(uuid.uuid4())
supabase.table("messages").insert({
"id": msg_id,
"conversation_id": conversation_id,
"role": "agent",
"content": data.message,
}).execute()
# Mark as agent_handling if not already, and record reply time
current_status = conv.data[0].get("status", "open")
update_data: dict = {"last_agent_reply_at": "now()"}
if current_status == "open":
update_data["status"] = "agent_handling"
supabase.table("conversations").update(update_data).eq("id", conversation_id).execute()
return {"success": True, "message_id": msg_id}
@router.delete("/conversations/{conversation_id}")
async def delete_inbox_conversation(
conversation_id: str,
user=Depends(get_current_user),
):
"""Delete a conversation and all its messages."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
# Verify ownership
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
chatbot_data = conv.data[0].get("chatbots") or {}
if chatbot_data.get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
supabase.table("conversations").delete().eq("id", conversation_id).execute()
return {"success": True}