From 30a124d85b8b1a67ddc115b63fbd0961c45cf85b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:18:19 +0000 Subject: [PATCH 1/5] Initial plan From ad795e200ad2d4d0fc8381ed2903f4a2a905243f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:28:01 +0000 Subject: [PATCH 2/5] 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 --- app/api/__init__.py | 2 + app/api/comments.py | 679 ++++++++++++++++++ app/models.py | 45 ++ docs/API.md | 199 +++++ frontend/translations/en.json | 29 + migrations/env.py | 2 + ...1_add_document_comments_and_annotations.py | 88 +++ tests/conftest.py | 2 + tests/test_comments.py | 525 ++++++++++++++ 9 files changed, 1571 insertions(+) create mode 100644 app/api/comments.py create mode 100644 migrations/versions/041_add_document_comments_and_annotations.py create mode 100644 tests/test_comments.py diff --git a/app/api/__init__.py b/app/api/__init__.py index de5c41e8..325b514c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 00000000..b4a39220 --- /dev/null +++ b/app/api/comments.py @@ -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 + ] diff --git a/app/models.py b/app/models.py index 94697fc4..7fcc72a2 100644 --- a/app/models.py +++ b/app/models.py @@ -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()) diff --git a/docs/API.md b/docs/API.md index cfc1a844..97b79294 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" } +] +``` diff --git a/frontend/translations/en.json b/frontend/translations/en.json index dbb9f8fb..9f5b042d 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -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", diff --git a/migrations/env.py b/migrations/env.py index 14566b67..79d91540 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -26,6 +26,8 @@ from app.models import ( # noqa: F401 BackupRecord, ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileProcessingStep, FileRecord, diff --git a/migrations/versions/041_add_document_comments_and_annotations.py b/migrations/versions/041_add_document_comments_and_annotations.py new file mode 100644 index 00000000..f0aac21a --- /dev/null +++ b/migrations/versions/041_add_document_comments_and_annotations.py @@ -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") diff --git a/tests/conftest.py b/tests/conftest.py index fd110d82..b8b654a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,6 +65,8 @@ from app.models import ( # noqa: F401, E402 AutomationHook, ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileRecord, Pipeline, diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 00000000..dda56809 --- /dev/null +++ b/tests/test_comments.py @@ -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"] From 4b6412734b53a345e41d0531f03ff1c2318e9f55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:30:05 +0000 Subject: [PATCH 3/5] fix(comments): address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add onupdate=sa.func.now() to migration updated_at columns - Use UserProfile.is_blocked.is_(False) instead of == False - Fix British to American spelling (organised → organized) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/3894af37-0f19-457b-8811-f1feb18b17ef --- app/api/comments.py | 9 ++------- docs/API.md | 2 +- .../041_add_document_comments_and_annotations.py | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/api/comments.py b/app/api/comments.py index b4a39220..cb4451eb 100644 --- a/app/api/comments.py +++ b/app/api/comments.py @@ -138,7 +138,7 @@ def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]: @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. + """List all comments for a document, organized into threads. Returns a threaded tree where top-level comments contain nested ``replies``. @@ -663,12 +663,7 @@ def list_mentionable_users(request: Request, db: DbSession): 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() - ) + profiles = db.query(UserProfile).filter(UserProfile.is_blocked.is_(False)).order_by(UserProfile.display_name).all() return [ { diff --git a/docs/API.md b/docs/API.md index 97b79294..6ee5f41f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2920,7 +2920,7 @@ Threaded comments and PDF annotations for document collaboration. **GET** `/api/files/{file_id}/comments` -Returns all comments for a document, organised into a threaded tree. +Returns all comments for a document, organized into a threaded tree. **Response (200):** ```json diff --git a/migrations/versions/041_add_document_comments_and_annotations.py b/migrations/versions/041_add_document_comments_and_annotations.py index f0aac21a..3bb84339 100644 --- a/migrations/versions/041_add_document_comments_and_annotations.py +++ b/migrations/versions/041_add_document_comments_and_annotations.py @@ -34,7 +34,7 @@ def upgrade() -> None: 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.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()), sa.ForeignKeyConstraint(["file_id"], ["files.id"]), sa.ForeignKeyConstraint(["parent_id"], ["document_comments.id"]), sa.PrimaryKeyConstraint("id"), @@ -59,7 +59,7 @@ def upgrade() -> None: 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.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()), sa.ForeignKeyConstraint(["file_id"], ["files.id"]), sa.PrimaryKeyConstraint("id"), ) From a7a88218c391ba3642ef60b433e5e6a657bea239 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:45:24 +0000 Subject: [PATCH 4/5] feat(ui): add comments and annotations UX to file detail page Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/20bbea80-fdfd-42d2-b0c2-756ca25b240b --- docs/UserGuide.md | 58 +++ frontend/static/js/annotations.js | 295 +++++++++++++ frontend/static/js/comments.js | 464 ++++++++++++++++++++ frontend/templates/file_detail.html | 636 ++++++++++++++++++++++++++++ tests/test_comments_ui.py | 123 ++++++ 5 files changed, 1576 insertions(+) create mode 100644 frontend/static/js/annotations.js create mode 100644 frontend/static/js/comments.js create mode 100644 tests/test_comments_ui.py diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 8f791cfb..8ea9486f 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -705,6 +705,64 @@ You can test your rules without actually routing a document using the **evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full API reference, see [API Documentation](API.md#routing-rules). +## Comments & Annotations + +The file detail page includes a **collaboration panel** for threaded +comments and PDF annotations, allowing team members to discuss documents +directly within DocuElevate. + +### Comments + +The **Comments** panel is on the left side of the collaboration section at +the bottom of the file detail page. + +#### Viewing Comments +Open any file's detail page (`/files/{id}/detail`). Existing comments load +automatically, displayed in a threaded tree — replies are nested under their +parent. + +#### Adding a Comment +1. Type your comment in the text area at the bottom of the Comments panel. +2. Use `@username` to mention another user — an autocomplete dropdown + appears as you type after the `@` symbol. Use arrow keys and Enter to + select a user. +3. Click **Add comment** to post. + +#### Replying to a Comment +Click the **Reply** button on any top-level comment. A reply text area +appears inline; type your response and click **Reply** to post. + +#### Editing & Deleting +You can edit or delete your own comments using the **Edit** and trash +buttons. Edits re-extract @mentions automatically. + +#### Resolving Threads +Click **Resolve** on a top-level comment to mark the thread as resolved +(shown with a green badge). Click **Reopen** to re-open it. + +### Annotations + +The **Annotations** panel is on the right side of the collaboration +section. + +#### Adding an Annotation +1. Type the annotation content in the text area. +2. Set the **Page** number the annotation refers to. +3. Choose a **Type**: Note, Highlight, Underline, or Strikethrough. +4. Pick a **Color** using the color picker. +5. Click **Add annotation** to save. + +#### Editing & Deleting +You can edit or delete your own annotations using the pencil and trash +buttons. When editing, you can also change the annotation type. + +### @Mention Autocomplete + +When typing `@` followed by characters in the comment input, an +autocomplete dropdown shows matching users (sourced from the +`/api/users/mentionable` endpoint). Navigate with arrow keys and press +Enter or click to insert the mention. + ## API Access For programmatic access, DocuElevate provides a comprehensive REST API: diff --git a/frontend/static/js/annotations.js b/frontend/static/js/annotations.js new file mode 100644 index 00000000..985b4135 --- /dev/null +++ b/frontend/static/js/annotations.js @@ -0,0 +1,295 @@ +// frontend/static/js/annotations.js +// Annotations panel — CRUD for PDF page annotations + +(function () { + 'use strict'; + + var _fileId = null; + var _currentUserId = null; + var _i18n = {}; + + // ------------------------------------------------------------------------- + // Initialisation + // ------------------------------------------------------------------------- + + /** + * Bootstrap the annotations panel. + * @param {number} fileId + * @param {string} currentUserId + * @param {object} i18n + */ + function initAnnotations(fileId, currentUserId, i18n) { + _fileId = fileId; + _currentUserId = currentUserId; + _i18n = i18n || {}; + _loadAnnotations(); + + var form = document.getElementById('annotation-form'); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + _createAnnotation(); + }); + } + } + + // ------------------------------------------------------------------------- + // Data fetching + // ------------------------------------------------------------------------- + + function _loadAnnotations() { + var container = document.getElementById('annotations-list'); + if (!container) return; + container.innerHTML = '
' + (_i18n.empty || 'No annotations yet') + '
'; + }); + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function _renderAnnotations(annotations, container) { + container.innerHTML = ''; + if (!annotations.length) { + container.innerHTML = '' + + (_i18n.empty || 'No annotations yet') + '
'; + return; + } + for (var i = 0; i < annotations.length; i++) { + container.appendChild(_buildAnnotationNode(annotations[i])); + } + } + + function _buildAnnotationNode(ann) { + var div = document.createElement('div'); + div.className = 'annotation-item'; + div.setAttribute('data-annotation-id', ann.id); + + // Type badge + color indicator + var header = document.createElement('div'); + header.className = 'annotation-header'; + + var typeBadge = document.createElement('span'); + typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type; + typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type; + + var pageInfo = document.createElement('span'); + pageInfo.className = 'annotation-page'; + pageInfo.innerHTML = ' ' + + (_i18n.page || 'Page') + ' ' + ann.page; + + header.appendChild(typeBadge); + if (ann.color) { + var colorDot = document.createElement('span'); + colorDot.className = 'annotation-color-dot'; + colorDot.style.backgroundColor = ann.color; + colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color); + header.appendChild(colorDot); + } + header.appendChild(pageInfo); + + div.appendChild(header); + + // Content + var content = document.createElement('div'); + content.className = 'annotation-content'; + content.id = 'annotation-content-' + ann.id; + content.textContent = ann.content; + div.appendChild(content); + + // Meta + var meta = document.createElement('div'); + meta.className = 'annotation-meta'; + + var author = document.createElement('span'); + author.className = 'annotation-author'; + author.textContent = ann.user_id; + + var time = document.createElement('time'); + time.className = 'annotation-time'; + time.setAttribute('datetime', ann.created_at); + time.textContent = _formatDate(ann.created_at); + + meta.appendChild(author); + meta.appendChild(time); + div.appendChild(meta); + + // Actions (author only) + if (ann.user_id === _currentUserId) { + var actions = document.createElement('div'); + actions.className = 'annotation-actions'; + + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'annotation-action-btn'; + editBtn.innerHTML = ''; + editBtn.setAttribute('aria-label', 'Edit annotation'); + editBtn.addEventListener('click', function () { _showEditForm(ann); }); + actions.appendChild(editBtn); + + var deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger'; + deleteBtn.innerHTML = ''; + deleteBtn.setAttribute('aria-label', 'Delete annotation'); + deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); }); + actions.appendChild(deleteBtn); + + div.appendChild(actions); + } + + return div; + } + + function _formatDate(iso) { + if (!iso) return ''; + try { + var d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch (_e) { + return iso; + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + function _createAnnotation() { + var content = document.getElementById('annotation-content-input'); + var page = document.getElementById('annotation-page-input'); + var type = document.getElementById('annotation-type-input'); + var color = document.getElementById('annotation-color-input'); + + if (!content || !content.value.trim()) return; + + var payload = { + content: content.value.trim(), + page: parseInt(page ? page.value : '1', 10) || 1, + annotation_type: type ? type.value : 'note', + color: color ? color.value : null, + x: 0, + y: 0, + width: 0, + height: 0, + }; + + fetch('/api/files/' + _fileId + '/annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + return r.json(); + }) + .then(function () { + content.value = ''; + if (page) page.value = '1'; + _loadAnnotations(); + }) + .catch(function () {}); + } + + function _deleteAnnotation(annotationId) { + if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return; + + fetch('/api/files/' + _fileId + '/annotations/' + annotationId, { + method: 'DELETE', + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadAnnotations(); + }) + .catch(function () {}); + } + + function _showEditForm(ann) { + var contentDiv = document.getElementById('annotation-content-' + ann.id); + if (!contentDiv) return; + if (contentDiv.querySelector('.annotation-edit-form')) return; + + var originalText = contentDiv.textContent; + contentDiv.textContent = ''; + + var form = document.createElement('div'); + form.className = 'annotation-edit-form'; + + var textarea = document.createElement('textarea'); + textarea.className = 'annotation-textarea'; + textarea.value = ann.content; + textarea.rows = 3; + textarea.setAttribute('aria-label', 'Edit annotation'); + + var typeSelect = document.createElement('select'); + typeSelect.className = 'annotation-select'; + typeSelect.setAttribute('aria-label', 'Annotation type'); + var types = ['note', 'highlight', 'underline', 'strikethrough']; + for (var i = 0; i < types.length; i++) { + var opt = document.createElement('option'); + opt.value = types[i]; + opt.textContent = _i18n['type_' + types[i]] || types[i]; + if (types[i] === ann.annotation_type) opt.selected = true; + typeSelect.appendChild(opt); + } + + var btns = document.createElement('div'); + btns.className = 'annotation-edit-btns'; + + var saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'annotation-submit-btn'; + saveBtn.textContent = _i18n.save || 'Save'; + saveBtn.addEventListener('click', function () { + var newContent = textarea.value.trim(); + if (!newContent) return; + fetch('/api/files/' + _fileId + '/annotations/' + ann.id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: newContent, + annotation_type: typeSelect.value, + page: ann.page, + x: ann.x, + y: ann.y, + }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadAnnotations(); + }) + .catch(function () { + contentDiv.textContent = originalText; + }); + }); + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'annotation-cancel-btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', function () { + contentDiv.textContent = originalText; + }); + + btns.appendChild(saveBtn); + btns.appendChild(cancelBtn); + + form.appendChild(textarea); + form.appendChild(typeSelect); + form.appendChild(btns); + contentDiv.appendChild(form); + textarea.focus(); + } + + // Expose + window.initAnnotations = initAnnotations; +})(); diff --git a/frontend/static/js/comments.js b/frontend/static/js/comments.js new file mode 100644 index 00000000..e0cf9259 --- /dev/null +++ b/frontend/static/js/comments.js @@ -0,0 +1,464 @@ +// frontend/static/js/comments.js +// Comments panel — threaded comments with @mention autocomplete + +(function () { + 'use strict'; + + var _fileId = null; + var _currentUserId = null; + var _i18n = {}; + var _mentionableUsers = []; + + // ------------------------------------------------------------------------- + // Initialisation + // ------------------------------------------------------------------------- + + /** + * Bootstrap the comments panel. + * @param {number} fileId + * @param {string} currentUserId + * @param {object} i18n + */ + function initComments(fileId, currentUserId, i18n) { + _fileId = fileId; + _currentUserId = currentUserId; + _i18n = i18n || {}; + _loadComments(); + _loadMentionableUsers(); + + var form = document.getElementById('comment-form'); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + _submitComment(null); + }); + } + + var input = document.getElementById('comment-input'); + if (input) { + input.addEventListener('input', function () { + _handleMentionInput(this); + }); + input.addEventListener('keydown', function (e) { + _handleMentionKeydown(e); + }); + // Close dropdown when clicking outside + document.addEventListener('click', function (e) { + var dropdown = document.getElementById('mention-dropdown'); + if (dropdown && !dropdown.contains(e.target) && e.target !== input) { + dropdown.classList.add('hidden'); + } + }); + } + } + + // ------------------------------------------------------------------------- + // Data fetching + // ------------------------------------------------------------------------- + + function _loadComments() { + var container = document.getElementById('comments-list'); + if (!container) return; + container.innerHTML = '' + (_i18n.empty || 'No comments yet') + '
'; + }); + } + + function _loadMentionableUsers() { + fetch('/api/users/mentionable') + .then(function (r) { return r.json(); }) + .then(function (users) { + _mentionableUsers = users || []; + }) + .catch(function () { + _mentionableUsers = []; + }); + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function _renderComments(comments, container) { + container.innerHTML = ''; + if (!comments.length) { + container.innerHTML = '' + + (_i18n.empty || 'No comments yet') + '
'; + return; + } + for (var i = 0; i < comments.length; i++) { + container.appendChild(_buildCommentNode(comments[i], false)); + } + } + + function _buildCommentNode(comment, isReply) { + var div = document.createElement('div'); + div.className = 'comment-item' + (isReply ? ' comment-reply' : '') + + (comment.is_resolved ? ' comment-resolved' : ''); + div.setAttribute('data-comment-id', comment.id); + + // Header + var header = document.createElement('div'); + header.className = 'comment-header'; + + var author = document.createElement('span'); + author.className = 'comment-author'; + author.textContent = comment.user_id; + + var time = document.createElement('time'); + time.className = 'comment-time'; + time.setAttribute('datetime', comment.created_at); + time.textContent = _formatDate(comment.created_at); + + header.appendChild(author); + header.appendChild(time); + + if (comment.is_resolved) { + var badge = document.createElement('span'); + badge.className = 'comment-resolved-badge'; + badge.innerHTML = ' ' + (_i18n.resolved || 'Resolved'); + header.appendChild(badge); + } + + div.appendChild(header); + + // Body + var bodyDiv = document.createElement('div'); + bodyDiv.className = 'comment-body'; + bodyDiv.id = 'comment-body-' + comment.id; + bodyDiv.innerHTML = _renderMentions(comment.body); + div.appendChild(bodyDiv); + + // Actions + var actions = document.createElement('div'); + actions.className = 'comment-actions'; + + // Reply button (only for top-level) + if (!isReply) { + var replyBtn = document.createElement('button'); + replyBtn.type = 'button'; + replyBtn.className = 'comment-action-btn'; + replyBtn.innerHTML = ' ' + (_i18n.add_reply || 'Reply'); + replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply'); + replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); }); + actions.appendChild(replyBtn); + + // Resolve / Unresolve + var resolveBtn = document.createElement('button'); + resolveBtn.type = 'button'; + resolveBtn.className = 'comment-action-btn'; + if (comment.is_resolved) { + resolveBtn.innerHTML = ' ' + (_i18n.unresolve || 'Reopen'); + resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen'); + } else { + resolveBtn.innerHTML = ' ' + (_i18n.resolve || 'Resolve'); + resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve'); + } + resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); }); + actions.appendChild(resolveBtn); + } + + // Edit (author only) + if (comment.user_id === _currentUserId) { + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'comment-action-btn'; + editBtn.innerHTML = ' ' + (_i18n.edit || 'Edit'); + editBtn.setAttribute('aria-label', _i18n.edit || 'Edit'); + editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); }); + actions.appendChild(editBtn); + + // Delete + var deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'comment-action-btn comment-action-btn--danger'; + deleteBtn.innerHTML = ''; + deleteBtn.setAttribute('aria-label', 'Delete comment'); + deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); }); + actions.appendChild(deleteBtn); + } + + div.appendChild(actions); + + // Replies + if (comment.replies && comment.replies.length) { + var repliesDiv = document.createElement('div'); + repliesDiv.className = 'comment-replies'; + for (var j = 0; j < comment.replies.length; j++) { + repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true)); + } + div.appendChild(repliesDiv); + } + + return div; + } + + function _renderMentions(text) { + if (!text) return ''; + // Escape HTML first + var escaped = text.replace(/&/g, '&').replace(//g, '>'); + // Highlight @mentions + return escaped.replace(/@([\w.\-]+)/g, '@$1'); + } + + function _formatDate(iso) { + if (!iso) return ''; + try { + var d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch (_e) { + return iso; + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + function _submitComment(parentId) { + var inputId = parentId ? 'reply-input-' + parentId : 'comment-input'; + var input = document.getElementById(inputId); + if (!input) return; + var body = input.value.trim(); + if (!body) return; + + var payload = { body: body }; + if (parentId) payload.parent_id = parentId; + + fetch('/api/files/' + _fileId + '/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + return r.json(); + }) + .then(function () { + input.value = ''; + _loadComments(); + }) + .catch(function () { + // Silently fail — the CSRF wrapper in common.js handles token injection + }); + } + + function _toggleResolve(commentId, resolve) { + fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resolve: resolve }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () {}); + } + + function _deleteComment(commentId) { + if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return; + + fetch('/api/files/' + _fileId + '/comments/' + commentId, { + method: 'DELETE', + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () {}); + } + + function _showReplyForm(commentId, containerNode) { + // Remove existing reply forms + var existing = containerNode.querySelector('.comment-reply-form'); + if (existing) { existing.remove(); return; } + + var form = document.createElement('div'); + form.className = 'comment-reply-form'; + + var textarea = document.createElement('textarea'); + textarea.id = 'reply-input-' + commentId; + textarea.className = 'comment-textarea'; + textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...'; + textarea.rows = 2; + textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...'); + + var submitBtn = document.createElement('button'); + submitBtn.type = 'button'; + submitBtn.className = 'comment-submit-btn'; + submitBtn.textContent = _i18n.add_reply || 'Reply'; + submitBtn.addEventListener('click', function () { _submitComment(commentId); }); + + form.appendChild(textarea); + form.appendChild(submitBtn); + + // Insert before the replies section or at end + var repliesDiv = containerNode.querySelector('.comment-replies'); + if (repliesDiv) { + containerNode.insertBefore(form, repliesDiv); + } else { + containerNode.appendChild(form); + } + textarea.focus(); + } + + function _showEditForm(commentId, currentBody, containerNode) { + var bodyDiv = document.getElementById('comment-body-' + commentId); + if (!bodyDiv) return; + + // Already editing? + if (bodyDiv.querySelector('.comment-edit-form')) return; + + var originalHTML = bodyDiv.innerHTML; + bodyDiv.innerHTML = ''; + + var form = document.createElement('div'); + form.className = 'comment-edit-form'; + + var textarea = document.createElement('textarea'); + textarea.className = 'comment-textarea'; + textarea.value = currentBody; + textarea.rows = 3; + textarea.setAttribute('aria-label', _i18n.edit || 'Edit'); + + var btns = document.createElement('div'); + btns.className = 'comment-edit-btns'; + + var saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'comment-submit-btn'; + saveBtn.textContent = _i18n.save || 'Save'; + saveBtn.addEventListener('click', function () { + var newBody = textarea.value.trim(); + if (!newBody) return; + fetch('/api/files/' + _fileId + '/comments/' + commentId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: newBody }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () { + bodyDiv.innerHTML = originalHTML; + }); + }); + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'comment-cancel-btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', function () { + bodyDiv.innerHTML = originalHTML; + }); + + btns.appendChild(saveBtn); + btns.appendChild(cancelBtn); + form.appendChild(textarea); + form.appendChild(btns); + bodyDiv.appendChild(form); + textarea.focus(); + } + + // ------------------------------------------------------------------------- + // @mention autocomplete + // ------------------------------------------------------------------------- + + function _handleMentionInput(input) { + var val = input.value; + var cursorPos = input.selectionStart; + var textBefore = val.substring(0, cursorPos); + var match = textBefore.match(/@([\w.\-]*)$/); + + var dropdown = document.getElementById('mention-dropdown'); + if (!dropdown) return; + + if (!match) { + dropdown.classList.add('hidden'); + return; + } + + var query = match[1].toLowerCase(); + var filtered = _mentionableUsers.filter(function (u) { + return u.user_id.toLowerCase().indexOf(query) !== -1 || + (u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1); + }).slice(0, 8); + + if (!filtered.length) { + dropdown.classList.add('hidden'); + return; + } + + dropdown.innerHTML = ''; + for (var i = 0; i < filtered.length; i++) { + (function (user) { + var item = document.createElement('button'); + item.type = 'button'; + item.className = 'mention-item'; + item.setAttribute('role', 'option'); + item.innerHTML = '' + _escapeHtml(user.user_id) + '' + + (user.display_name ? '' + _escapeHtml(user.display_name) + '' : ''); + item.addEventListener('click', function () { + _insertMention(input, match.index, cursorPos, user.user_id); + dropdown.classList.add('hidden'); + }); + dropdown.appendChild(item); + })(filtered[i]); + } + dropdown.classList.remove('hidden'); + } + + function _handleMentionKeydown(e) { + var dropdown = document.getElementById('mention-dropdown'); + if (!dropdown || dropdown.classList.contains('hidden')) return; + + if (e.key === 'Escape') { + dropdown.classList.add('hidden'); + e.preventDefault(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + var items = dropdown.querySelectorAll('.mention-item'); + var focused = dropdown.querySelector('.mention-item:focus'); + var idx = Array.prototype.indexOf.call(items, focused); + if (e.key === 'ArrowDown') { + idx = (idx + 1) % items.length; + } else { + idx = idx <= 0 ? items.length - 1 : idx - 1; + } + items[idx].focus(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + var active = dropdown.querySelector('.mention-item:focus'); + if (active) { + active.click(); + e.preventDefault(); + } + } + } + + function _insertMention(input, matchStart, cursorPos, userId) { + var before = input.value.substring(0, matchStart); + var after = input.value.substring(cursorPos); + input.value = before + '@' + userId + ' ' + after; + var newPos = matchStart + userId.length + 2; + input.setSelectionRange(newPos, newPos); + input.focus(); + } + + function _escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // Expose + window.initComments = initComments; +})(); diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html index 2e9d3ae1..9bb08b4e 100644 --- a/frontend/templates/file_detail.html +++ b/frontend/templates/file_detail.html @@ -1091,6 +1091,513 @@ } {% endif %} + + + {% endblock %} {% block content %} @@ -1777,6 +2284,135 @@ {% endif %} + +