Files
claudia-docs-api/app/routers/documents.py
Motoko 7f3e8a8f53 Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist
- Projects/Folders/Documents CRUD with soft deletes
- Tags CRUD and assignment
- FTS5 search with highlights and tag filtering
- ADR-001, ADR-002, ADR-003 compliant
- Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure)
- 25 tests passing
2026-03-30 15:17:27 +00:00

436 lines
13 KiB
Python

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