- Added CommentsPanel component for managing comments on documents. - Integrated comment creation, deletion, and resolution functionalities. - Implemented activity tracking for document interactions. - Enhanced SketchPage with editing and saving capabilities. - Introduced template management API endpoints for creating, sharing, and deleting templates. - Added runtime validation report for frontend and backend. - Created GettingStartedPanel for user onboarding with template options. - Implemented Playwright E2E tests for accessibility and export functionality.
168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
import uuid
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
from pymongo import ReturnDocument
|
|
|
|
from app.services.document_service import access_query
|
|
|
|
COLLECTION = "comments"
|
|
NOTIFICATIONS = "notifications"
|
|
ACTIVITY = "activity"
|
|
|
|
|
|
async def ensure_collaboration_indexes(db: AsyncIOMotorDatabase) -> None:
|
|
await db[COLLECTION].create_index([("document_id", 1), ("created_at", 1)])
|
|
await db[NOTIFICATIONS].create_index([("user_id", 1), ("created_at", -1)])
|
|
await db[ACTIVITY].create_index([("document_id", 1), ("created_at", -1)])
|
|
|
|
|
|
def _serialize(comment: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": comment["_id"],
|
|
"document_id": comment["document_id"],
|
|
"body": comment["body"],
|
|
"parent_id": comment.get("parent_id"),
|
|
"author_id": comment["author_id"],
|
|
"author_name": comment["author_name"],
|
|
"created_at": comment["created_at"],
|
|
"updated_at": comment["updated_at"],
|
|
"resolved": comment.get("resolved", False),
|
|
}
|
|
|
|
|
|
async def list_comments(db: AsyncIOMotorDatabase, document_id: str, user_id: int) -> list[dict[str, Any]] | None:
|
|
document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
|
|
if document is None:
|
|
return None
|
|
cursor = db[COLLECTION].find({"document_id": document_id}).sort("created_at", 1)
|
|
return [_serialize(comment) async for comment in cursor]
|
|
|
|
|
|
async def create_comment(
|
|
db: AsyncIOMotorDatabase,
|
|
document_id: str,
|
|
user_id: int,
|
|
author_name: str,
|
|
body: str,
|
|
parent_id: str | None,
|
|
mentioned_users: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
|
|
if document is None:
|
|
return None
|
|
if parent_id:
|
|
parent = await db[COLLECTION].find_one({"_id": parent_id, "document_id": document_id})
|
|
if parent is None:
|
|
return None
|
|
now = datetime.now(UTC)
|
|
comment = {
|
|
"_id": str(uuid.uuid4()),
|
|
"document_id": document_id,
|
|
"body": body.strip(),
|
|
"parent_id": parent_id,
|
|
"author_id": user_id,
|
|
"author_name": author_name,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"resolved": False,
|
|
}
|
|
await db[COLLECTION].insert_one(comment)
|
|
await db[ACTIVITY].insert_one({
|
|
"_id": str(uuid.uuid4()),
|
|
"document_id": document_id,
|
|
"kind": "comment_created",
|
|
"message": f"{author_name} commented on the page",
|
|
"actor_name": author_name,
|
|
"created_at": now,
|
|
})
|
|
for mentioned in mentioned_users or []:
|
|
await db[NOTIFICATIONS].insert_one({
|
|
"_id": str(uuid.uuid4()),
|
|
"user_id": mentioned["id"],
|
|
"kind": "mention",
|
|
"message": f"{author_name} mentioned you in a comment",
|
|
"document_id": document_id,
|
|
"comment_id": comment["_id"],
|
|
"actor_name": author_name,
|
|
"read": False,
|
|
"created_at": now,
|
|
})
|
|
return _serialize(comment)
|
|
|
|
|
|
async def delete_comment(db: AsyncIOMotorDatabase, comment_id: str, user_id: int) -> bool:
|
|
comment = await db[COLLECTION].find_one({"_id": comment_id, "author_id": user_id})
|
|
if comment is None:
|
|
return False
|
|
result = await db[COLLECTION].delete_one({"_id": comment_id, "author_id": user_id})
|
|
return result.deleted_count == 1
|
|
|
|
|
|
async def resolve_comment(
|
|
db: AsyncIOMotorDatabase,
|
|
document_id: str,
|
|
comment_id: str,
|
|
user_id: int,
|
|
resolved: bool,
|
|
actor_name: str,
|
|
) -> dict[str, Any] | None:
|
|
document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
|
|
if document is None:
|
|
return None
|
|
now = datetime.now(UTC)
|
|
comment = await db[COLLECTION].find_one_and_update(
|
|
{"_id": comment_id, "document_id": document_id},
|
|
{"$set": {"resolved": resolved, "updated_at": now}},
|
|
return_document=ReturnDocument.AFTER,
|
|
)
|
|
if comment is None:
|
|
return None
|
|
await db[ACTIVITY].insert_one({
|
|
"_id": str(uuid.uuid4()),
|
|
"document_id": document_id,
|
|
"kind": "comment_resolved" if resolved else "comment_reopened",
|
|
"message": f"{actor_name} {'resolved' if resolved else 'reopened'} a comment",
|
|
"actor_name": actor_name,
|
|
"created_at": now,
|
|
})
|
|
return _serialize(comment)
|
|
|
|
|
|
async def list_notifications(db: AsyncIOMotorDatabase, user_id: int) -> list[dict[str, Any]]:
|
|
cursor = db[NOTIFICATIONS].find({"user_id": user_id}).sort("created_at", -1).limit(50)
|
|
return [{
|
|
"id": item["_id"],
|
|
"kind": item["kind"],
|
|
"message": item["message"],
|
|
"document_id": item.get("document_id"),
|
|
"comment_id": item.get("comment_id"),
|
|
"actor_name": item["actor_name"],
|
|
"read": item.get("read", False),
|
|
"created_at": item["created_at"],
|
|
} async for item in cursor]
|
|
|
|
|
|
async def mark_notifications_read(db: AsyncIOMotorDatabase, user_id: int) -> None:
|
|
await db[NOTIFICATIONS].update_many({"user_id": user_id, "read": False}, {"$set": {"read": True}})
|
|
|
|
|
|
async def list_activity(db: AsyncIOMotorDatabase, document_id: str, user_id: int) -> list[dict[str, Any]] | None:
|
|
document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
|
|
if document is None:
|
|
return None
|
|
cursor = db[ACTIVITY].find({"document_id": document_id}).sort("created_at", -1).limit(100)
|
|
return [{
|
|
"id": item["_id"],
|
|
"document_id": item["document_id"],
|
|
"kind": item["kind"],
|
|
"message": item["message"],
|
|
"actor_name": item["actor_name"],
|
|
"created_at": item["created_at"],
|
|
} async for item in cursor]
|
|
|
|
|
|
def mention_names(body: str) -> list[str]:
|
|
return list(dict.fromkeys(re.findall(r"(?<![\w@])@([A-Za-z0-9_.-]{2,50})", body)))
|