feat(comments): add document comments, annotations, and @mention support

- Add DocumentComment and DocumentAnnotation models to app/models.py
- Create migration 041_add_document_comments_and_annotations
- Add API endpoints for CRUD operations on comments and annotations
- Add threaded comment support with parent_id relationships
- Add @mention extraction from comment body text
- Add resolve/unresolve comment thread endpoint
- Add mentionable users endpoint (GET /api/users/mentionable)
- Add 43 unit tests covering all endpoints and edge cases
- Add 29 i18n translation keys to en.json
- Update API documentation in docs/API.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/3894af37-0f19-457b-8811-f1feb18b17ef
This commit is contained in:
copilot-swe-agent[bot]
2026-03-21 18:28:01 +00:00
parent 30a124d85b
commit ad795e200a
9 changed files with 1571 additions and 0 deletions
+2
View File
@@ -14,6 +14,7 @@ from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
from app.api.classification_rules import router as classification_rules_router
from app.api.comments import router as comments_router
from app.api.compliance import router as compliance_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
@@ -108,3 +109,4 @@ router.include_router(system_reset_router)
router.include_router(translation_router)
router.include_router(classification_rules_router)
router.include_router(automation_router)
router.include_router(comments_router)
+679
View File
@@ -0,0 +1,679 @@
"""Document comments and annotations API endpoints.
Provides CRUD operations for threaded comments on documents,
text annotations on PDF pages, and a list of mentionable users
for the @mention feature.
"""
import json
import logging
import re
from typing import Annotated, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import get_current_user_id, require_login
from app.database import get_db
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
logger = logging.getLogger(__name__)
router = APIRouter(tags=["comments"])
DbSession = Annotated[Session, Depends(get_db)]
# Constraints
MAX_COMMENT_BODY_LENGTH = 10_000
MAX_ANNOTATION_CONTENT_LENGTH = 5_000
# Allowed annotation types
ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"})
# Simple pattern for @mentions matches @username tokens inside comment body
_MENTION_PATTERN = re.compile(r"@([\w.\-]+)")
def _extract_mentions(body: str) -> list[str]:
"""Extract unique @mentioned usernames from a comment body.
Args:
body: The raw comment text.
Returns:
A deduplicated list of mentioned usernames (without the ``@`` prefix).
"""
return list(dict.fromkeys(_MENTION_PATTERN.findall(body)))
def _serialize_comment(c: DocumentComment) -> dict[str, Any]:
"""Serialize a DocumentComment to a JSON-friendly dict.
Args:
c: The comment model instance.
Returns:
A dictionary representation of the comment.
"""
mentions: list[str] = []
if c.mentions:
try:
mentions = json.loads(c.mentions)
except (json.JSONDecodeError, TypeError):
pass
return {
"id": c.id,
"file_id": c.file_id,
"user_id": c.user_id,
"parent_id": c.parent_id,
"body": c.body,
"mentions": mentions,
"is_resolved": c.is_resolved,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]:
"""Serialize a DocumentAnnotation to a JSON-friendly dict.
Args:
a: The annotation model instance.
Returns:
A dictionary representation of the annotation.
"""
return {
"id": a.id,
"file_id": a.file_id,
"user_id": a.user_id,
"page": a.page,
"x": a.x,
"y": a.y,
"width": a.width,
"height": a.height,
"content": a.content,
"annotation_type": a.annotation_type,
"color": a.color,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]:
"""Organize a flat list of comments into a threaded tree structure.
Top-level comments (``parent_id is None``) appear as root nodes.
Replies are nested inside their parent's ``replies`` list.
Args:
comments: All comments for a given document, ordered by ``created_at``.
Returns:
A list of root-level comment dicts, each with a ``replies`` key.
"""
by_id: dict[int, dict[str, Any]] = {}
roots: list[dict[str, Any]] = []
for c in comments:
node = _serialize_comment(c)
node["replies"] = []
by_id[c.id] = node
for c in comments:
node = by_id[c.id]
if c.parent_id and c.parent_id in by_id:
by_id[c.parent_id]["replies"].append(node)
else:
roots.append(node)
return roots
# ---------------------------------------------------------------------------
# Comments endpoints
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/comments")
@require_login
def list_comments(request: Request, file_id: int, db: DbSession):
"""List all comments for a document, organised into threads.
Returns a threaded tree where top-level comments contain nested
``replies``.
Path Parameters:
file_id: The ID of the document.
Returns:
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
"""
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
comments = (
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
)
return {
"file_id": file_id,
"comments": _build_thread_tree(comments),
"total": len(comments),
}
@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED)
@require_login
def create_comment(
request: Request,
file_id: int,
db: DbSession,
body: str = Body(..., embed=True),
parent_id: int | None = Body(None, embed=True),
):
"""Create a new comment on a document.
Automatically extracts @mentions from the comment body and stores
them for later notification or UI highlighting.
Path Parameters:
file_id: The ID of the document to comment on.
Request body (JSON):
body: Comment text (required, max 10 000 characters).
parent_id: ID of the parent comment for threaded replies (optional).
Returns:
The created comment object.
"""
user_id = get_current_user_id(request)
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not isinstance(body, str) or not body.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="body is required and must be non-empty",
)
body = body.strip()
if len(body) > MAX_COMMENT_BODY_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
)
if parent_id is not None:
parent = (
db.query(DocumentComment)
.filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id)
.first()
)
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent comment not found",
)
mentions = _extract_mentions(body)
comment = DocumentComment(
file_id=file_id,
user_id=user_id,
parent_id=parent_id,
body=body,
mentions=json.dumps(mentions) if mentions else None,
)
try:
db.add(comment)
db.commit()
db.refresh(comment)
except Exception:
db.rollback()
logger.exception("Failed to create comment on file_id=%s", file_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create comment",
)
logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id)
return _serialize_comment(comment)
@router.put("/files/{file_id}/comments/{comment_id}")
@require_login
def update_comment(
request: Request,
file_id: int,
comment_id: int,
db: DbSession,
body: str = Body(..., embed=True),
):
"""Update the body of an existing comment.
Only the comment author may update the comment. Mentions are
re-extracted from the updated body.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to update.
Request body (JSON):
body: New comment text (required).
Returns:
The updated comment object.
"""
user_id = get_current_user_id(request)
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
if comment.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments")
if not isinstance(body, str) or not body.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="body is required and must be non-empty",
)
body = body.strip()
if len(body) > MAX_COMMENT_BODY_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
)
mentions = _extract_mentions(body)
comment.body = body
comment.mentions = json.dumps(mentions) if mentions else None
try:
db.commit()
db.refresh(comment)
except Exception:
db.rollback()
logger.exception("Failed to update comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update comment",
)
logger.info("Comment updated: id=%s, user=%s", comment_id, user_id)
return _serialize_comment(comment)
@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession):
"""Delete a comment.
Only the comment author may delete the comment. Replies to the
deleted comment are **not** removed — they become orphaned root
comments so that conversation context is preserved.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to delete.
"""
user_id = get_current_user_id(request)
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
if comment.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments")
try:
db.delete(comment)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete comment",
)
logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id)
@router.patch("/files/{file_id}/comments/{comment_id}/resolve")
@require_login
def resolve_comment(
request: Request,
file_id: int,
comment_id: int,
db: DbSession,
is_resolved: bool = Body(..., embed=True),
):
"""Mark a top-level comment thread as resolved or unresolved.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to resolve / unresolve.
Request body (JSON):
is_resolved: ``true`` to resolve, ``false`` to unresolve.
Returns:
The updated comment object.
"""
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
comment.is_resolved = is_resolved
try:
db.commit()
db.refresh(comment)
except Exception:
db.rollback()
logger.exception("Failed to resolve comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update comment",
)
logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id)
return _serialize_comment(comment)
# ---------------------------------------------------------------------------
# Annotations endpoints
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/annotations")
@require_login
def list_annotations(request: Request, file_id: int, db: DbSession):
"""List all annotations for a document.
Path Parameters:
file_id: The ID of the document.
Returns:
A dict with ``file_id``, ``annotations``, and ``total``.
"""
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
annotations = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.file_id == file_id)
.order_by(DocumentAnnotation.page, DocumentAnnotation.created_at)
.all()
)
return {
"file_id": file_id,
"annotations": [_serialize_annotation(a) for a in annotations],
"total": len(annotations),
}
@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED)
@require_login
def create_annotation(
request: Request,
file_id: int,
db: DbSession,
page: int = Body(..., embed=True),
x: float = Body(..., embed=True),
y: float = Body(..., embed=True),
content: str = Body(..., embed=True),
width: float = Body(0, embed=True),
height: float = Body(0, embed=True),
annotation_type: str = Body("note", embed=True),
color: str | None = Body(None, embed=True),
):
"""Create a new annotation on a PDF page.
Path Parameters:
file_id: The ID of the document.
Request body (JSON):
page: Page number (1-based, required).
x: Horizontal position on the page (required).
y: Vertical position on the page (required).
content: Annotation text (required, max 5 000 characters).
width: Width of the annotation bounding box (default 0).
height: Height of the annotation bounding box (default 0).
annotation_type: One of ``note``, ``highlight``, ``underline``,
``strikethrough`` (default ``note``).
color: Optional CSS colour string (e.g. ``#ff0000``).
Returns:
The created annotation object.
"""
user_id = get_current_user_id(request)
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not isinstance(content, str) or not content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="content is required and must be non-empty",
)
content = content.strip()
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
)
if page < 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="page must be >= 1",
)
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
)
annotation = DocumentAnnotation(
file_id=file_id,
user_id=user_id,
page=page,
x=x,
y=y,
width=width,
height=height,
content=content,
annotation_type=annotation_type,
color=color,
)
try:
db.add(annotation)
db.commit()
db.refresh(annotation)
except Exception:
db.rollback()
logger.exception("Failed to create annotation on file_id=%s", file_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create annotation",
)
logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id)
return _serialize_annotation(annotation)
@router.put("/files/{file_id}/annotations/{annotation_id}")
@require_login
def update_annotation(
request: Request,
file_id: int,
annotation_id: int,
db: DbSession,
content: str | None = Body(None, embed=True),
x: float | None = Body(None, embed=True),
y: float | None = Body(None, embed=True),
width: float | None = Body(None, embed=True),
height: float | None = Body(None, embed=True),
annotation_type: str | None = Body(None, embed=True),
color: str | None = Body(None, embed=True),
):
"""Update an existing annotation.
Only the annotation author may update the annotation.
Path Parameters:
file_id: The ID of the document.
annotation_id: The ID of the annotation to update.
Request body (JSON):
Any subset of ``content``, ``x``, ``y``, ``width``, ``height``,
``annotation_type``, and ``color``.
Returns:
The updated annotation object.
"""
user_id = get_current_user_id(request)
annotation = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
.first()
)
if not annotation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
if annotation.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations")
if content is not None:
content = content.strip() if isinstance(content, str) else ""
if not content:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="content must be non-empty",
)
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
)
annotation.content = content
if x is not None:
annotation.x = x
if y is not None:
annotation.y = y
if width is not None:
annotation.width = width
if height is not None:
annotation.height = height
if annotation_type is not None:
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
)
annotation.annotation_type = annotation_type
if color is not None:
annotation.color = color
try:
db.commit()
db.refresh(annotation)
except Exception:
db.rollback()
logger.exception("Failed to update annotation id=%s", annotation_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update annotation",
)
logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id)
return _serialize_annotation(annotation)
@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession):
"""Delete an annotation.
Only the annotation author may delete the annotation.
Path Parameters:
file_id: The ID of the document.
annotation_id: The ID of the annotation to delete.
"""
user_id = get_current_user_id(request)
annotation = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
.first()
)
if not annotation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
if annotation.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations")
try:
db.delete(annotation)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete annotation id=%s", annotation_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete annotation",
)
logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id)
# ---------------------------------------------------------------------------
# Mentionable users endpoint
# ---------------------------------------------------------------------------
@router.get("/users/mentionable")
@require_login
def list_mentionable_users(request: Request, db: DbSession):
"""List users that can be @mentioned in comments.
Returns all user profiles that are not blocked, sorted by
``display_name``.
Returns:
A list of ``{user_id, display_name}`` objects.
"""
profiles = (
db.query(UserProfile)
.filter(UserProfile.is_blocked == False) # noqa: E712
.order_by(UserProfile.display_name)
.all()
)
return [
{
"user_id": p.user_id,
"display_name": p.display_name or p.user_id,
}
for p in profiles
]
+45
View File
@@ -1192,3 +1192,48 @@ class PipelineRoutingRule(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class DocumentComment(Base):
"""Threaded comment on a document.
Supports threaded replies via ``parent_id`` and @mentions via the
``mentions`` column (comma-separated user identifiers).
"""
__tablename__ = "document_comments"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
user_id = Column(String, nullable=False, index=True)
parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True)
body = Column(Text, nullable=False)
mentions = Column(Text, nullable=True)
is_resolved = Column(Boolean, nullable=False, default=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class DocumentAnnotation(Base):
"""Text annotation on a specific page and position of a PDF document.
Stores the bounding-box coordinates (``x``, ``y``, ``width``,
``height``) relative to the page dimensions so that the annotation
can be rendered on top of the PDF viewer.
"""
__tablename__ = "document_annotations"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
user_id = Column(String, nullable=False, index=True)
page = Column(Integer, nullable=False)
x = Column(Float, nullable=False)
y = Column(Float, nullable=False)
width = Column(Float, nullable=False, default=0)
height = Column(Float, nullable=False, default=0)
content = Column(Text, nullable=False)
annotation_type = Column(String(50), nullable=False, default="note", server_default="note")
color = Column(String(20), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+199
View File
@@ -2909,3 +2909,202 @@ Move original files to a reimport folder, wipe everything, and configure the rei
}
}
```
---
## Comments & Annotations
Threaded comments and PDF annotations for document collaboration.
### List Comments
**GET** `/api/files/{file_id}/comments`
Returns all comments for a document, organised into a threaded tree.
**Response (200):**
```json
{
"file_id": 1,
"comments": [
{
"id": 1,
"file_id": 1,
"user_id": "alice",
"parent_id": null,
"body": "Please review section 3.",
"mentions": ["bob"],
"is_resolved": false,
"created_at": "2026-03-21T12:00:00+00:00",
"updated_at": "2026-03-21T12:00:00+00:00",
"replies": [
{
"id": 2,
"file_id": 1,
"user_id": "bob",
"parent_id": 1,
"body": "Done!",
"mentions": [],
"is_resolved": false,
"created_at": "2026-03-21T12:05:00+00:00",
"updated_at": "2026-03-21T12:05:00+00:00",
"replies": []
}
]
}
],
"total": 2
}
```
### Create Comment
**POST** `/api/files/{file_id}/comments`
Create a new comment on a document. @mentions are automatically extracted from the body.
**Request:**
```json
{
"body": "Hey @bob, please review this section.",
"parent_id": null
}
```
**Response (201):**
```json
{
"id": 3,
"file_id": 1,
"user_id": "alice",
"parent_id": null,
"body": "Hey @bob, please review this section.",
"mentions": ["bob"],
"is_resolved": false,
"created_at": "2026-03-21T12:10:00+00:00",
"updated_at": "2026-03-21T12:10:00+00:00"
}
```
### Update Comment
**PUT** `/api/files/{file_id}/comments/{comment_id}`
Update the body of an existing comment. Only the comment author may update it.
**Request:**
```json
{
"body": "Updated comment text @charlie"
}
```
### Delete Comment
**DELETE** `/api/files/{file_id}/comments/{comment_id}`
Delete a comment. Only the comment author may delete it.
**Response:** `204 No Content`
### Resolve / Unresolve Comment
**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve`
Mark a comment thread as resolved or unresolved.
**Request:**
```json
{
"is_resolved": true
}
```
### List Annotations
**GET** `/api/files/{file_id}/annotations`
Returns all PDF page annotations for a document, ordered by page then creation time.
**Response (200):**
```json
{
"file_id": 1,
"annotations": [
{
"id": 1,
"file_id": 1,
"user_id": "alice",
"page": 1,
"x": 100.0,
"y": 200.0,
"width": 150.0,
"height": 20.0,
"content": "Important paragraph",
"annotation_type": "highlight",
"color": "#ffff00",
"created_at": "2026-03-21T12:00:00+00:00",
"updated_at": "2026-03-21T12:00:00+00:00"
}
],
"total": 1
}
```
### Create Annotation
**POST** `/api/files/{file_id}/annotations`
Create a new annotation on a PDF page.
**Request:**
```json
{
"page": 1,
"x": 100.0,
"y": 200.0,
"width": 150.0,
"height": 20.0,
"content": "Important paragraph",
"annotation_type": "highlight",
"color": "#ffff00"
}
```
Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`.
### Update Annotation
**PUT** `/api/files/{file_id}/annotations/{annotation_id}`
Update an existing annotation. Only the annotation author may update it.
**Request** (all fields optional):
```json
{
"content": "Updated note",
"color": "#00ff00"
}
```
### Delete Annotation
**DELETE** `/api/files/{file_id}/annotations/{annotation_id}`
Delete an annotation. Only the annotation author may delete it.
**Response:** `204 No Content`
### List Mentionable Users
**GET** `/api/users/mentionable`
Returns all non-blocked user profiles for the @mention autocomplete.
**Response (200):**
```json
[
{ "user_id": "alice", "display_name": "Alice Anderson" },
{ "user_id": "bob", "display_name": "Bob Baker" }
]
```
+29
View File
@@ -315,6 +315,20 @@
"admin_users.total_count_users": "{count} users",
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"annotations.add": "Add annotation",
"annotations.color": "Color",
"annotations.content_placeholder": "Write an annotation...",
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
"annotations.deleted": "Annotation deleted",
"annotations.empty": "No annotations yet",
"annotations.heading": "Annotations",
"annotations.page": "Page",
"annotations.save": "Save",
"annotations.type_highlight": "Highlight",
"annotations.type_note": "Note",
"annotations.type_strikethrough": "Strikethrough",
"annotations.type_underline": "Underline",
"annotations.updated": "Annotation updated",
"api_tokens.col_created": "Created",
"api_tokens.col_expires": "Expires",
"api_tokens.col_last_ip": "Last IP",
@@ -484,6 +498,21 @@
"billing.success_heading": "You're all set!",
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
"billing.success_page_title": "DocuElevate - Subscription Activated",
"comments.add_comment": "Add comment",
"comments.add_reply": "Reply",
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
"comments.delete_confirm": "Are you sure you want to delete this comment?",
"comments.deleted": "Comment deleted",
"comments.edit": "Edit",
"comments.empty": "No comments yet",
"comments.heading": "Comments",
"comments.mention_users": "Mention users",
"comments.reply_placeholder": "Write a reply...",
"comments.resolve": "Resolve",
"comments.resolved": "Resolved",
"comments.save": "Save",
"comments.unresolve": "Reopen",
"comments.updated": "Comment updated",
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
+2
View File
@@ -26,6 +26,8 @@ from app.models import ( # noqa: F401
BackupRecord,
ClassificationRuleModel,
ComplianceTemplate,
DocumentAnnotation,
DocumentComment,
DocumentMetadata,
FileProcessingStep,
FileRecord,
@@ -0,0 +1,88 @@
"""Add document_comments and document_annotations tables.
Revision ID: 041_add_document_comments_and_annotations
Revises: 040_add_automation_hooks
Create Date: 2026-03-21
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "041_add_document_comments_and_annotations"
down_revision: Union[str, None] = "040_add_automation_hooks"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add document_comments and document_annotations tables."""
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if "document_comments" not in existing_tables:
op.create_table(
"document_comments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("mentions", sa.Text(), nullable=True),
sa.Column("is_resolved", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
sa.ForeignKeyConstraint(["parent_id"], ["document_comments.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_document_comments_id", "document_comments", ["id"])
op.create_index("ix_document_comments_file_id", "document_comments", ["file_id"])
op.create_index("ix_document_comments_user_id", "document_comments", ["user_id"])
op.create_index("ix_document_comments_parent_id", "document_comments", ["parent_id"])
if "document_annotations" not in existing_tables:
op.create_table(
"document_annotations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("page", sa.Integer(), nullable=False),
sa.Column("x", sa.Float(), nullable=False),
sa.Column("y", sa.Float(), nullable=False),
sa.Column("width", sa.Float(), nullable=False),
sa.Column("height", sa.Float(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("annotation_type", sa.String(50), nullable=False, server_default="note"),
sa.Column("color", sa.String(20), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_document_annotations_id", "document_annotations", ["id"])
op.create_index("ix_document_annotations_file_id", "document_annotations", ["file_id"])
op.create_index("ix_document_annotations_user_id", "document_annotations", ["user_id"])
def downgrade() -> None:
"""Drop document_comments and document_annotations tables."""
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if "document_annotations" in existing_tables:
op.drop_index("ix_document_annotations_user_id", "document_annotations")
op.drop_index("ix_document_annotations_file_id", "document_annotations")
op.drop_index("ix_document_annotations_id", "document_annotations")
op.drop_table("document_annotations")
if "document_comments" in existing_tables:
op.drop_index("ix_document_comments_parent_id", "document_comments")
op.drop_index("ix_document_comments_user_id", "document_comments")
op.drop_index("ix_document_comments_file_id", "document_comments")
op.drop_index("ix_document_comments_id", "document_comments")
op.drop_table("document_comments")
+2
View File
@@ -65,6 +65,8 @@ from app.models import ( # noqa: F401, E402
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentAnnotation,
DocumentComment,
DocumentMetadata,
FileRecord,
Pipeline,
+525
View File
@@ -0,0 +1,525 @@
"""Tests for the document comments and annotations API."""
import pytest
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
def _create_file(db_session, owner_id="testuser") -> FileRecord:
"""Helper to create a minimal FileRecord for testing."""
f = FileRecord(
owner_id=owner_id,
filehash="abc123",
original_filename="test.pdf",
local_filename="test.pdf",
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
# ---------------------------------------------------------------------------
# Comment tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListComments:
"""Tests for GET /api/files/{file_id}/comments."""
def test_list_comments_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["comments"] == []
assert data["total"] == 0
def test_list_comments_file_not_found(self, client):
resp = client.get("/api/files/99999/comments")
assert resp.status_code == 404
def test_list_comments_threaded(self, client, db_session):
f = _create_file(db_session)
# Root comment
c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello")
db_session.add(c1)
db_session.commit()
db_session.refresh(c1)
# Reply
c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back")
db_session.add(c2)
db_session.commit()
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["comments"]) == 1 # only root
assert len(data["comments"][0]["replies"]) == 1
assert data["comments"][0]["replies"][0]["body"] == "Hi back"
@pytest.mark.unit
class TestCreateComment:
"""Tests for POST /api/files/{file_id}/comments."""
def test_create_comment(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Great document!"},
)
assert resp.status_code == 201
data = resp.json()
assert data["body"] == "Great document!"
assert data["file_id"] == f.id
assert data["parent_id"] is None
def test_create_comment_with_mention(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @alice please review"},
)
assert resp.status_code == 201
data = resp.json()
assert data["mentions"] == ["alice"]
def test_create_comment_with_parent(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="user1", body="root")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": c.id},
)
assert resp.status_code == 201
assert resp.json()["parent_id"] == c.id
def test_create_comment_parent_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": 99999},
)
assert resp.status_code == 404
def test_create_comment_empty_body(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": " "},
)
assert resp.status_code == 422
def test_create_comment_file_not_found(self, client):
resp = client.post(
"/api/files/99999/comments",
json={"body": "test"},
)
assert resp.status_code == 404
def test_create_comment_body_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "x" * 10_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateComment:
"""Tests for PUT /api/files/{file_id}/comments/{comment_id}."""
def test_update_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new body @bob"},
)
assert resp.status_code == 200
data = resp.json()
assert data["body"] == "new body @bob"
assert data["mentions"] == ["bob"]
def test_update_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/comments/99999",
json={"body": "new"},
)
assert resp.status_code == 404
def test_update_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="old")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new"},
)
assert resp.status_code == 403
@pytest.mark.unit
class TestDeleteComment:
"""Tests for DELETE /api/files/{file_id}/comments/{comment_id}."""
def test_delete_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 204
# Verify deleted
assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None
def test_delete_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/comments/99999")
assert resp.status_code == 404
def test_delete_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="mine")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 403
@pytest.mark.unit
class TestResolveComment:
"""Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve."""
def test_resolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is True
def test_unresolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True)
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": False},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is False
def test_resolve_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.patch(
f"/api/files/{f.id}/comments/99999/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Annotation tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListAnnotations:
"""Tests for GET /api/files/{file_id}/annotations."""
def test_list_annotations_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/annotations")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["annotations"] == []
assert data["total"] == 0
def test_list_annotations_file_not_found(self, client):
resp = client.get("/api/files/99999/annotations")
assert resp.status_code == 404
@pytest.mark.unit
class TestCreateAnnotation:
"""Tests for POST /api/files/{file_id}/annotations."""
def test_create_annotation(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 1,
"x": 100.0,
"y": 200.0,
"content": "Important note",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["page"] == 1
assert data["x"] == 100.0
assert data["y"] == 200.0
assert data["content"] == "Important note"
assert data["annotation_type"] == "note"
def test_create_annotation_with_all_fields(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 2,
"x": 50.0,
"y": 100.0,
"width": 200.0,
"height": 30.0,
"content": "Highlighted text",
"annotation_type": "highlight",
"color": "#ffff00",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["annotation_type"] == "highlight"
assert data["color"] == "#ffff00"
assert data["width"] == 200.0
assert data["height"] == 30.0
def test_create_annotation_file_not_found(self, client):
resp = client.post(
"/api/files/99999/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 404
def test_create_annotation_empty_content(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": " "},
)
assert resp.status_code == 422
def test_create_annotation_invalid_page(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 0, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 422
def test_create_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"},
)
assert resp.status_code == 422
def test_create_annotation_content_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateAnnotation:
"""Tests for PUT /api/files/{file_id}/annotations/{annotation_id}."""
def test_update_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "updated note", "color": "#00ff00"},
)
assert resp.status_code == 200
data = resp.json()
assert data["content"] == "updated note"
assert data["color"] == "#00ff00"
def test_update_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/annotations/99999",
json={"content": "new"},
)
assert resp.status_code == 404
def test_update_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "hijack"},
)
assert resp.status_code == 403
def test_update_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"annotation_type": "invalid"},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestDeleteAnnotation:
"""Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}."""
def test_delete_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(
file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete"
)
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 204
assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None
def test_delete_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/annotations/99999")
assert resp.status_code == 404
def test_delete_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Mentionable users tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListMentionableUsers:
"""Tests for GET /api/users/mentionable."""
def test_list_mentionable_empty(self, client, db_session):
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
assert resp.json() == []
def test_list_mentionable_users(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A")
p2 = UserProfile(user_id="bob", display_name="Bob B")
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["user_id"] == "alice"
assert data[1]["user_id"] == "bob"
def test_blocked_users_excluded(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False)
p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True)
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["user_id"] == "alice"
# ---------------------------------------------------------------------------
# Mention extraction helper tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestExtractMentions:
"""Tests for the _extract_mentions helper function."""
def test_no_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hello world") == []
def test_single_mention(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hey @alice check this") == ["alice"]
def test_multiple_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"]
def test_duplicate_mentions(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@alice and @alice again")
assert result == ["alice"]
def test_mention_with_dots_and_dashes(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@user.name @user-name")
assert result == ["user.name", "user-name"]