import uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.document import Document from app.models.folder import Folder from app.models.project import Project from app.models.tag import DocumentTag, Tag from app.routers.auth import get_current_agent from app.schemas.document import ( DocumentBriefResponse, DocumentContentUpdate, DocumentCreate, DocumentListResponse, DocumentResponse, DocumentUpdate, TagInfo, ) from app.schemas.tag import DocumentTagsAssign router = APIRouter(tags=["documents"]) def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_path: str | None) -> str: if folder_id and folder_path: return f"{folder_path}/{doc_id}" return f"/{project_id}/{doc_id}" async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]: result = await db.execute( text(""" SELECT t.id, t.name, t.color FROM active_tags t JOIN document_tags dt ON t.id = dt.tag_id WHERE dt.document_id = :doc_id """), {"doc_id": doc_id} ) rows = result.fetchall() return [TagInfo(id=r.id, name=r.name, color=r.color) for r in rows] async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse: tags = await get_document_tags(db, doc.id) return DocumentResponse( id=doc.id, title=doc.title, content=doc.content, project_id=doc.project_id, folder_id=doc.folder_id, path=doc.path, tags=tags, created_at=doc.created_at, updated_at=doc.updated_at, ) @router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse) async def list_documents( request: Request, project_id: str, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) proj_result = await db.execute( select(Project).where( Project.id == project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") result = await db.execute( select(Document).where( Document.project_id == project_id, Document.is_deleted == False, ).order_by(Document.created_at.desc()) ) docs = result.scalars().all() responses = [] for doc in docs: tags = await get_document_tags(db, doc.id) responses.append(DocumentBriefResponse( id=doc.id, title=doc.title, project_id=doc.project_id, folder_id=doc.folder_id, path=doc.path, tags=tags, created_at=doc.created_at, updated_at=doc.updated_at, )) return DocumentListResponse(documents=responses) @router.post("/api/v1/projects/{project_id}/documents", response_model=DocumentResponse, status_code=201) async def create_document( request: Request, project_id: str, payload: DocumentCreate, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) proj_result = await db.execute( select(Project).where( Project.id == project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") folder_path = None if payload.folder_id: folder_result = await db.execute( select(Folder).where( Folder.id == payload.folder_id, Folder.project_id == project_id, Folder.is_deleted == False, ) ) folder = folder_result.scalar_one_or_none() if not folder: raise HTTPException(status_code=400, detail="Folder not found") folder_path = folder.path doc_id = str(uuid.uuid4()) path = build_doc_path(project_id, doc_id, payload.folder_id, folder_path) doc = Document( id=doc_id, title=payload.title, content=payload.content, project_id=project_id, folder_id=payload.folder_id, path=path, ) db.add(doc) await db.flush() return await document_to_response(db, doc) @router.get("/api/v1/documents/{document_id}", response_model=DocumentResponse) async def get_document( request: Request, document_id: str, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Document not found") return await document_to_response(db, doc) @router.put("/api/v1/documents/{document_id}", response_model=DocumentResponse) async def update_document( request: Request, document_id: str, payload: DocumentUpdate, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") if payload.title is not None: doc.title = payload.title if payload.folder_id is not None: if payload.folder_id: folder_result = await db.execute( select(Folder).where( Folder.id == payload.folder_id, Folder.project_id == doc.project_id, Folder.is_deleted == False, ) ) folder = folder_result.scalar_one_or_none() if not folder: raise HTTPException(status_code=400, detail="Folder not found") doc.path = f"{folder.path}/{doc.id}" else: doc.path = f"/{doc.project_id}/{doc.id}" doc.folder_id = payload.folder_id doc.updated_at = datetime.utcnow() await db.flush() return await document_to_response(db, doc) @router.delete("/api/v1/documents/{document_id}", status_code=204) async def delete_document( request: Request, document_id: str, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") doc.is_deleted = True doc.deleted_at = datetime.utcnow() doc.deleted_by = agent.id await db.flush() return None @router.put("/api/v1/documents/{document_id}/content", response_model=DocumentResponse) async def update_document_content( request: Request, document_id: str, payload: DocumentContentUpdate, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") doc.content = payload.content doc.updated_at = datetime.utcnow() await db.flush() return await document_to_response(db, doc) @router.post("/api/v1/documents/{document_id}/restore", response_model=DocumentResponse) async def restore_document( request: Request, document_id: str, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == True, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") doc.is_deleted = False doc.deleted_at = None doc.deleted_by = None await db.flush() return await document_to_response(db, doc) @router.post("/api/v1/documents/{document_id}/tags", status_code=204) async def assign_tags( request: Request, document_id: str, payload: DocumentTagsAssign, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") for tag_id in payload.tag_ids: tag_result = await db.execute( select(Tag).where( Tag.id == tag_id, Tag.is_deleted == False, ) ) tag = tag_result.scalar_one_or_none() if not tag: raise HTTPException(status_code=400, detail=f"Tag {tag_id} not found") existing = await db.execute( select(DocumentTag).where( DocumentTag.document_id == document_id, DocumentTag.tag_id == tag_id, ) ) if not existing.scalar_one_or_none(): dt = DocumentTag(document_id=document_id, tag_id=tag_id) db.add(dt) await db.flush() return None @router.delete("/api/v1/documents/{document_id}/tags/{tag_id}", status_code=204) async def remove_tag( request: Request, document_id: str, tag_id: str, db: AsyncSession = Depends(get_db), ): agent = await get_current_agent(request, db) result = await db.execute( select(Document).where( Document.id == document_id, Document.is_deleted == False, ) ) doc = result.scalar_one_or_none() if not doc: raise HTTPException(status_code=404, detail="Document not found") proj_result = await db.execute( select(Project).where( Project.id == doc.project_id, Project.agent_id == agent.id, Project.is_deleted == False, ) ) if not proj_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Forbidden") await db.execute( delete(DocumentTag).where( DocumentTag.document_id == document_id, DocumentTag.tag_id == tag_id, ) ) await db.flush() return None