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
|
|
@ -1,11 +1,28 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_mongo_db_dep
|
||||
from app.models.user import User
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
|
||||
from app.schemas.collaboration import (
|
||||
ActivityResponse,
|
||||
CommentCreate,
|
||||
CommentResolveRequest,
|
||||
CommentResponse,
|
||||
NotificationResponse,
|
||||
)
|
||||
from app.services.collaboration_service import (
|
||||
create_comment,
|
||||
delete_comment,
|
||||
list_activity,
|
||||
list_comments,
|
||||
list_notifications,
|
||||
mark_notifications_read,
|
||||
mention_names,
|
||||
resolve_comment,
|
||||
)
|
||||
from app.services.document_service import (
|
||||
add_member,
|
||||
create_document,
|
||||
|
|
@ -18,14 +35,169 @@ from app.services.document_service import (
|
|||
update_document,
|
||||
)
|
||||
from app.services.user_service import get_user_by_email_or_username
|
||||
from app.services.history_service import list_versions, restore_version
|
||||
from html import escape
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
@router.get("/{doc_id}/comments", response_model=list[CommentResponse])
|
||||
async def get_comments(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[CommentResponse]:
|
||||
comments = await list_comments(db, doc_id, current_user.id)
|
||||
if comments is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [CommentResponse(**comment) for comment in comments]
|
||||
|
||||
|
||||
@router.post("/{doc_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def post_comment(
|
||||
doc_id: str,
|
||||
data: CommentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
sql: Session = Depends(get_db),
|
||||
) -> CommentResponse:
|
||||
mentioned_users = []
|
||||
for name in mention_names(data.body):
|
||||
mentioned = get_user_by_email_or_username(sql, name.lower())
|
||||
if mentioned is not None and mentioned.id != current_user.id:
|
||||
mentioned_users.append({"id": mentioned.id})
|
||||
comment = await create_comment(
|
||||
db,
|
||||
doc_id,
|
||||
current_user.id,
|
||||
current_user.full_name or current_user.username,
|
||||
data.body,
|
||||
data.parent_id,
|
||||
mentioned_users,
|
||||
)
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or parent comment is invalid")
|
||||
return CommentResponse(**comment)
|
||||
|
||||
|
||||
@router.patch("/{doc_id}/comments/{comment_id}/resolve", response_model=CommentResponse)
|
||||
async def update_comment_resolution(
|
||||
doc_id: str,
|
||||
comment_id: str,
|
||||
data: CommentResolveRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> CommentResponse:
|
||||
comment = await resolve_comment(
|
||||
db, doc_id, comment_id, current_user.id, data.resolved,
|
||||
current_user.full_name or current_user.username,
|
||||
)
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or access denied")
|
||||
return CommentResponse(**comment)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/activity", response_model=list[ActivityResponse])
|
||||
async def get_activity(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[ActivityResponse]:
|
||||
entries = await list_activity(db, doc_id, current_user.id)
|
||||
if entries is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [ActivityResponse(**entry) for entry in entries]
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[NotificationResponse])
|
||||
async def get_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[NotificationResponse]:
|
||||
return [NotificationResponse(**item) for item in await list_notifications(db, current_user.id)]
|
||||
|
||||
|
||||
@router.post("/notifications/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def read_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
await mark_notifications_read(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/history", response_model=list[DocumentVersionResponse])
|
||||
async def get_history(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[DocumentVersionResponse]:
|
||||
versions = await list_versions(db, doc_id, current_user.id)
|
||||
if versions is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [DocumentVersionResponse(**version) for version in versions]
|
||||
|
||||
|
||||
@router.post("/{doc_id}/history/{version_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_history(
|
||||
doc_id: str,
|
||||
version_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
document = await restore_version(
|
||||
db,
|
||||
doc_id,
|
||||
version_id,
|
||||
current_user.id,
|
||||
current_user.full_name or current_user.username,
|
||||
)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Version not found or restore access denied")
|
||||
from app.services.document_service import _serialize
|
||||
|
||||
return DocumentResponse(**_serialize(document))
|
||||
|
||||
|
||||
@router.get("/{doc_id}/export")
|
||||
async def export_document(
|
||||
doc_id: str,
|
||||
format: str = Query("html", pattern="^(html|text)$"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> Response:
|
||||
document = await get_document(db, doc_id, current_user.id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
body_html = str(document["content"].get("bodyHtml", document["content"].get("body", "")))
|
||||
if format == "text":
|
||||
import re
|
||||
|
||||
return Response(re.sub(r"<[^>]+>", " ", body_html).strip(), media_type="text/plain")
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<title>{escape(document['title'])}</title></head><body>"
|
||||
f"<h1>{escape(document['title'])}</h1>{body_html}</body></html>"
|
||||
)
|
||||
return Response(html, media_type="text/html")
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_comment(
|
||||
doc_id: str,
|
||||
comment_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
deleted = await delete_comment(db, comment_id, current_user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or you are not its author")
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentResponse])
|
||||
async def list_docs(
|
||||
type: str | None = Query(None, description="Filter by document type"),
|
||||
stead_id: str | None = Query(None, description="Filter by parent stead"),
|
||||
parent_page_id: str | None = Query(None, description="Filter by parent page"),
|
||||
starred: bool | None = Query(None, description="Filter starred projects"),
|
||||
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -37,6 +209,7 @@ async def list_docs(
|
|||
current_user.id,
|
||||
doc_type=type,
|
||||
stead_id=stead_id,
|
||||
parent_page_id=parent_page_id,
|
||||
starred=starred,
|
||||
sort=sort_field,
|
||||
)
|
||||
|
|
@ -72,7 +245,10 @@ async def create_doc(
|
|||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
created_by = current_user.full_name or current_user.username
|
||||
doc = await create_document(db, current_user.id, created_by, data)
|
||||
try:
|
||||
doc = await create_document(db, current_user.id, created_by, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue