diff --git a/ai-service/app/api/mid/share.py b/ai-service/app/api/mid/share.py new file mode 100644 index 0000000..3e1f329 --- /dev/null +++ b/ai-service/app/api/mid/share.py @@ -0,0 +1,434 @@ +""" +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 with timezone info.""" + return datetime.now(timezone.utc) + + +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 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 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 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 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, + } diff --git a/ai-service/app/core/config.py b/ai-service/app/core/config.py index 393cbff..27bcb13 100644 --- a/ai-service/app/core/config.py +++ b/ai-service/app/core/config.py @@ -65,6 +65,8 @@ class Settings(BaseSettings): dashboard_cache_ttl: int = 60 stats_counter_ttl: int = 7776000 + frontend_base_url: str = "http://localhost:3000" + @lru_cache def get_settings() -> Settings: