"""From draft to published, and the questions that hang off a document. Two things that used to be one. **Publishing** is the author's own decision: a draft is private until they say it is worth reading, one action, no waiting. **A review request** is "please check this", and it is not a status — it can sit on a draft the author is unsure about OR on a document that has been published for months, and it marks the document wherever it appears until someone answers it. Being asked is what grants the right to edit: a reviewer who spots a wrong number should fix it rather than file a second question about it. """ import uuid from datetime import UTC, datetime from typing import Annotated from fastapi import Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.documents.access import ( readable_document, require_author_or_admin, require_editor, ) from app.api.documents.routing import documents_router from app.api.documents.schemas import ( DocumentDetail, ReviewerCandidate, ReviewRequestBody, ) from app.api.documents.view import full_detail from app.auth.deps import get_current_user from app.authoring.history import record_event from app.db import get_db from app.errors import ApiError from app.ingestion.handlers import INDEX_DOCUMENT from app.ingestion.queue import enqueue from app.models import DocumentEventAction, DocumentStatus, ReviewRequest, User from app.rag.permissions import document_reader_filter router = documents_router() @router.post("/{document_id}/publish") async def publish_document( document_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: """Make a draft readable and searchable for everyone its visibility allows. The author's own call — an open question about the content does not block it, it travels with the document instead (`open_reviews`), which is what lets a colleague read it AND know it is not settled. Author or admin, deliberately not every editor: a colleague asked to check a draft may fix what is wrong in it, but whether the company gets to read it at all is not their call. """ document = await readable_document(db, document_id, user) require_author_or_admin(document, user) if document.status != DocumentStatus.draft: raise ApiError(409, "Only a draft can be published.", "invalid_status") document.status = DocumentStatus.published record_event(db, document, user, DocumentEventAction.published) await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) await db.commit() return await full_detail(db, document, user) @router.get("/{document_id}/reviewers") async def list_reviewers( document_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> list[ReviewerCandidate]: """Who can be asked: everyone who could read this document once published, minus the author. Permission-safe and non-admin (unlike /admin/users), and only id + name leave the server.""" document = await readable_document(db, document_id, user) require_editor(document, user) rows = ( await db.execute( select(User.id, User.name) .where(document_reader_filter(document), User.id != document.author_id) .order_by(User.name) ) ).all() return [ReviewerCandidate(id=row.id, name=row.name) for row in rows] @router.post("/{document_id}/reviews") async def request_review( document_id: uuid.UUID, body: ReviewRequestBody, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: """Ask a colleague to check this document, optionally about something specific. The request grants them the right to read and edit it until it is answered.""" document = await readable_document(db, document_id, user) require_author_or_admin(document, user) if body.reviewer_id == user.id: raise ApiError(422, "You cannot ask yourself.", "invalid_reviewer") allowed = ( await db.execute( select(User.id).where( User.id == body.reviewer_id, document_reader_filter(document), ) ) ).scalar_one_or_none() if allowed is None: raise ApiError( 422, "That user cannot review this document.", "invalid_reviewer" ) if any(review.reviewer_id == body.reviewer_id for review in document.open_reviews): raise ApiError( 409, "That colleague has already been asked.", "review_already_open" ) db.add( ReviewRequest( document_id=document.id, requester_id=user.id, reviewer_id=body.reviewer_id, question=(body.question or "").strip() or None, ) ) record_event(db, document, user, DocumentEventAction.review_requested) await db.commit() # Reload the collection, not just the columns: the serializer reads the # requests, and a lazy load there would be IO in a sync property. await db.refresh(document, attribute_names=["reviews"]) return await full_detail(db, document, user) @router.post("/{document_id}/reviews/{review_id}/resolve") async def resolve_review( document_id: uuid.UUID, review_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: """Answer a request: the content was checked. The reviewer answers their own request; the author (or an admin) can close one that has become moot, because a question nobody will answer should not mark a document forever. """ document = await readable_document(db, document_id, user) review = next( (review for review in document.reviews if review.id == review_id), None ) if review is None: raise ApiError(404, "Review request not found.", "not_found") if review.resolved_at is not None: raise ApiError(409, "This request is already answered.", "already_resolved") if review.reviewer_id != user.id: require_author_or_admin(document, user) review.resolved_at = datetime.now(UTC) review.resolved_by_id = user.id record_event(db, document, user, DocumentEventAction.review_resolved) await db.commit() # Reload the collection, not just the columns: the serializer reads the # requests, and a lazy load there would be IO in a sync property. await db.refresh(document, attribute_names=["reviews"]) return await full_detail(db, document, user)