2026-03-05 09:38:46 +00:00
|
|
|
"""
|
|
|
|
|
Share Controller for Mid Platform.
|
|
|
|
|
[AC-IDMP-SHARE] Share session via unique token with expiration and concurrent user limits.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from typing import Annotated
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
|
|
|
|
from sqlalchemy import and_, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
from app.core.database import get_session
|
|
|
|
|
from app.core.tenant import get_tenant_id
|
|
|
|
|
from app.models.entities import ChatMessage, SharedSession
|
|
|
|
|
from app.models.mid.schemas import (
|
|
|
|
|
CreateShareRequest,
|
|
|
|
|
ShareListResponse,
|
|
|
|
|
ShareListItem,
|
|
|
|
|
ShareResponse,
|
|
|
|
|
SharedMessageRequest,
|
|
|
|
|
SharedSessionInfo,
|
|
|
|
|
HistoryMessage,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter(prefix="/mid", tags=["Mid Platform Share"])
|
|
|
|
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _utcnow() -> datetime:
|
2026-03-05 17:07:46 +00:00
|
|
|
"""Get current UTC time without timezone info (for DB compatibility)."""
|
|
|
|
|
return datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_datetime(dt: datetime) -> datetime:
|
|
|
|
|
"""Normalize datetime to offset-naive UTC for comparison."""
|
|
|
|
|
if dt.tzinfo is not None:
|
|
|
|
|
return dt.replace(tzinfo=None)
|
|
|
|
|
return dt
|
2026-03-05 09:38:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_share_url(share_token: str) -> str:
|
|
|
|
|
"""Generate full share URL from token."""
|
|
|
|
|
base_url = getattr(settings, 'frontend_base_url', 'http://localhost:3000')
|
|
|
|
|
return f"{base_url}/share/{share_token}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/sessions/{session_id}/share",
|
|
|
|
|
operation_id="createShare",
|
|
|
|
|
summary="Create a share link for session",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Create a share link for a chat session.
|
|
|
|
|
|
|
|
|
|
Returns share_token and share_url for accessing the shared conversation.
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Share created successfully", "model": ShareResponse},
|
|
|
|
|
400: {"description": "Invalid request"},
|
|
|
|
|
404: {"description": "Session not found"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def create_share(
|
|
|
|
|
session_id: Annotated[str, Path(description="Session ID to share")],
|
|
|
|
|
request: CreateShareRequest,
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> ShareResponse:
|
|
|
|
|
"""Create a share link for a session."""
|
|
|
|
|
tenant_id = get_tenant_id()
|
|
|
|
|
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
from app.core.exceptions import MissingTenantIdException
|
|
|
|
|
raise MissingTenantIdException()
|
|
|
|
|
|
|
|
|
|
share_token = str(uuid.uuid4())
|
|
|
|
|
expires_at = _utcnow() + timedelta(days=request.expires_in_days)
|
|
|
|
|
|
|
|
|
|
shared_session = SharedSession(
|
|
|
|
|
share_token=share_token,
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
title=request.title,
|
|
|
|
|
description=request.description,
|
|
|
|
|
expires_at=expires_at,
|
|
|
|
|
max_concurrent_users=request.max_concurrent_users,
|
|
|
|
|
current_users=0,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
db.add(shared_session)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(shared_session)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"[AC-IDMP-SHARE] Share created: tenant={tenant_id}, "
|
|
|
|
|
f"session={session_id}, token={share_token}, expires={expires_at}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ShareResponse(
|
|
|
|
|
share_token=share_token,
|
|
|
|
|
share_url=_generate_share_url(share_token),
|
|
|
|
|
expires_at=expires_at.isoformat(),
|
|
|
|
|
title=request.title,
|
|
|
|
|
description=request.description,
|
|
|
|
|
max_concurrent_users=request.max_concurrent_users,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/share/{share_token}",
|
|
|
|
|
operation_id="getSharedSession",
|
|
|
|
|
summary="Get shared session info",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Get shared session information by share token.
|
|
|
|
|
|
|
|
|
|
Returns session info with history messages. Public endpoint (no tenant required).
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Shared session info", "model": SharedSessionInfo},
|
|
|
|
|
404: {"description": "Share not found or expired"},
|
|
|
|
|
410: {"description": "Share expired or inactive"},
|
|
|
|
|
429: {"description": "Too many concurrent users"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def get_shared_session(
|
|
|
|
|
share_token: Annotated[str, Path(description="Share token")],
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> SharedSessionInfo:
|
|
|
|
|
"""Get shared session info by token (public endpoint)."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(SharedSession).where(SharedSession.share_token == share_token)
|
|
|
|
|
)
|
|
|
|
|
shared = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not shared:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
|
|
|
|
|
|
|
|
if not shared.is_active:
|
|
|
|
|
raise HTTPException(status_code=410, detail="Share is inactive")
|
|
|
|
|
|
2026-03-05 17:07:46 +00:00
|
|
|
if _normalize_datetime(shared.expires_at) < _utcnow():
|
2026-03-05 09:38:46 +00:00
|
|
|
raise HTTPException(status_code=410, detail="Share has expired")
|
|
|
|
|
|
|
|
|
|
if shared.current_users >= shared.max_concurrent_users:
|
|
|
|
|
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
|
|
|
|
|
|
|
|
|
|
messages_result = await db.execute(
|
|
|
|
|
select(ChatMessage)
|
|
|
|
|
.where(
|
|
|
|
|
and_(
|
|
|
|
|
ChatMessage.tenant_id == shared.tenant_id,
|
|
|
|
|
ChatMessage.session_id == shared.session_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.order_by(ChatMessage.created_at.asc())
|
|
|
|
|
)
|
|
|
|
|
messages = messages_result.scalars().all()
|
|
|
|
|
|
|
|
|
|
history = [
|
|
|
|
|
HistoryMessage(role=msg.role, content=msg.content)
|
|
|
|
|
for msg in messages
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return SharedSessionInfo(
|
|
|
|
|
session_id=shared.session_id,
|
|
|
|
|
title=shared.title,
|
|
|
|
|
description=shared.description,
|
|
|
|
|
expires_at=shared.expires_at.isoformat(),
|
|
|
|
|
max_concurrent_users=shared.max_concurrent_users,
|
|
|
|
|
current_users=shared.current_users,
|
|
|
|
|
history=history,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/sessions/{session_id}/shares",
|
|
|
|
|
operation_id="listShares",
|
|
|
|
|
summary="List all shares for a session",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] List all share links for a session.
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "List of shares", "model": ShareListResponse},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def list_shares(
|
|
|
|
|
session_id: Annotated[str, Path(description="Session ID")],
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
include_expired: Annotated[bool, Query(description="Include expired shares")] = False,
|
|
|
|
|
) -> ShareListResponse:
|
|
|
|
|
"""List all shares for a session."""
|
|
|
|
|
tenant_id = get_tenant_id()
|
|
|
|
|
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
from app.core.exceptions import MissingTenantIdException
|
|
|
|
|
raise MissingTenantIdException()
|
|
|
|
|
|
|
|
|
|
query = select(SharedSession).where(
|
|
|
|
|
and_(
|
|
|
|
|
SharedSession.tenant_id == tenant_id,
|
|
|
|
|
SharedSession.session_id == session_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
now = _utcnow()
|
|
|
|
|
if not include_expired:
|
|
|
|
|
query = query.where(SharedSession.expires_at > now)
|
|
|
|
|
|
|
|
|
|
query = query.order_by(SharedSession.created_at.desc())
|
|
|
|
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
shares = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
items = [
|
|
|
|
|
ShareListItem(
|
|
|
|
|
share_token=s.share_token,
|
|
|
|
|
share_url=_generate_share_url(s.share_token),
|
|
|
|
|
title=s.title,
|
|
|
|
|
description=s.description,
|
|
|
|
|
expires_at=s.expires_at.isoformat(),
|
2026-03-05 17:07:46 +00:00
|
|
|
is_active=s.is_active and _normalize_datetime(s.expires_at) > now,
|
2026-03-05 09:38:46 +00:00
|
|
|
max_concurrent_users=s.max_concurrent_users,
|
|
|
|
|
current_users=s.current_users,
|
|
|
|
|
created_at=s.created_at.isoformat(),
|
|
|
|
|
)
|
|
|
|
|
for s in shares
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return ShareListResponse(shares=items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/share/{share_token}",
|
|
|
|
|
operation_id="deleteShare",
|
|
|
|
|
summary="Delete a share",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Delete (deactivate) a share link.
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Share deleted"},
|
|
|
|
|
404: {"description": "Share not found"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def delete_share(
|
|
|
|
|
share_token: Annotated[str, Path(description="Share token")],
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Delete a share (set is_active=False)."""
|
|
|
|
|
tenant_id = get_tenant_id()
|
|
|
|
|
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
from app.core.exceptions import MissingTenantIdException
|
|
|
|
|
raise MissingTenantIdException()
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(SharedSession).where(
|
|
|
|
|
and_(
|
|
|
|
|
SharedSession.share_token == share_token,
|
|
|
|
|
SharedSession.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
shared = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not shared:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
|
|
|
|
|
|
|
|
shared.is_active = False
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
logger.info(f"[AC-IDMP-SHARE] Share deleted: token={share_token}")
|
|
|
|
|
|
|
|
|
|
return {"success": True, "message": "Share deleted"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/share/{share_token}/join",
|
|
|
|
|
operation_id="joinSharedSession",
|
|
|
|
|
summary="Join a shared session",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Join a shared session (increment current_users).
|
|
|
|
|
|
|
|
|
|
Returns updated session info.
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Joined successfully", "model": SharedSessionInfo},
|
|
|
|
|
404: {"description": "Share not found"},
|
|
|
|
|
410: {"description": "Share expired or inactive"},
|
|
|
|
|
429: {"description": "Too many concurrent users"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def join_shared_session(
|
|
|
|
|
share_token: Annotated[str, Path(description="Share token")],
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> SharedSessionInfo:
|
|
|
|
|
"""Join a shared session (increment current_users)."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(SharedSession).where(SharedSession.share_token == share_token)
|
|
|
|
|
)
|
|
|
|
|
shared = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not shared:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
|
|
|
|
|
|
|
|
if not shared.is_active:
|
|
|
|
|
raise HTTPException(status_code=410, detail="Share is inactive")
|
|
|
|
|
|
2026-03-05 17:07:46 +00:00
|
|
|
if _normalize_datetime(shared.expires_at) < _utcnow():
|
2026-03-05 09:38:46 +00:00
|
|
|
raise HTTPException(status_code=410, detail="Share has expired")
|
|
|
|
|
|
|
|
|
|
if shared.current_users >= shared.max_concurrent_users:
|
|
|
|
|
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
|
|
|
|
|
|
|
|
|
|
shared.current_users += 1
|
|
|
|
|
shared.updated_at = _utcnow()
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
messages_result = await db.execute(
|
|
|
|
|
select(ChatMessage)
|
|
|
|
|
.where(
|
|
|
|
|
and_(
|
|
|
|
|
ChatMessage.tenant_id == shared.tenant_id,
|
|
|
|
|
ChatMessage.session_id == shared.session_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.order_by(ChatMessage.created_at.asc())
|
|
|
|
|
)
|
|
|
|
|
messages = messages_result.scalars().all()
|
|
|
|
|
|
|
|
|
|
history = [
|
|
|
|
|
HistoryMessage(role=msg.role, content=msg.content)
|
|
|
|
|
for msg in messages
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
logger.info(f"[AC-IDMP-SHARE] User joined: token={share_token}, users={shared.current_users}")
|
|
|
|
|
|
|
|
|
|
return SharedSessionInfo(
|
|
|
|
|
session_id=shared.session_id,
|
|
|
|
|
title=shared.title,
|
|
|
|
|
description=shared.description,
|
|
|
|
|
expires_at=shared.expires_at.isoformat(),
|
|
|
|
|
max_concurrent_users=shared.max_concurrent_users,
|
|
|
|
|
current_users=shared.current_users,
|
|
|
|
|
history=history,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/share/{share_token}/leave",
|
|
|
|
|
operation_id="leaveSharedSession",
|
|
|
|
|
summary="Leave a shared session",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Leave a shared session (decrement current_users).
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Left successfully"},
|
|
|
|
|
404: {"description": "Share not found"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def leave_shared_session(
|
|
|
|
|
share_token: Annotated[str, Path(description="Share token")],
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Leave a shared session (decrement current_users)."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(SharedSession).where(SharedSession.share_token == share_token)
|
|
|
|
|
)
|
|
|
|
|
shared = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not shared:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
|
|
|
|
|
|
|
|
if shared.current_users > 0:
|
|
|
|
|
shared.current_users -= 1
|
|
|
|
|
shared.updated_at = _utcnow()
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
logger.info(f"[AC-IDMP-SHARE] User left: token={share_token}, users={shared.current_users}")
|
|
|
|
|
|
|
|
|
|
return {"success": True, "current_users": shared.current_users}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/share/{share_token}/message",
|
|
|
|
|
operation_id="sendSharedMessage",
|
|
|
|
|
summary="Send message via shared session",
|
|
|
|
|
description="""
|
|
|
|
|
[AC-IDMP-SHARE] Send a message via shared session.
|
|
|
|
|
|
|
|
|
|
Creates a new message in the shared session and returns the response.
|
|
|
|
|
""",
|
|
|
|
|
responses={
|
|
|
|
|
200: {"description": "Message sent successfully"},
|
|
|
|
|
404: {"description": "Share not found"},
|
|
|
|
|
410: {"description": "Share expired or inactive"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def send_shared_message(
|
|
|
|
|
share_token: Annotated[str, Path(description="Share token")],
|
|
|
|
|
request: SharedMessageRequest,
|
|
|
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Send a message via shared session."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(SharedSession).where(SharedSession.share_token == share_token)
|
|
|
|
|
)
|
|
|
|
|
shared = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not shared:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
|
|
|
|
|
|
|
|
if not shared.is_active:
|
|
|
|
|
raise HTTPException(status_code=410, detail="Share is inactive")
|
|
|
|
|
|
2026-03-05 17:07:46 +00:00
|
|
|
if _normalize_datetime(shared.expires_at) < _utcnow():
|
2026-03-05 09:38:46 +00:00
|
|
|
raise HTTPException(status_code=410, detail="Share has expired")
|
|
|
|
|
|
|
|
|
|
user_message = ChatMessage(
|
|
|
|
|
tenant_id=shared.tenant_id,
|
|
|
|
|
session_id=shared.session_id,
|
|
|
|
|
role="user",
|
|
|
|
|
content=request.user_message,
|
|
|
|
|
)
|
|
|
|
|
db.add(user_message)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"[AC-IDMP-SHARE] Message sent via share: token={share_token}, "
|
|
|
|
|
f"session={shared.session_id}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Message sent successfully",
|
|
|
|
|
"session_id": shared.session_id,
|
|
|
|
|
}
|