"""The audit trail: who changed a document, when, and what that change was. Snapshots are written AFTER their event, so an entry's content is the state it produced. Showing "what did this one do" therefore needs the pair (this snapshot and the one before it), which is why the version endpoint returns both. """ import uuid from typing import Annotated from fastapi import Depends from sqlalchemy import select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.api.documents.access import readable_document from app.api.documents.routing import documents_router from app.api.documents.schemas import DocumentEventOut, DocumentVersion from app.auth.deps import get_current_user from app.db import get_db from app.errors import ApiError from app.models import DocumentEvent, User router = documents_router() # Newest first, with the id as tiebreaker: events written in one transaction # share a timestamp, and only a total order can be paged or walked backwards. _NEWEST_FIRST = (DocumentEvent.created_at.desc(), DocumentEvent.id.desc()) @router.get("/{document_id}/history") async def document_history( document_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> list[DocumentEventOut]: """The document's audit trail, newest first: who changed or reviewed it, when, and whether a content snapshot exists to diff against. Same read gate as the document itself, so history never leaks to a user who cannot read the document.""" document = await readable_document(db, document_id, user) rows = ( await db.execute( select(DocumentEvent, User.name) .join(User, DocumentEvent.actor_id == User.id, isouter=True) .where(DocumentEvent.document_id == document.id) .order_by(*_NEWEST_FIRST) ) ).all() return [ DocumentEventOut( id=event.id, action=event.action, actor_id=event.actor_id, actor_name=name, visibility=event.visibility, created_at=event.created_at, has_snapshot=event.content_md is not None, ) for event, name in rows ] @router.get("/{document_id}/versions/{event_id}") async def document_version( document_id: uuid.UUID, event_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> DocumentVersion: """A single past version's frozen content plus the content it replaced, so the caller can show what this event changed. Same read gate as the document.""" document = await readable_document(db, document_id, user) row = ( await db.execute( select(DocumentEvent, User.name) .join(User, DocumentEvent.actor_id == User.id, isouter=True) .where( DocumentEvent.id == event_id, DocumentEvent.document_id == document.id, ) ) ).first() if row is None: raise ApiError(404, "Version not found.", "not_found") event, name = row # The state this event started from: the closest earlier snapshot, in the # same order the history list uses. previous = ( await db.execute( select(DocumentEvent.content_md) .where( DocumentEvent.document_id == document.id, DocumentEvent.content_md.is_not(None), tuple_(DocumentEvent.created_at, DocumentEvent.id) < tuple_(event.created_at, event.id), ) .order_by(*_NEWEST_FIRST) .limit(1) ) ).scalar_one_or_none() return DocumentVersion( id=event.id, action=event.action, actor_id=event.actor_id, actor_name=name, created_at=event.created_at, title=event.title, content_md=event.content_md, previous_content_md=previous, visibility=event.visibility, )