""" 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: """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 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") if _normalize_datetime(shared.expires_at) < _utcnow(): 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(), is_active=s.is_active and _normalize_datetime(s.expires_at) > now, 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") if _normalize_datetime(shared.expires_at) < _utcnow(): 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") if _normalize_datetime(shared.expires_at) < _utcnow(): 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, }