from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from motor.motor_asyncio import AsyncIOMotorDatabase from sqlalchemy.orm import Session from app.dependencies import get_current_user, get_mongo_db_dep from app.database import get_db from app.services.tenant_service import get_workspace from app.models.user import User from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest from app.schemas.collaboration import ( ActivityResponse, CommentCreate, CommentResolveRequest, CommentResponse, NotificationResponse, ) from app.services.collaboration_service import ( create_comment, delete_comment, list_activity, list_comments, list_notifications, mark_notifications_read, mention_names, resolve_comment, ) from app.services.document_service import ( add_member, create_document, delete_document, get_document, list_documents, record_visit, remove_member, search_documents, update_document, ) from app.services.user_service import get_user_by_email_or_username from app.services.history_service import list_versions, restore_version from html import escape router = APIRouter(prefix="/documents", tags=["documents"]) @router.get("/{doc_id}/comments", response_model=list[CommentResponse]) async def get_comments( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> list[CommentResponse]: comments = await list_comments(db, doc_id, current_user.id) if comments is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") return [CommentResponse(**comment) for comment in comments] @router.post("/{doc_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) async def post_comment( doc_id: str, data: CommentCreate, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), sql: Session = Depends(get_db), ) -> CommentResponse: mentioned_users = [] for name in mention_names(data.body): mentioned = get_user_by_email_or_username(sql, name.lower()) if mentioned is not None and mentioned.id != current_user.id: mentioned_users.append({"id": mentioned.id}) comment = await create_comment( db, doc_id, current_user.id, current_user.full_name or current_user.username, data.body, data.parent_id, mentioned_users, ) if comment is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or parent comment is invalid") return CommentResponse(**comment) @router.patch("/{doc_id}/comments/{comment_id}/resolve", response_model=CommentResponse) async def update_comment_resolution( doc_id: str, comment_id: str, data: CommentResolveRequest, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> CommentResponse: comment = await resolve_comment( db, doc_id, comment_id, current_user.id, data.resolved, current_user.full_name or current_user.username, ) if comment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or access denied") return CommentResponse(**comment) @router.get("/{doc_id}/activity", response_model=list[ActivityResponse]) async def get_activity( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> list[ActivityResponse]: entries = await list_activity(db, doc_id, current_user.id) if entries is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") return [ActivityResponse(**entry) for entry in entries] @router.get("/notifications", response_model=list[NotificationResponse]) async def get_notifications( current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> list[NotificationResponse]: return [NotificationResponse(**item) for item in await list_notifications(db, current_user.id)] @router.post("/notifications/read", status_code=status.HTTP_204_NO_CONTENT) async def read_notifications( current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> None: await mark_notifications_read(db, current_user.id) @router.get("/{doc_id}/history", response_model=list[DocumentVersionResponse]) async def get_history( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> list[DocumentVersionResponse]: versions = await list_versions(db, doc_id, current_user.id) if versions is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") return [DocumentVersionResponse(**version) for version in versions] @router.post("/{doc_id}/history/{version_id}/restore", response_model=DocumentResponse) async def restore_history( doc_id: str, version_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> DocumentResponse: document = await restore_version( db, doc_id, version_id, current_user.id, current_user.full_name or current_user.username, ) if document is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Version not found or restore access denied") from app.services.document_service import _serialize return DocumentResponse(**_serialize(document)) @router.get("/{doc_id}/export") async def export_document( doc_id: str, format: str = Query("html", pattern="^(html|text)$"), current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> Response: document = await get_document(db, doc_id, current_user.id) if document is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") body_html = str(document["content"].get("bodyHtml", document["content"].get("body", ""))) if format == "text": import re return Response(re.sub(r"<[^>]+>", " ", body_html).strip(), media_type="text/plain") html = ( "" f"{escape(document['title'])}" f"

{escape(document['title'])}

{body_html}" ) return Response(html, media_type="text/html") @router.delete("/{doc_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_comment( doc_id: str, comment_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> None: deleted = await delete_comment(db, comment_id, current_user.id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or you are not its author") @router.get("", response_model=list[DocumentResponse]) async def list_docs( type: str | None = Query(None, description="Filter by document type"), stead_id: str | None = Query(None, description="Filter by parent stead"), parent_page_id: str | None = Query(None, description="Filter by parent page"), starred: bool | None = Query(None, description="Filter starred projects"), workspace_id: int | None = Query(None, description="Filter by workspace"), sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"), current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), auth_db: Session = Depends(get_db), ) -> list[DocumentResponse]: if workspace_id is not None: if get_workspace(auth_db, workspace_id, current_user.id) is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied") await db["documents"].update_many( {"owner_id": current_user.id, "workspace_id": {"$exists": False}}, {"$set": {"workspace_id": workspace_id}}, ) sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at" docs = await list_documents( db, current_user.id, doc_type=type, stead_id=stead_id, parent_page_id=parent_page_id, starred=starred, sort=sort_field, workspace_id=workspace_id, ) return [DocumentResponse(**doc) for doc in docs] @router.get("/search", response_model=list[DocumentResponse]) async def search_docs( q: str = Query("", min_length=0), current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> list[DocumentResponse]: docs = await search_documents(db, current_user.id, q) return [DocumentResponse(**doc) for doc in docs] @router.get("/{doc_id}", response_model=DocumentResponse) async def get_doc( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> DocumentResponse: doc = await get_document(db, doc_id, current_user.id) if doc is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") return DocumentResponse(**doc) @router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) async def create_doc( data: DocumentCreate, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), auth_db: Session = Depends(get_db), ) -> DocumentResponse: if data.workspace_id is not None and get_workspace(auth_db, data.workspace_id, current_user.id) is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied") created_by = current_user.full_name or current_user.username try: doc = await create_document(db, current_user.id, created_by, data) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return DocumentResponse(**doc) @router.patch("/{doc_id}", response_model=DocumentResponse) async def update_doc( doc_id: str, data: DocumentUpdate, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> DocumentResponse: doc = await update_document(db, doc_id, current_user.id, data) if doc is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or you do not have edit access", ) return DocumentResponse(**doc) @router.post("/{doc_id}/visit", response_model=DocumentResponse) async def visit_doc( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> DocumentResponse: doc = await record_visit(db, doc_id, current_user.id) if doc is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied") return DocumentResponse(**doc) @router.post("/{doc_id}/share", response_model=DocumentResponse) async def share_doc( doc_id: str, data: ShareRequest, current_user: User = Depends(get_current_user), mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), sql: Session = Depends(get_db), ) -> DocumentResponse: identifier = data.email_or_username.strip().lower() user = get_user_by_email_or_username(sql, identifier) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if user.id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this document") doc = await add_member( mongo, doc_id, current_user.id, { "user_id": user.id, "name": user.full_name or user.username, "email": user.email, }, data.role, ) if doc is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this document") return DocumentResponse(**doc) @router.delete("/{doc_id}/share/{user_id}", response_model=DocumentResponse) async def unshare_doc( doc_id: str, user_id: int, current_user: User = Depends(get_current_user), mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> DocumentResponse: doc = await remove_member(mongo, doc_id, current_user.id, user_id) if doc is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update sharing") return DocumentResponse(**doc) @router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_doc( doc_id: str, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), ) -> None: deleted = await delete_document(db, doc_id, current_user.id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")