feat(06-01): WebSocket endpoint, chat REST API, orchestrator wiring, gateway mounting
- Create gateway/channels/web.py with normalize_web_event() and /chat/ws/{conversation_id}
WebSocket endpoint (auth via first JSON message, typing indicator, Redis pub-sub response)
- Create shared/api/chat.py with GET/POST/DELETE /api/portal/chat/conversations* REST API
with require_tenant_member RBAC enforcement and RLS context var setup
- Add chat_router to shared/api/__init__.py exports
- Mount chat_router and web_chat_router in gateway/main.py (Phase 6 Web Chat routers)
- All 19 unit tests pass; full 313-test suite green
This commit is contained in:
@@ -6,6 +6,7 @@ Import and mount these routers in service main.py files.
|
||||
|
||||
from shared.api.billing import billing_router, webhook_router
|
||||
from shared.api.channels import channels_router
|
||||
from shared.api.chat import chat_router
|
||||
from shared.api.invitations import invitations_router
|
||||
from shared.api.llm_keys import llm_keys_router
|
||||
from shared.api.portal import portal_router
|
||||
@@ -21,4 +22,5 @@ __all__ = [
|
||||
"usage_router",
|
||||
"invitations_router",
|
||||
"templates_router",
|
||||
"chat_router",
|
||||
]
|
||||
|
||||
356
packages/shared/shared/api/chat.py
Normal file
356
packages/shared/shared/api/chat.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
FastAPI chat REST API — conversation CRUD with RBAC.
|
||||
|
||||
Provides conversation management for the Phase 6 web chat feature.
|
||||
All endpoints require portal authentication via X-Portal-User-Id headers
|
||||
and enforce tenant membership (or platform_admin bypass).
|
||||
|
||||
Endpoints:
|
||||
GET /api/portal/chat/conversations — list conversations
|
||||
POST /api/portal/chat/conversations — create or get-or-create
|
||||
GET /api/portal/chat/conversations/{id}/messages — paginated history
|
||||
DELETE /api/portal/chat/conversations/{id} — reset conversation
|
||||
|
||||
RBAC:
|
||||
- platform_admin: can access any tenant's conversations
|
||||
- customer_admin / customer_operator: must be a member of the target tenant
|
||||
- Other roles: 403
|
||||
|
||||
RLS:
|
||||
All DB queries set current_tenant_id context var before executing so
|
||||
PostgreSQL's FORCE ROW LEVEL SECURITY policy is applied automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.api.rbac import PortalCaller, get_portal_caller, require_tenant_member
|
||||
from shared.db import get_session, engine
|
||||
from shared.models.chat import WebConversation, WebConversationMessage
|
||||
from shared.models.tenant import Agent
|
||||
from shared.rls import configure_rls_hook, current_tenant_id
|
||||
|
||||
chat_router = APIRouter(prefix="/api/portal/chat", tags=["chat"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
agent_name: str | None = None
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_message_preview: str | None = None
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
tenant_id: uuid.UUID
|
||||
agent_id: uuid.UUID
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DeleteResult(BaseModel):
|
||||
deleted: bool
|
||||
conversation_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: configure RLS and set context var
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rls_set(engine_: Any, tenant_uuid: uuid.UUID) -> Any:
|
||||
"""Configure RLS hook and set the tenant context variable."""
|
||||
configure_rls_hook(engine_)
|
||||
return current_tenant_id.set(tenant_uuid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/portal/chat/conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@chat_router.get("/conversations", response_model=list[ConversationOut])
|
||||
async def list_conversations(
|
||||
tenant_id: uuid.UUID = Query(...),
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ConversationOut]:
|
||||
"""
|
||||
List conversations for the authenticated user within a tenant.
|
||||
|
||||
Platform admins can see all conversations for the tenant.
|
||||
Other users see only their own conversations.
|
||||
"""
|
||||
# RBAC — raises 403 if caller is not a member (platform_admin bypasses)
|
||||
await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
||||
|
||||
token = _rls_set(engine, tenant_id)
|
||||
try:
|
||||
stmt = (
|
||||
select(WebConversation, Agent.name.label("agent_name"))
|
||||
.join(Agent, WebConversation.agent_id == Agent.id, isouter=True)
|
||||
.where(WebConversation.tenant_id == tenant_id)
|
||||
)
|
||||
# Non-admins only see their own conversations
|
||||
if caller.role != "platform_admin":
|
||||
stmt = stmt.where(WebConversation.user_id == caller.user_id)
|
||||
|
||||
stmt = stmt.order_by(WebConversation.updated_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
conversations: list[ConversationOut] = []
|
||||
for row in rows:
|
||||
conv = row[0]
|
||||
agent_name = row[1] if len(row) > 1 else None
|
||||
conversations.append(
|
||||
ConversationOut(
|
||||
id=str(conv.id),
|
||||
tenant_id=str(conv.tenant_id),
|
||||
agent_id=str(conv.agent_id),
|
||||
agent_name=agent_name,
|
||||
user_id=str(conv.user_id),
|
||||
created_at=conv.created_at,
|
||||
updated_at=conv.updated_at,
|
||||
)
|
||||
)
|
||||
return conversations
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/portal/chat/conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@chat_router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_200_OK)
|
||||
async def create_conversation(
|
||||
body: ConversationCreate,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ConversationOut:
|
||||
"""
|
||||
Create or get an existing conversation for the caller + agent pair.
|
||||
|
||||
Uses get-or-create semantics: if a conversation already exists for this
|
||||
(tenant_id, agent_id, user_id) triple, it is returned rather than creating
|
||||
a duplicate.
|
||||
"""
|
||||
# RBAC
|
||||
await require_tenant_member(tenant_id=body.tenant_id, caller=caller, session=session)
|
||||
|
||||
token = _rls_set(engine, body.tenant_id)
|
||||
try:
|
||||
# Check for existing conversation
|
||||
existing_stmt = select(WebConversation).where(
|
||||
WebConversation.tenant_id == body.tenant_id,
|
||||
WebConversation.agent_id == body.agent_id,
|
||||
WebConversation.user_id == caller.user_id,
|
||||
)
|
||||
existing_result = await session.execute(existing_stmt)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
return ConversationOut(
|
||||
id=str(existing.id),
|
||||
tenant_id=str(existing.tenant_id),
|
||||
agent_id=str(existing.agent_id),
|
||||
user_id=str(existing.user_id),
|
||||
created_at=existing.created_at,
|
||||
updated_at=existing.updated_at,
|
||||
)
|
||||
|
||||
# Create new conversation
|
||||
new_conv = WebConversation(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=body.tenant_id,
|
||||
agent_id=body.agent_id,
|
||||
user_id=caller.user_id,
|
||||
)
|
||||
session.add(new_conv)
|
||||
try:
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
await session.refresh(new_conv)
|
||||
except IntegrityError:
|
||||
# Race condition: another request created it between our SELECT and INSERT
|
||||
await session.rollback()
|
||||
existing_result2 = await session.execute(existing_stmt)
|
||||
existing2 = existing_result2.scalar_one_or_none()
|
||||
if existing2 is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create conversation",
|
||||
)
|
||||
return ConversationOut(
|
||||
id=str(existing2.id),
|
||||
tenant_id=str(existing2.tenant_id),
|
||||
agent_id=str(existing2.agent_id),
|
||||
user_id=str(existing2.user_id),
|
||||
created_at=existing2.created_at,
|
||||
updated_at=existing2.updated_at,
|
||||
)
|
||||
|
||||
return ConversationOut(
|
||||
id=str(new_conv.id),
|
||||
tenant_id=str(new_conv.tenant_id),
|
||||
agent_id=str(new_conv.agent_id),
|
||||
user_id=str(new_conv.user_id),
|
||||
created_at=new_conv.created_at,
|
||||
updated_at=new_conv.updated_at,
|
||||
)
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/portal/chat/conversations/{id}/messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@chat_router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut])
|
||||
async def list_messages(
|
||||
conversation_id: uuid.UUID,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
before: str | None = Query(default=None),
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MessageOut]:
|
||||
"""
|
||||
Return paginated message history for a conversation.
|
||||
|
||||
Messages ordered by created_at ASC (oldest first).
|
||||
Cursor pagination via `before` parameter (message ID).
|
||||
|
||||
Ownership enforced: caller must own the conversation OR be platform_admin.
|
||||
"""
|
||||
# Fetch conversation first to verify ownership and get tenant_id
|
||||
conv_stmt = select(WebConversation).where(WebConversation.id == conversation_id)
|
||||
conv_result = await session.execute(conv_stmt)
|
||||
conversation = conv_result.scalar_one_or_none()
|
||||
|
||||
if conversation is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
# Ownership check: caller owns the conversation or is platform_admin
|
||||
if caller.role != "platform_admin" and conversation.user_id != caller.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have access to this conversation",
|
||||
)
|
||||
|
||||
token = _rls_set(engine, conversation.tenant_id)
|
||||
try:
|
||||
msg_stmt = (
|
||||
select(WebConversationMessage)
|
||||
.where(WebConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(WebConversationMessage.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if before:
|
||||
try:
|
||||
before_uuid = uuid.UUID(before)
|
||||
# Get the cursor message's created_at
|
||||
cursor_stmt = select(WebConversationMessage.created_at).where(
|
||||
WebConversationMessage.id == before_uuid
|
||||
)
|
||||
cursor_result = await session.execute(cursor_stmt)
|
||||
cursor_ts = cursor_result.scalar_one_or_none()
|
||||
if cursor_ts is not None:
|
||||
msg_stmt = msg_stmt.where(WebConversationMessage.created_at < cursor_ts)
|
||||
except (ValueError, AttributeError):
|
||||
pass # Invalid cursor — ignore and return from start
|
||||
|
||||
msg_result = await session.execute(msg_stmt)
|
||||
messages = msg_result.scalars().all()
|
||||
|
||||
return [
|
||||
MessageOut(
|
||||
id=str(m.id),
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/portal/chat/conversations/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@chat_router.delete("/conversations/{conversation_id}", response_model=DeleteResult)
|
||||
async def reset_conversation(
|
||||
conversation_id: uuid.UUID,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DeleteResult:
|
||||
"""
|
||||
Reset a conversation by deleting all messages.
|
||||
|
||||
The conversation row is kept but all messages are deleted.
|
||||
Updates updated_at on the conversation.
|
||||
|
||||
Ownership enforced: caller must own the conversation OR be platform_admin.
|
||||
"""
|
||||
# Fetch conversation
|
||||
conv_stmt = select(WebConversation).where(WebConversation.id == conversation_id)
|
||||
conv_result = await session.execute(conv_stmt)
|
||||
conversation = conv_result.scalar_one_or_none()
|
||||
|
||||
if conversation is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
# Ownership check
|
||||
if caller.role != "platform_admin" and conversation.user_id != caller.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have access to this conversation",
|
||||
)
|
||||
|
||||
token = _rls_set(engine, conversation.tenant_id)
|
||||
try:
|
||||
# Delete all messages for this conversation
|
||||
delete_stmt = delete(WebConversationMessage).where(
|
||||
WebConversationMessage.conversation_id == conversation_id
|
||||
)
|
||||
await session.execute(delete_stmt)
|
||||
|
||||
# Update conversation timestamp
|
||||
await session.execute(
|
||||
text("UPDATE web_conversations SET updated_at = NOW() WHERE id = :conv_id"),
|
||||
{"conv_id": str(conversation_id)},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return DeleteResult(deleted=True, conversation_id=str(conversation_id))
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
Reference in New Issue
Block a user