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
|
|
@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||
refresh_token_expire_days: int = 7
|
||||
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
google_redirect_uri: str = "http://localhost:8000/api/v1/auth/google/callback"
|
||||
|
||||
@property
|
||||
def is_sqlite(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ from sqlalchemy.exc import OperationalError
|
|||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.mongo import close_mongo, connect_mongo
|
||||
from app.routers import auth, collab, documents, health
|
||||
from app.routers import auth, collab, documents, health, templates
|
||||
from app.services.collaboration_service import ensure_collaboration_indexes
|
||||
from app.services.document_service import ensure_indexes
|
||||
from app.services.template_service import ensure_template_indexes
|
||||
from app.services.history_service import ensure_history_indexes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,6 +31,9 @@ async def lifespan(_: FastAPI):
|
|||
from app.mongo import get_mongo_db
|
||||
|
||||
await ensure_indexes(get_mongo_db())
|
||||
await ensure_collaboration_indexes(get_mongo_db())
|
||||
await ensure_template_indexes(get_mongo_db())
|
||||
await ensure_history_indexes(get_mongo_db())
|
||||
logger.info("MongoDB connected and indexes ready")
|
||||
except Exception:
|
||||
logger.warning("MongoDB unavailable — will connect when available")
|
||||
|
|
@ -49,6 +55,7 @@ app.add_middleware(
|
|||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(documents.router, prefix="/api/v1")
|
||||
app.include_router(templates.router, prefix="/api/v1")
|
||||
app.include_router(collab.router, prefix="/api/v1")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -20,6 +25,7 @@ from app.schemas.user import (
|
|||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
ChangePasswordRequest,
|
||||
)
|
||||
from app.services.user_service import (
|
||||
authenticate_user,
|
||||
|
|
@ -30,7 +36,14 @@ from app.services.user_service import (
|
|||
get_valid_reset_token,
|
||||
update_user,
|
||||
)
|
||||
from app.utils.security import create_access_token, create_refresh_token, hash_password
|
||||
from app.utils.security import (
|
||||
create_access_token,
|
||||
create_oauth_state,
|
||||
create_refresh_token,
|
||||
hash_password,
|
||||
verify_oauth_state,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -114,3 +127,90 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) ->
|
|||
db.commit()
|
||||
|
||||
return MessageResponse(message="Password reset successfully. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MessageResponse:
|
||||
if not verify_password(data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
||||
current_user.hashed_password = hash_password(data.new_password)
|
||||
db.commit()
|
||||
return MessageResponse(message="Password updated successfully.")
|
||||
|
||||
|
||||
@router.get("/google/login")
|
||||
def google_login() -> RedirectResponse:
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Google sign-in is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
|
||||
)
|
||||
state = create_oauth_state()
|
||||
query = urlencode({
|
||||
"client_id": settings.google_client_id,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "offline",
|
||||
"prompt": "select_account",
|
||||
})
|
||||
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{query}")
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_callback(code: str, state: str | None = None) -> RedirectResponse:
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google sign-in is not configured")
|
||||
if not code or not state or not verify_oauth_state(state):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google authorization was incomplete")
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
token_response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google authorization failed")
|
||||
token_data = token_response.json()
|
||||
profile_response = await client.get(
|
||||
"https://openidconnect.googleapis.com/v1/userinfo",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
||||
)
|
||||
profile = profile_response.json()
|
||||
email = str(profile.get("email", "")).lower()
|
||||
if not email or not profile.get("email_verified"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google account email is not verified")
|
||||
from app.services.user_service import get_user_by_email
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
user = get_user_by_email(db, email)
|
||||
if user is None:
|
||||
base_username = email.split("@")[0][:90]
|
||||
username = base_username
|
||||
suffix = 1
|
||||
while get_user_by_username(db, username):
|
||||
username = f"{base_username}{suffix}"
|
||||
suffix += 1
|
||||
user = create_user(db, UserCreate(
|
||||
email=email,
|
||||
username=username,
|
||||
password=secrets.token_urlsafe(32),
|
||||
full_name=str(profile.get("name") or username),
|
||||
))
|
||||
access_token = create_access_token(user.email)
|
||||
refresh_token = create_refresh_token(user.email)
|
||||
finally:
|
||||
db.close()
|
||||
query = urlencode({"access_token": access_token, "refresh_token": refresh_token})
|
||||
return RedirectResponse(f"{settings.frontend_url}/auth/google/callback?{query}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
63
backend/app/routers/templates.py
Normal file
63
backend/app/routers/templates.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from app.dependencies import get_current_user, get_mongo_db_dep
|
||||
from app.models.user import User
|
||||
from app.schemas.template import TemplateCreate, TemplateResponse, TemplateShareRequest
|
||||
from app.services.template_service import create_template, delete_template, list_templates, share_template
|
||||
from app.database import get_db
|
||||
from app.services.user_service import get_user_by_email_or_username
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["templates"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateResponse])
|
||||
async def get_templates(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[TemplateResponse]:
|
||||
templates = await list_templates(db, current_user.id)
|
||||
return [TemplateResponse(**template) for template in templates]
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def post_template(
|
||||
data: TemplateCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> TemplateResponse:
|
||||
template = await create_template(db, current_user.id, data.model_dump())
|
||||
return TemplateResponse(**template)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_template(
|
||||
template_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
if not await delete_template(db, template_id, current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
|
||||
|
||||
@router.post("/{template_id}/share", response_model=TemplateResponse)
|
||||
async def add_template_member(
|
||||
template_id: str,
|
||||
data: TemplateShareRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
sql: Session = Depends(get_db),
|
||||
) -> TemplateResponse:
|
||||
user = get_user_by_email_or_username(sql, data.email_or_username.strip().lower())
|
||||
if user is None or user.id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
template = await share_template(
|
||||
db,
|
||||
template_id,
|
||||
current_user.id,
|
||||
{"user_id": user.id, "name": user.full_name or user.username, "email": user.email},
|
||||
)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this template")
|
||||
return TemplateResponse(**template)
|
||||
44
backend/app/schemas/collaboration.py
Normal file
44
backend/app/schemas/collaboration.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=5000)
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
body: str
|
||||
parent_id: str | None = None
|
||||
author_id: int
|
||||
author_name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
class CommentResolveRequest(BaseModel):
|
||||
resolved: bool
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
message: str
|
||||
document_id: str | None = None
|
||||
comment_id: str | None = None
|
||||
actor_name: str
|
||||
read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
kind: str
|
||||
message: str
|
||||
actor_name: str
|
||||
created_at: datetime
|
||||
|
|
@ -21,12 +21,15 @@ class DocumentCreate(BaseModel):
|
|||
content: dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: dict[str, Any] | None = None
|
||||
starred: bool | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
|
|
@ -40,6 +43,7 @@ class DocumentResponse(BaseModel):
|
|||
title: str
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
content: dict[str, Any]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
|
@ -49,3 +53,13 @@ class DocumentResponse(BaseModel):
|
|||
owner_id: int | None = None
|
||||
visibility: Visibility = "private"
|
||||
members: list[DocumentMember] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DocumentVersionResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
title: str
|
||||
content: dict[str, Any]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
reason: str
|
||||
|
|
|
|||
24
backend/app/schemas/template.py
Normal file
24
backend/app/schemas/template.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = Field(default="", max_length=500)
|
||||
category: str = Field(default="General", min_length=1, max_length=60)
|
||||
emoji: str = Field(default="📄", max_length=8)
|
||||
content: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TemplateResponse(TemplateCreate):
|
||||
id: str
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
members: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TemplateShareRequest(BaseModel):
|
||||
email_or_username: str
|
||||
|
|
@ -23,6 +23,11 @@ class UserUpdate(BaseModel):
|
|||
occupation: str | None = Field(default=None, max_length=150)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import bcrypt
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -31,6 +32,20 @@ def create_refresh_token(subject: str) -> str:
|
|||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_oauth_state() -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=10)
|
||||
payload = {"nonce": secrets.token_urlsafe(16), "exp": expire, "type": "oauth_state"}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_oauth_state(state: str) -> bool:
|
||||
try:
|
||||
payload = decode_token(state)
|
||||
return payload.get("type") == "oauth_state"
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue