"""Document upload, list, delete, and file serving.""" from __future__ import annotations import logging from pathlib import Path from fastapi import ( APIRouter, Depends, File, Form, HTTPException, UploadFile, ) from fastapi.responses import FileResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.db import get_session from app.models import Document from app.schemas import DocumentOut, FolderOut, IngestResponse from app.services.rag import ( SUPPORTED_SUFFIXES, document_file_path, ingest_file, ) from app.services.store import get_document_store logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/documents", tags=["documents"]) @router.get("", response_model=list[DocumentOut]) async def list_documents( folder: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[Document]: """List indexed documents.""" stmt = select(Document).order_by(Document.created_at.desc()) if folder: stmt = stmt.where(Document.folder == folder) result = await session.execute(stmt) return list(result.scalars().all()) @router.get("/folders", response_model=list[FolderOut]) async def list_folders( session: AsyncSession = Depends(get_session), ) -> list[FolderOut]: """List folders with document counts.""" result = await session.execute( select(Document.folder, func.count(Document.id)) .group_by(Document.folder) .order_by(Document.folder) ) return [ FolderOut(name=row[0], document_count=row[1]) for row in result.all() ] @router.get("/{document_id}", response_model=DocumentOut) async def get_document( document_id: str, session: AsyncSession = Depends(get_session), ) -> Document: """Fetch one document metadata row.""" doc = await session.get(Document, document_id) if doc is None: raise HTTPException(404, "Document not found") return doc @router.get("/{document_id}/file") async def download_document( document_id: str, session: AsyncSession = Depends(get_session), ) -> FileResponse: """Serve the original file for the PDF viewer.""" path = await document_file_path(session, document_id) if path is None or not path.exists(): raise HTTPException(404, "File not found") doc = await session.get(Document, document_id) media = doc.media_type if doc else "application/octet-stream" return FileResponse( path, media_type=media, filename=doc.original_name if doc else path.name, ) @router.post("/upload", response_model=IngestResponse) async def upload_document( file: UploadFile = File(...), folder: str = Form("general"), session: AsyncSession = Depends(get_session), ) -> IngestResponse: """Upload and index a document.""" settings = get_settings() name = file.filename or "upload.bin" suffix = Path(name).suffix.lower() if suffix not in SUPPORTED_SUFFIXES: raise HTTPException( 400, f"Unsupported type {suffix}. " f"Allowed: {', '.join(sorted(SUPPORTED_SUFFIXES))}", ) raw = await file.read() max_bytes = settings.max_upload_mb * 1024 * 1024 if len(raw) > max_bytes: raise HTTPException( 400, f"File exceeds {settings.max_upload_mb} MB limit", ) tmp_dir = Path(settings.upload_path) / "_tmp" tmp_dir.mkdir(parents=True, exist_ok=True) tmp_path = tmp_dir / name tmp_path.write_bytes(raw) try: doc, chunks = await ingest_file( session, source_path=tmp_path, original_name=name, folder=(folder or "general").strip() or "general", media_type=file.content_type or "application/octet-stream", ) finally: if tmp_path.exists(): tmp_path.unlink() return IngestResponse( document=DocumentOut.model_validate(doc), chunks_added=chunks, ) @router.delete("/{document_id}") async def delete_document( document_id: str, session: AsyncSession = Depends(get_session), ) -> dict[str, str]: """Delete document metadata, chunks, and file.""" doc = await session.get(Document, document_id) if doc is None: raise HTTPException(404, "Document not found") store = get_document_store() store.delete_by_document(document_id) settings = get_settings() folder = Path(settings.upload_path) / document_id if folder.exists(): for child in folder.iterdir(): child.unlink(missing_ok=True) folder.rmdir() await session.delete(doc) await session.commit() return {"status": "deleted", "id": document_id}