"""Starting, listing, reading and deleting conversations. Everything here is owner-scoped. Deleting is a GDPR surface, not a convenience: a user must be able to remove their own transcripts, and the messages go with them by cascade. """ import uuid from typing import Annotated from fastapi import Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.conversations.access import own_conversation from app.api.conversations.routing import conversations_router from app.api.conversations.schemas import ( ConversationCreate, ConversationDetail, ConversationSummary, ) from app.api.conversations.view import message_out, title from app.auth.deps import get_current_user from app.db import get_db from app.errors import ApiError from app.models import Conversation, Message, User from app.modes import get_mode router = conversations_router() @router.post("") async def create_conversation( body: ConversationCreate, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> ConversationSummary: if get_mode(body.mode.value) is None: raise ApiError( 400, f"Mode '{body.mode.value}' is not available.", "unknown_mode" ) conversation = Conversation(mode=body.mode, user_id=user.id) db.add(conversation) await db.commit() return ConversationSummary.model_validate(conversation) @router.get("") async def list_conversations( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> list[ConversationSummary]: """The sidebar list, newest activity first. The title is the first message, fetched as a correlated subquery so one statement answers the whole list.""" first_message = ( select(Message.content) .where(Message.conversation_id == Conversation.id) .order_by(Message.created_at) .limit(1) .correlate(Conversation) .scalar_subquery() ) rows = await db.execute( select(Conversation, first_message) .where(Conversation.user_id == user.id) .order_by(Conversation.updated_at.desc()) ) return [ ConversationSummary.model_validate(conversation).model_copy( update={"title": title(first)} ) for conversation, first in rows ] @router.get("/{conversation_id}") async def get_conversation( conversation_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> ConversationDetail: conversation = await own_conversation(db, conversation_id, user, with_messages=True) first = conversation.messages[0].content if conversation.messages else None return ConversationDetail.model_validate(conversation).model_copy( update={ "title": title(first), "messages": [message_out(message) for message in conversation.messages], } ) @router.delete("/{conversation_id}", status_code=204) async def delete_conversation( conversation_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> None: """GDPR: users delete their own conversations; messages cascade.""" conversation = await own_conversation(db, conversation_id, user) await db.delete(conversation) await db.commit()