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:
2026-03-25 10:26:54 -06:00
parent c72beb916b
commit 56c11a0f1a
4 changed files with 706 additions and 0 deletions

View File

@@ -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",
]

View 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)