feat: implement comments and activity panel in workspace
- 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.
This commit is contained in:
parent
6e354ba041
commit
809235e962
41 changed files with 2840 additions and 285 deletions
168
backend/app/services/collaboration_service.py
Normal file
168
backend/app/services/collaboration_service.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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)))
|
||||
|
|
@ -30,6 +30,7 @@ def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
|
|||
"title": doc["title"],
|
||||
"template_id": doc.get("template_id"),
|
||||
"stead_id": doc.get("stead_id"),
|
||||
"parent_page_id": doc.get("parent_page_id"),
|
||||
"content": doc.get("content", {}),
|
||||
"created_by": doc["created_by"],
|
||||
"created_at": doc["created_at"],
|
||||
|
|
@ -61,6 +62,10 @@ async def create_document(
|
|||
created_by: str,
|
||||
data: DocumentCreate,
|
||||
) -> dict[str, Any]:
|
||||
if data.parent_page_id:
|
||||
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(owner_id)})
|
||||
if parent is None or parent.get("type") == "stead":
|
||||
raise ValueError("Parent page is not accessible or is not a page")
|
||||
now = datetime.now(UTC)
|
||||
doc_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
|
|
@ -70,6 +75,7 @@ async def create_document(
|
|||
"title": data.title,
|
||||
"template_id": data.template_id,
|
||||
"stead_id": data.stead_id,
|
||||
"parent_page_id": data.parent_page_id,
|
||||
"content": data.content,
|
||||
"created_by": created_by,
|
||||
"created_at": now,
|
||||
|
|
@ -97,6 +103,7 @@ async def list_documents(
|
|||
user_id: int,
|
||||
doc_type: CreateType | None = None,
|
||||
stead_id: str | None = None,
|
||||
parent_page_id: str | None = None,
|
||||
starred: bool | None = None,
|
||||
sort: SortField = "updated_at",
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
@ -106,6 +113,8 @@ async def list_documents(
|
|||
extras["type"] = doc_type
|
||||
if stead_id:
|
||||
extras["stead_id"] = stead_id
|
||||
if parent_page_id:
|
||||
extras["parent_page_id"] = parent_page_id
|
||||
if starred is not None:
|
||||
extras["starred"] = starred
|
||||
if extras:
|
||||
|
|
@ -156,6 +165,23 @@ async def update_document(
|
|||
return None
|
||||
if not can_edit(existing, user_id):
|
||||
return None
|
||||
if data.content is not None or data.title is not None:
|
||||
from app.services.history_service import create_version
|
||||
|
||||
await create_version(
|
||||
db,
|
||||
existing,
|
||||
user_id,
|
||||
existing.get("created_by", "Collaborator"),
|
||||
"Before update",
|
||||
)
|
||||
if "parent_page_id" in data.model_fields_set:
|
||||
if data.parent_page_id == doc_id:
|
||||
return None
|
||||
if data.parent_page_id is not None:
|
||||
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(user_id)})
|
||||
if parent is None or parent.get("type") == "stead":
|
||||
return None
|
||||
|
||||
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
|
||||
if data.title is not None:
|
||||
|
|
@ -164,6 +190,10 @@ async def update_document(
|
|||
updates["content"] = data.content
|
||||
if data.starred is not None:
|
||||
updates["starred"] = data.starred
|
||||
if data.stead_id is not None:
|
||||
updates["stead_id"] = data.stead_id
|
||||
if "parent_page_id" in data.model_fields_set:
|
||||
updates["parent_page_id"] = data.parent_page_id
|
||||
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id},
|
||||
|
|
|
|||
81
backend/app/services/history_service.py
Normal file
81
backend/app/services/history_service.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import uuid
|
||||
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, can_edit
|
||||
|
||||
COLLECTION = "document_versions"
|
||||
|
||||
|
||||
async def ensure_history_indexes(db: AsyncIOMotorDatabase) -> None:
|
||||
await db[COLLECTION].create_index([("document_id", 1), ("created_at", -1)])
|
||||
|
||||
|
||||
def serialize_version(version: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": version["_id"],
|
||||
"document_id": version["document_id"],
|
||||
"title": version["title"],
|
||||
"content": version.get("content", {}),
|
||||
"created_by": version["created_by"],
|
||||
"created_at": version["created_at"],
|
||||
"reason": version.get("reason", "Updated"),
|
||||
}
|
||||
|
||||
|
||||
async def create_version(
|
||||
db: AsyncIOMotorDatabase,
|
||||
document: dict[str, Any],
|
||||
actor_id: int,
|
||||
actor_name: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
if not can_edit(document, actor_id):
|
||||
return
|
||||
await db[COLLECTION].insert_one({
|
||||
"_id": str(uuid.uuid4()),
|
||||
"document_id": document["_id"],
|
||||
"title": document["title"],
|
||||
"content": document.get("content", {}),
|
||||
"created_by": actor_name,
|
||||
"created_at": datetime.now(UTC),
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
|
||||
async def list_versions(
|
||||
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).limit(50)
|
||||
return [serialize_version(version) async for version in cursor]
|
||||
|
||||
|
||||
async def restore_version(
|
||||
db: AsyncIOMotorDatabase,
|
||||
document_id: str,
|
||||
version_id: str,
|
||||
user_id: int,
|
||||
actor_name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
|
||||
if document is None or not can_edit(document, user_id):
|
||||
return None
|
||||
version = await db[COLLECTION].find_one({"_id": version_id, "document_id": document_id})
|
||||
if version is None:
|
||||
return None
|
||||
await create_version(db, document, user_id, actor_name, "Before restore")
|
||||
now = datetime.now(UTC)
|
||||
updated = await db["documents"].find_one_and_update(
|
||||
{"_id": document_id},
|
||||
{"$set": {"title": version["title"], "content": version.get("content", {}), "updated_at": now}},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return updated
|
||||
77
backend/app/services/template_service.py
Normal file
77
backend/app/services/template_service.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
COLLECTION = "templates"
|
||||
|
||||
|
||||
async def ensure_template_indexes(db: AsyncIOMotorDatabase) -> None:
|
||||
await db[COLLECTION].create_index([("owner_id", 1), ("updated_at", -1)])
|
||||
await db[COLLECTION].create_index([("owner_id", 1), ("category", 1)])
|
||||
|
||||
|
||||
def _serialize(template: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": template["_id"],
|
||||
"owner_id": template["owner_id"],
|
||||
"name": template["name"],
|
||||
"description": template.get("description", ""),
|
||||
"category": template.get("category", "General"),
|
||||
"emoji": template.get("emoji", "📄"),
|
||||
"content": template.get("content", {}),
|
||||
"created_at": template["created_at"],
|
||||
"updated_at": template["updated_at"],
|
||||
"members": template.get("members", []),
|
||||
}
|
||||
|
||||
|
||||
async def list_templates(db: AsyncIOMotorDatabase, owner_id: int) -> list[dict[str, Any]]:
|
||||
cursor = db[COLLECTION].find(
|
||||
{"$or": [{"owner_id": owner_id}, {"members.user_id": owner_id}]}
|
||||
).sort([("category", 1), ("updated_at", -1)])
|
||||
return [_serialize(template) async for template in cursor]
|
||||
|
||||
|
||||
async def create_template(
|
||||
db: AsyncIOMotorDatabase,
|
||||
owner_id: int,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
template = {
|
||||
"_id": str(uuid.uuid4()),
|
||||
"owner_id": owner_id,
|
||||
**data,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"members": [],
|
||||
}
|
||||
await db[COLLECTION].insert_one(template)
|
||||
return _serialize(template)
|
||||
|
||||
|
||||
async def delete_template(db: AsyncIOMotorDatabase, template_id: str, owner_id: int) -> bool:
|
||||
result = await db[COLLECTION].delete_one({"_id": template_id, "owner_id": owner_id})
|
||||
return result.deleted_count == 1
|
||||
|
||||
|
||||
async def share_template(
|
||||
db: AsyncIOMotorDatabase,
|
||||
template_id: str,
|
||||
owner_id: int,
|
||||
member: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
template = await db[COLLECTION].find_one({"_id": template_id, "owner_id": owner_id})
|
||||
if template is None:
|
||||
return None
|
||||
members = [item for item in template.get("members", []) if item.get("user_id") != member["user_id"]]
|
||||
members.append(member)
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": template_id, "owner_id": owner_id},
|
||||
{"$set": {"members": members, "updated_at": datetime.now(UTC)}},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return _serialize(result) if result else None
|
||||
Loading…
Add table
Add a link
Reference in a new issue