"""The life of one document: open it, read it, change it, delete it. Publishing does NOT live here — a draft becomes public through `workflow.py`, so a content edit can never make a private draft readable by accident. Archiving does, because it is the mirror of the `status` a PATCH already carries. """ import uuid from typing import Annotated, Any from fastapi import Depends from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api.documents.access import ( guard_self_lockout, readable_document, require_author_or_admin, require_editor, ) from app.api.documents.routing import documents_router from app.api.documents.schemas import DocumentCreate, DocumentDetail, DocumentUpdate from app.api.documents.view import detail, full_detail, granted_department_ids from app.auth.deps import get_current_user from app.authoring.context import summarize_conversation from app.authoring.document import render_skeleton, render_title from app.authoring.history import record_event from app.authoring.schema import AuthoringTemplate 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 ( Conversation, Document, DocumentEventAction, DocumentStatus, DocumentVisibility, Template, User, ) router = documents_router() @router.post("", status_code=201) async def create_document( body: DocumentCreate, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: """Open a new document to write in. A `draft` is the author's private working copy: `readable_documents_filter` shows it to no one else (bar a colleague asked to check it) and only `published` documents are indexed, so a draft never reaches another user or an LLM prompt.""" title = body.title content_md = "" visibility = body.visibility or DocumentVisibility.department meta: dict[str, Any] = {} if body.template_id is not None: row = await db.get(Template, body.template_id) if row is None: raise ApiError(404, "Template not found.", "not_found") try: template = AuthoringTemplate.model_validate(row.config) except ValidationError: raise ApiError( 422, "Template is not a valid authoring template.", "invalid_template" ) from None content_md = render_skeleton(template) meta = {"template": template.id} if title is None: title = render_title(template, user) if body.visibility is None: visibility = DocumentVisibility(template.metadata.visibility) if not title: raise ApiError(422, "A title or a template is required.", "title_required") if body.conversation_id is not None: meta.update(await _conversation_context(db, body.conversation_id, user)) document = Document( title=title, status=DocumentStatus.draft, visibility=visibility, content_md=content_md, meta=meta, author_id=user.id, department_id=user.department_id, # Marks the collection loaded — a brand-new document has no requests, # and the serializer reads them without a session to lazy-load in. reviews=[], ) db.add(document) # Flush so the event can reference document.id (the PK default is applied # at flush, not at construction). await db.flush() record_event(db, document, user, DocumentEventAction.created, snapshot=True) await db.commit() return detail(document, user) async def _conversation_context( db: AsyncSession, conversation_id: uuid.UUID, user: User ) -> dict[str, Any]: """What the chat this capture started from was about, as background for section refinement. Owner-scoped; a foreign or unknown conversation simply contributes nothing.""" conversation = ( await db.execute( select(Conversation) .where( Conversation.id == conversation_id, Conversation.user_id == user.id, ) .options(selectinload(Conversation.messages)) ) ).scalar_one_or_none() if conversation is None: return {} topic = await summarize_conversation(conversation) if not topic: return {} return {"context": topic, "conversation_id": str(conversation.id)} @router.get("/{document_id}") async def get_document( document_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: document = await readable_document(db, document_id, user) return await full_detail(db, document, user) @router.patch("/{document_id}") async def update_document( document_id: uuid.UUID, body: DocumentUpdate, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentDetail: document = await readable_document(db, document_id, user) require_editor(document, user) # Track the axes of change separately so the audit trail can name what # happened (an edit vs. a visibility change vs. an archive), even though # all three equally invalidate the denormalized chunk copy. body_changed = False if body.title is not None and body.title != document.title: document.title = body.title body_changed = True if body.content_md is not None and body.content_md != document.content_md: document.content_md = body.content_md body_changed = True visibility_changed = False if body.visibility is not None and body.visibility != document.visibility: # Who may READ this is the owner's decision, like sharing and deleting: # a colleague asked to check the text may correct it, not re-address it. require_author_or_admin(document, user) # A visibility change can remove the editing user's own access (only an # admin editing a document they do not own — an author keeps access). guard_self_lockout( user, author_id=document.author_id, visibility=body.visibility, department_id=document.department_id, granted_department_ids=await granted_department_ids(db, document.id), confirm=bool(body.confirm_lockout), ) document.visibility = body.visibility visibility_changed = True # chunk meta carries a denormalized copy if body.conversation_id is not None: # Extending a document out of a chat: same background as a fresh # capture. Metadata only — no event, and no reindex, because nothing # a chunk carries changed. document.meta = { **document.meta, **await _conversation_context(db, body.conversation_id, user), } archived = False status_changed = False if body.status is not None and body.status != document.status: archivable = {DocumentStatus.published, DocumentStatus.archived} if body.status not in archivable or document.status not in archivable: # Publishing is its own endpoint: it indexes the document and is # the author's decision, not a field on a content edit. raise ApiError( 409, "Only published documents can be archived (and vice versa).", "invalid_status", ) document.status = body.status status_changed = True archived = body.status == DocumentStatus.archived # Audit: an edit snapshots the new Markdown so the version can be diffed; a # visibility change or archive is a pure transition (no content snapshot). if body_changed: record_event(db, document, user, DocumentEventAction.edited, snapshot=True) if visibility_changed: record_event(db, document, user, DocumentEventAction.visibility_changed) if archived: record_event(db, document, user, DocumentEventAction.archived) # Chunks are derivatives of the Markdown: published edits reindex, and # archive/publish transitions add or remove the chunks. content_changed = body_changed or visibility_changed published = document.status == DocumentStatus.published if (content_changed and published) or status_changed: await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)}) await db.commit() return detail(document, user) @router.delete("/{document_id}", status_code=204) async def delete_document( document_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> None: document = await readable_document(db, document_id, user) require_author_or_admin(document, user) await db.delete(document) # chunks cascade await db.commit()