From 3470428411e65b5453131fafd78b1777e84cdec4 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Mon, 29 Dec 2025 20:45:07 +0800 Subject: [PATCH] feat: implement collaboration module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend (FastAPI): - Task comments with nested replies and soft delete - @mention parsing with 10-mention limit per comment - Notification system with read/unread tracking - Blocker management with project owner notification - WebSocket endpoint with JWT auth and keepalive - User search API for @mention autocomplete - Alembic migration for 4 new tables - Frontend (React + Vite): - Comments component with @mention autocomplete - NotificationBell with real-time WebSocket updates - BlockerDialog for task blocking workflow - NotificationContext for state management - OpenSpec: - 4 requirements with scenarios defined - add-collaboration change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/api/blockers/__init__.py | 3 + backend/app/api/blockers/router.py | 200 +++++++++ backend/app/api/comments/__init__.py | 3 + backend/app/api/comments/router.py | 257 +++++++++++ backend/app/api/notifications/__init__.py | 3 + backend/app/api/notifications/router.py | 119 +++++ backend/app/api/users/router.py | 30 +- backend/app/api/websocket/__init__.py | 3 + backend/app/api/websocket/router.py | 98 ++++ backend/app/core/redis.py | 5 + backend/app/main.py | 8 + backend/app/models/__init__.py | 9 +- backend/app/models/blocker.py | 23 + backend/app/models/comment.py | 26 ++ backend/app/models/mention.py | 18 + backend/app/models/notification.py | 37 ++ backend/app/models/task.py | 4 + backend/app/models/user.py | 7 + backend/app/schemas/__init__.py | 20 + backend/app/schemas/blocker.py | 39 ++ backend/app/schemas/comment.py | 52 +++ backend/app/schemas/notification.py | 28 ++ backend/app/services/notification_service.py | 183 ++++++++ backend/app/services/websocket_manager.py | 82 ++++ backend/migrations/env.py | 5 +- .../versions/004_collaboration_tables.py | 96 ++++ backend/tests/test_collaboration.py | 420 ++++++++++++++++++ frontend/src/components/BlockerDialog.tsx | 218 +++++++++ frontend/src/components/Comments.tsx | 258 +++++++++++ frontend/src/components/Layout.tsx | 2 + frontend/src/components/NotificationBell.tsx | 155 +++++++ frontend/src/contexts/NotificationContext.tsx | 191 ++++++++ frontend/src/main.tsx | 5 +- frontend/src/services/collaboration.ts | 169 +++++++ .../2025-12-29-add-collaboration/design.md | 125 ++++++ .../2025-12-29-add-collaboration/proposal.md | 18 + .../specs/collaboration/spec.md | 97 ++++ .../2025-12-29-add-collaboration/tasks.md | 76 ++++ 38 files changed, 3088 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/blockers/__init__.py create mode 100644 backend/app/api/blockers/router.py create mode 100644 backend/app/api/comments/__init__.py create mode 100644 backend/app/api/comments/router.py create mode 100644 backend/app/api/notifications/__init__.py create mode 100644 backend/app/api/notifications/router.py create mode 100644 backend/app/api/websocket/__init__.py create mode 100644 backend/app/api/websocket/router.py create mode 100644 backend/app/models/blocker.py create mode 100644 backend/app/models/comment.py create mode 100644 backend/app/models/mention.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/schemas/blocker.py create mode 100644 backend/app/schemas/comment.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/websocket_manager.py create mode 100644 backend/migrations/versions/004_collaboration_tables.py create mode 100644 backend/tests/test_collaboration.py create mode 100644 frontend/src/components/BlockerDialog.tsx create mode 100644 frontend/src/components/Comments.tsx create mode 100644 frontend/src/components/NotificationBell.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx create mode 100644 frontend/src/services/collaboration.ts create mode 100644 openspec/changes/archive/2025-12-29-add-collaboration/design.md create mode 100644 openspec/changes/archive/2025-12-29-add-collaboration/proposal.md create mode 100644 openspec/changes/archive/2025-12-29-add-collaboration/specs/collaboration/spec.md create mode 100644 openspec/changes/archive/2025-12-29-add-collaboration/tasks.md diff --git a/backend/app/api/blockers/__init__.py b/backend/app/api/blockers/__init__.py new file mode 100644 index 0000000..b78451c --- /dev/null +++ b/backend/app/api/blockers/__init__.py @@ -0,0 +1,3 @@ +from app.api.blockers.router import router + +__all__ = ["router"] diff --git a/backend/app/api/blockers/router.py b/backend/app/api/blockers/router.py new file mode 100644 index 0000000..722aa68 --- /dev/null +++ b/backend/app/api/blockers/router.py @@ -0,0 +1,200 @@ +import uuid +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models import User, Task, Blocker +from app.schemas.blocker import ( + BlockerCreate, BlockerResolve, BlockerResponse, BlockerListResponse, BlockerUserInfo +) +from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access +from app.services.notification_service import NotificationService + +router = APIRouter(tags=["blockers"]) + + +def blocker_to_response(blocker: Blocker) -> BlockerResponse: + """Convert Blocker model to BlockerResponse.""" + return BlockerResponse( + id=blocker.id, + task_id=blocker.task_id, + reason=blocker.reason, + resolution_note=blocker.resolution_note, + created_at=blocker.created_at, + resolved_at=blocker.resolved_at, + reporter=BlockerUserInfo( + id=blocker.reporter.id, + name=blocker.reporter.name, + email=blocker.reporter.email, + ), + resolver=BlockerUserInfo( + id=blocker.resolver.id, + name=blocker.resolver.name, + email=blocker.resolver.email, + ) if blocker.resolver else None, + ) + + +@router.post("/api/tasks/{task_id}/blockers", response_model=BlockerResponse, status_code=status.HTTP_201_CREATED) +async def create_blocker( + task_id: str, + blocker_data: BlockerCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark a task as blocked with a reason.""" + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found", + ) + + if not check_task_edit_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + # Check if task is already blocked with an unresolved blocker + existing_blocker = db.query(Blocker).filter( + Blocker.task_id == task_id, + Blocker.resolved_at == None, + ).first() + + if existing_blocker: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Task already has an unresolved blocker", + ) + + # Create blocker record + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=task_id, + reported_by=current_user.id, + reason=blocker_data.reason, + ) + db.add(blocker) + + # Update task blocker_flag + task.blocker_flag = True + + # Notify project owner + NotificationService.notify_blocker(db, task, current_user, blocker_data.reason) + + db.commit() + db.refresh(blocker) + + return blocker_to_response(blocker) + + +@router.put("/api/blockers/{blocker_id}/resolve", response_model=BlockerResponse) +async def resolve_blocker( + blocker_id: str, + resolve_data: BlockerResolve, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Resolve a blocker with a resolution note.""" + blocker = db.query(Blocker).filter(Blocker.id == blocker_id).first() + + if not blocker: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Blocker not found", + ) + + if blocker.resolved_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Blocker is already resolved", + ) + + task = blocker.task + if not check_task_edit_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + # Update blocker + blocker.resolved_by = current_user.id + blocker.resolution_note = resolve_data.resolution_note + blocker.resolved_at = datetime.utcnow() + + # Check if there are other unresolved blockers + other_blockers = db.query(Blocker).filter( + Blocker.task_id == task.id, + Blocker.id != blocker_id, + Blocker.resolved_at == None, + ).count() + + if other_blockers == 0: + task.blocker_flag = False + + # Notify reporter that blocker is resolved + NotificationService.notify_blocker_resolved(db, task, current_user, blocker.reported_by) + + db.commit() + db.refresh(blocker) + + return blocker_to_response(blocker) + + +@router.get("/api/tasks/{task_id}/blockers", response_model=BlockerListResponse) +async def list_blockers( + task_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all blockers (history) for a task.""" + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found", + ) + + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + blockers = db.query(Blocker).filter( + Blocker.task_id == task_id, + ).order_by(Blocker.created_at.desc()).all() + + return BlockerListResponse( + blockers=[blocker_to_response(b) for b in blockers], + total=len(blockers), + ) + + +@router.get("/api/blockers/{blocker_id}", response_model=BlockerResponse) +async def get_blocker( + blocker_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a specific blocker by ID.""" + blocker = db.query(Blocker).filter(Blocker.id == blocker_id).first() + + if not blocker: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Blocker not found", + ) + + task = blocker.task + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + return blocker_to_response(blocker) diff --git a/backend/app/api/comments/__init__.py b/backend/app/api/comments/__init__.py new file mode 100644 index 0000000..aa70977 --- /dev/null +++ b/backend/app/api/comments/__init__.py @@ -0,0 +1,3 @@ +from app.api.comments.router import router + +__all__ = ["router"] diff --git a/backend/app/api/comments/router.py b/backend/app/api/comments/router.py new file mode 100644 index 0000000..64f721b --- /dev/null +++ b/backend/app/api/comments/router.py @@ -0,0 +1,257 @@ +import uuid +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models import User, Task, Comment +from app.schemas.comment import ( + CommentCreate, CommentUpdate, CommentResponse, CommentListResponse, + CommentAuthor, MentionedUser +) +from app.middleware.auth import get_current_user, check_task_access +from app.services.notification_service import NotificationService + +router = APIRouter(tags=["comments"]) + + +def comment_to_response(comment: Comment) -> CommentResponse: + """Convert Comment model to CommentResponse.""" + mentioned_users = [ + MentionedUser( + id=m.mentioned_user.id, + name=m.mentioned_user.name, + email=m.mentioned_user.email, + ) + for m in comment.mentions + if m.mentioned_user + ] + + reply_count = len([r for r in comment.replies if not r.is_deleted]) if comment.replies else 0 + + return CommentResponse( + id=comment.id, + task_id=comment.task_id, + parent_comment_id=comment.parent_comment_id, + content=comment.content if not comment.is_deleted else "[This comment has been deleted]", + is_edited=comment.is_edited, + is_deleted=comment.is_deleted, + created_at=comment.created_at, + updated_at=comment.updated_at, + author=CommentAuthor( + id=comment.author.id, + name=comment.author.name, + email=comment.author.email, + ), + mentions=mentioned_users, + reply_count=reply_count, + ) + + +@router.post("/api/tasks/{task_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def create_comment( + task_id: str, + comment_data: CommentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new comment on a task.""" + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found", + ) + + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + # Validate parent comment if provided + parent_author_id = None + if comment_data.parent_comment_id: + parent_comment = db.query(Comment).filter( + Comment.id == comment_data.parent_comment_id, + Comment.task_id == task_id, + ).first() + + if not parent_comment: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parent comment not found", + ) + parent_author_id = parent_comment.author_id + + # Check @mention limit + mention_count = NotificationService.count_mentions(comment_data.content) + if mention_count > NotificationService.MAX_MENTIONS_PER_COMMENT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {NotificationService.MAX_MENTIONS_PER_COMMENT} mentions allowed per comment", + ) + + # Create comment + comment = Comment( + id=str(uuid.uuid4()), + task_id=task_id, + parent_comment_id=comment_data.parent_comment_id, + author_id=current_user.id, + content=comment_data.content, + ) + db.add(comment) + db.flush() + + # Process mentions and create notifications + NotificationService.process_mentions(db, comment, task, current_user) + + # Notify parent comment author if this is a reply + if parent_author_id: + NotificationService.notify_comment_reply(db, comment, task, current_user, parent_author_id) + + db.commit() + db.refresh(comment) + + return comment_to_response(comment) + + +@router.get("/api/tasks/{task_id}/comments", response_model=CommentListResponse) +async def list_comments( + task_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all comments on a task.""" + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found", + ) + + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + # Get root comments (no parent) ordered by creation time + comments = db.query(Comment).filter( + Comment.task_id == task_id, + Comment.parent_comment_id == None, + ).order_by(Comment.created_at).all() + + return CommentListResponse( + comments=[comment_to_response(c) for c in comments], + total=len(comments), + ) + + +@router.get("/api/comments/{comment_id}/replies", response_model=CommentListResponse) +async def list_replies( + comment_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List replies to a comment.""" + comment = db.query(Comment).filter(Comment.id == comment_id).first() + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found", + ) + + task = comment.task + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + replies = db.query(Comment).filter( + Comment.parent_comment_id == comment_id, + ).order_by(Comment.created_at).all() + + return CommentListResponse( + comments=[comment_to_response(r) for r in replies], + total=len(replies), + ) + + +@router.put("/api/comments/{comment_id}", response_model=CommentResponse) +async def update_comment( + comment_id: str, + comment_data: CommentUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a comment (only by author).""" + comment = db.query(Comment).filter(Comment.id == comment_id).first() + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found", + ) + + if comment.is_deleted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot edit a deleted comment", + ) + + if comment.author_id != current_user.id and not current_user.is_system_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the author can edit this comment", + ) + + # Check @mention limit in updated content + mention_count = NotificationService.count_mentions(comment_data.content) + if mention_count > NotificationService.MAX_MENTIONS_PER_COMMENT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {NotificationService.MAX_MENTIONS_PER_COMMENT} mentions allowed per comment", + ) + + comment.content = comment_data.content + comment.is_edited = True + + db.commit() + db.refresh(comment) + + return comment_to_response(comment) + + +@router.delete("/api/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_comment( + comment_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a comment (soft delete, only by author or admin).""" + comment = db.query(Comment).filter(Comment.id == comment_id).first() + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found", + ) + + if comment.author_id != current_user.id and not current_user.is_system_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the author can delete this comment", + ) + + # Soft delete - mark as deleted but keep for reply chain + comment.is_deleted = True + comment.content = "" + + db.commit() + + return None diff --git a/backend/app/api/notifications/__init__.py b/backend/app/api/notifications/__init__.py new file mode 100644 index 0000000..b74fc11 --- /dev/null +++ b/backend/app/api/notifications/__init__.py @@ -0,0 +1,3 @@ +from app.api.notifications.router import router + +__all__ = ["router"] diff --git a/backend/app/api/notifications/router.py b/backend/app/api/notifications/router.py new file mode 100644 index 0000000..117ffef --- /dev/null +++ b/backend/app/api/notifications/router.py @@ -0,0 +1,119 @@ +from typing import Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models import User, Notification +from app.schemas.notification import ( + NotificationResponse, NotificationListResponse, UnreadCountResponse +) +from app.middleware.auth import get_current_user + +router = APIRouter(tags=["notifications"]) + + +def notification_to_response(notification: Notification) -> NotificationResponse: + """Convert Notification model to NotificationResponse.""" + return NotificationResponse( + id=notification.id, + type=notification.type, + reference_type=notification.reference_type, + reference_id=notification.reference_id, + title=notification.title, + message=notification.message, + is_read=notification.is_read, + created_at=notification.created_at, + read_at=notification.read_at, + ) + + +@router.get("/api/notifications", response_model=NotificationListResponse) +async def list_notifications( + is_read: Optional[bool] = Query(None, description="Filter by read status"), + limit: int = Query(50, ge=1, le=100, description="Number of notifications to return"), + offset: int = Query(0, ge=0, description="Offset for pagination"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List notifications for the current user.""" + query = db.query(Notification).filter(Notification.user_id == current_user.id) + + if is_read is not None: + query = query.filter(Notification.is_read == is_read) + + total = query.count() + unread_count = db.query(Notification).filter( + Notification.user_id == current_user.id, + Notification.is_read == False, + ).count() + + notifications = query.order_by(Notification.created_at.desc()).offset(offset).limit(limit).all() + + return NotificationListResponse( + notifications=[notification_to_response(n) for n in notifications], + total=total, + unread_count=unread_count, + ) + + +@router.get("/api/notifications/unread-count", response_model=UnreadCountResponse) +async def get_unread_count( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get count of unread notifications.""" + count = db.query(Notification).filter( + Notification.user_id == current_user.id, + Notification.is_read == False, + ).count() + + return UnreadCountResponse(unread_count=count) + + +@router.put("/api/notifications/{notification_id}/read", response_model=NotificationResponse) +async def mark_as_read( + notification_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark a notification as read.""" + notification = db.query(Notification).filter( + Notification.id == notification_id, + Notification.user_id == current_user.id, + ).first() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + + if not notification.is_read: + notification.is_read = True + notification.read_at = datetime.utcnow() + db.commit() + db.refresh(notification) + + return notification_to_response(notification) + + +@router.put("/api/notifications/read-all", response_model=dict) +async def mark_all_as_read( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark all notifications as read.""" + now = datetime.utcnow() + + updated_count = db.query(Notification).filter( + Notification.user_id == current_user.id, + Notification.is_read == False, + ).update({ + Notification.is_read: True, + Notification.read_at: now, + }) + + db.commit() + + return {"updated_count": updated_count} diff --git a/backend/app/api/users/router.py b/backend/app/api/users/router.py index 15823c2..8a9b43e 100644 --- a/backend/app/api/users/router.py +++ b/backend/app/api/users/router.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session +from sqlalchemy import or_ from typing import List from app.core.database import get_db @@ -16,6 +17,33 @@ from app.middleware.auth import ( router = APIRouter() +@router.get("/search", response_model=List[UserResponse]) +async def search_users( + q: str = Query(..., min_length=1, max_length=100, description="Search query"), + limit: int = Query(10, ge=1, le=50, description="Max results"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Search users by name or email. Used for @mention autocomplete. + Returns users matching the query, limited to same department unless system admin. + """ + query = db.query(User).filter( + User.is_active == True, + or_( + User.name.ilike(f"%{q}%"), + User.email.ilike(f"%{q}%"), + ) + ) + + # Filter by department if not system admin + if not current_user.is_system_admin and current_user.department_id: + query = query.filter(User.department_id == current_user.department_id) + + users = query.limit(limit).all() + return users + + @router.get("", response_model=List[UserResponse]) async def list_users( skip: int = 0, diff --git a/backend/app/api/websocket/__init__.py b/backend/app/api/websocket/__init__.py new file mode 100644 index 0000000..dbd5cff --- /dev/null +++ b/backend/app/api/websocket/__init__.py @@ -0,0 +1,3 @@ +from app.api.websocket.router import router + +__all__ = ["router"] diff --git a/backend/app/api/websocket/router.py b/backend/app/api/websocket/router.py new file mode 100644 index 0000000..8c66c2e --- /dev/null +++ b/backend/app/api/websocket/router.py @@ -0,0 +1,98 @@ +import asyncio +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.security import decode_access_token +from app.core.redis import get_redis_sync +from app.models import User +from app.services.websocket_manager import manager + +router = APIRouter(tags=["websocket"]) + + +async def get_user_from_token(token: str) -> tuple[str | None, User | None]: + """Validate token and return user_id and user object.""" + payload = decode_access_token(token) + if payload is None: + return None, None + + user_id = payload.get("sub") + if user_id is None: + return None, None + + # Verify session in Redis + redis_client = get_redis_sync() + stored_token = redis_client.get(f"session:{user_id}") + if stored_token is None or stored_token != token: + return None, None + + # Get user from database + db = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if user is None or not user.is_active: + return None, None + return user_id, user + finally: + db.close() + + +@router.websocket("/ws/notifications") +async def websocket_notifications( + websocket: WebSocket, + token: str = Query(..., description="JWT token for authentication"), +): + """ + WebSocket endpoint for real-time notifications. + + Connect with: ws://host/ws/notifications?token= + + Messages sent by server: + - {"type": "notification", "data": {...}} - New notification + - {"type": "unread_count", "data": {"unread_count": N}} - Unread count update + - {"type": "pong"} - Response to ping + + Messages accepted from client: + - {"type": "ping"} - Keepalive ping + """ + user_id, user = await get_user_from_token(token) + + if user_id is None: + await websocket.close(code=4001, reason="Invalid or expired token") + return + + await manager.connect(websocket, user_id) + + try: + # Send initial connection success message + await websocket.send_json({ + "type": "connected", + "data": {"user_id": user_id, "message": "Connected to notification service"}, + }) + + while True: + try: + # Wait for messages from client (ping/pong for keepalive) + data = await asyncio.wait_for( + websocket.receive_json(), + timeout=60.0 # 60 second timeout + ) + + # Handle ping message + if data.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + + except asyncio.TimeoutError: + # Send keepalive ping if no message received + try: + await websocket.send_json({"type": "ping"}) + except Exception: + break + + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + await manager.disconnect(websocket, user_id) diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py index a28b72a..1fd668c 100644 --- a/backend/app/core/redis.py +++ b/backend/app/core/redis.py @@ -12,3 +12,8 @@ redis_client = redis.Redis( def get_redis(): """Dependency for getting Redis client.""" return redis_client + + +def get_redis_sync(): + """Get Redis client synchronously (non-dependency use).""" + return redis_client diff --git a/backend/app/main.py b/backend/app/main.py index ad2aea5..41bd636 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,10 @@ from app.api.spaces import router as spaces_router from app.api.projects import router as projects_router from app.api.tasks import router as tasks_router from app.api.workload import router as workload_router +from app.api.comments import router as comments_router +from app.api.notifications import router as notifications_router +from app.api.blockers import router as blockers_router +from app.api.websocket import router as websocket_router from app.core.config import settings app = FastAPI( @@ -33,6 +37,10 @@ app.include_router(spaces_router) app.include_router(projects_router) app.include_router(tasks_router) app.include_router(workload_router, prefix="/api/workload", tags=["Workload"]) +app.include_router(comments_router) +app.include_router(notifications_router) +app.include_router(blockers_router) +app.include_router(websocket_router) @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b5b9203..29d1cfa 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,5 +6,12 @@ from app.models.project import Project from app.models.task_status import TaskStatus from app.models.task import Task from app.models.workload_snapshot import WorkloadSnapshot +from app.models.comment import Comment +from app.models.mention import Mention +from app.models.notification import Notification +from app.models.blocker import Blocker -__all__ = ["User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot"] +__all__ = [ + "User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot", + "Comment", "Mention", "Notification", "Blocker" +] diff --git a/backend/app/models/blocker.py b/backend/app/models/blocker.py new file mode 100644 index 0000000..1a2169d --- /dev/null +++ b/backend/app/models/blocker.py @@ -0,0 +1,23 @@ +import uuid +from sqlalchemy import Column, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class Blocker(Base): + __tablename__ = "pjctrl_blockers" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=False) + reported_by = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False) + reason = Column(Text, nullable=False) + resolved_by = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=True) + resolution_note = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + resolved_at = Column(DateTime, nullable=True) + + # Relationships + task = relationship("Task", back_populates="blockers") + reporter = relationship("User", foreign_keys=[reported_by], back_populates="reported_blockers") + resolver = relationship("User", foreign_keys=[resolved_by], back_populates="resolved_blockers") diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..24dabdc --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,26 @@ +import uuid +from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class Comment(Base): + __tablename__ = "pjctrl_comments" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=False) + parent_comment_id = Column(String(36), ForeignKey("pjctrl_comments.id", ondelete="CASCADE"), nullable=True) + author_id = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False) + content = Column(Text, nullable=False) + is_edited = Column(Boolean, default=False, nullable=False) + is_deleted = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + # Relationships + task = relationship("Task", back_populates="comments") + author = relationship("User", back_populates="comments") + parent_comment = relationship("Comment", remote_side=[id], back_populates="replies") + replies = relationship("Comment", back_populates="parent_comment", cascade="all, delete-orphan") + mentions = relationship("Mention", back_populates="comment", cascade="all, delete-orphan") diff --git a/backend/app/models/mention.py b/backend/app/models/mention.py new file mode 100644 index 0000000..23f5130 --- /dev/null +++ b/backend/app/models/mention.py @@ -0,0 +1,18 @@ +import uuid +from sqlalchemy import Column, String, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class Mention(Base): + __tablename__ = "pjctrl_mentions" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + comment_id = Column(String(36), ForeignKey("pjctrl_comments.id", ondelete="CASCADE"), nullable=False) + mentioned_user_id = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + + # Relationships + comment = relationship("Comment", back_populates="mentions") + mentioned_user = relationship("User", back_populates="mentions") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..ed15cf1 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,37 @@ +import uuid +from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Enum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base +import enum + + +class NotificationType(str, enum.Enum): + MENTION = "mention" + ASSIGNMENT = "assignment" + BLOCKER = "blocker" + STATUS_CHANGE = "status_change" + COMMENT = "comment" + BLOCKER_RESOLVED = "blocker_resolved" + + +class Notification(Base): + __tablename__ = "pjctrl_notifications" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="CASCADE"), nullable=False) + type = Column( + Enum("mention", "assignment", "blocker", "status_change", "comment", "blocker_resolved", + name="notification_type_enum"), + nullable=False + ) + reference_type = Column(String(50), nullable=False) + reference_id = Column(String(36), nullable=False) + title = Column(String(200), nullable=False) + message = Column(Text, nullable=True) + is_read = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + read_at = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="notifications") diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 893636f..6010592 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -43,3 +43,7 @@ class Task(Base): assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_tasks") creator = relationship("User", foreign_keys=[created_by], back_populates="created_tasks") status = relationship("TaskStatus", back_populates="tasks") + + # Collaboration relationships + comments = relationship("Comment", back_populates="task", cascade="all, delete-orphan") + blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 159192b..29ebeaf 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -29,3 +29,10 @@ class User(Base): owned_projects = relationship("Project", foreign_keys="Project.owner_id", back_populates="owner") assigned_tasks = relationship("Task", foreign_keys="Task.assignee_id", back_populates="assignee") created_tasks = relationship("Task", foreign_keys="Task.created_by", back_populates="creator") + + # Collaboration relationships + comments = relationship("Comment", back_populates="author") + mentions = relationship("Mention", back_populates="mentioned_user") + notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") + reported_blockers = relationship("Blocker", foreign_keys="Blocker.reported_by", back_populates="reporter") + resolved_blockers = relationship("Blocker", foreign_keys="Blocker.resolved_by", back_populates="resolver") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4b89096..55ccb0d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -11,6 +11,15 @@ from app.schemas.task import ( TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse, TaskStatusUpdate as TaskStatusChangeUpdate, TaskAssignUpdate, Priority ) +from app.schemas.comment import ( + CommentCreate, CommentUpdate, CommentResponse, CommentListResponse +) +from app.schemas.notification import ( + NotificationResponse, NotificationListResponse, UnreadCountResponse +) +from app.schemas.blocker import ( + BlockerCreate, BlockerResolve, BlockerResponse, BlockerListResponse +) __all__ = [ "LoginRequest", @@ -44,4 +53,15 @@ __all__ = [ "TaskStatusChangeUpdate", "TaskAssignUpdate", "Priority", + "CommentCreate", + "CommentUpdate", + "CommentResponse", + "CommentListResponse", + "NotificationResponse", + "NotificationListResponse", + "UnreadCountResponse", + "BlockerCreate", + "BlockerResolve", + "BlockerResponse", + "BlockerListResponse", ] diff --git a/backend/app/schemas/blocker.py b/backend/app/schemas/blocker.py new file mode 100644 index 0000000..d2afc85 --- /dev/null +++ b/backend/app/schemas/blocker.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class BlockerCreate(BaseModel): + reason: str = Field(..., min_length=1, max_length=2000) + + +class BlockerResolve(BaseModel): + resolution_note: str = Field(..., min_length=1, max_length=2000) + + +class BlockerUserInfo(BaseModel): + id: str + name: str + email: str + + class Config: + from_attributes = True + + +class BlockerResponse(BaseModel): + id: str + task_id: str + reason: str + resolution_note: Optional[str] + created_at: datetime + resolved_at: Optional[datetime] + reporter: BlockerUserInfo + resolver: Optional[BlockerUserInfo] + + class Config: + from_attributes = True + + +class BlockerListResponse(BaseModel): + blockers: List[BlockerResponse] + total: int diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py new file mode 100644 index 0000000..e8cf670 --- /dev/null +++ b/backend/app/schemas/comment.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class CommentCreate(BaseModel): + content: str = Field(..., min_length=1, max_length=10000) + parent_comment_id: Optional[str] = None + + +class CommentUpdate(BaseModel): + content: str = Field(..., min_length=1, max_length=10000) + + +class CommentAuthor(BaseModel): + id: str + name: str + email: str + + class Config: + from_attributes = True + + +class MentionedUser(BaseModel): + id: str + name: str + email: str + + class Config: + from_attributes = True + + +class CommentResponse(BaseModel): + id: str + task_id: str + parent_comment_id: Optional[str] + content: str + is_edited: bool + is_deleted: bool + created_at: datetime + updated_at: datetime + author: CommentAuthor + mentions: List[MentionedUser] = [] + reply_count: int = 0 + + class Config: + from_attributes = True + + +class CommentListResponse(BaseModel): + comments: List[CommentResponse] + total: int diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..582f1d0 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel + + +class NotificationResponse(BaseModel): + id: str + type: str + reference_type: str + reference_id: str + title: str + message: Optional[str] + is_read: bool + created_at: datetime + read_at: Optional[datetime] + + class Config: + from_attributes = True + + +class NotificationListResponse(BaseModel): + notifications: List[NotificationResponse] + total: int + unread_count: int + + +class UnreadCountResponse(BaseModel): + unread_count: int diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..3be52c5 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,183 @@ +import uuid +import re +from typing import List, Optional +from sqlalchemy.orm import Session + +from app.models import User, Notification, Task, Comment, Mention + + +class NotificationService: + """Service for creating and managing notifications.""" + + MAX_MENTIONS_PER_COMMENT = 10 + + @staticmethod + def create_notification( + db: Session, + user_id: str, + notification_type: str, + reference_type: str, + reference_id: str, + title: str, + message: Optional[str] = None, + ) -> Notification: + """Create a notification for a user.""" + notification = Notification( + id=str(uuid.uuid4()), + user_id=user_id, + type=notification_type, + reference_type=reference_type, + reference_id=reference_id, + title=title, + message=message, + ) + db.add(notification) + return notification + + @staticmethod + def notify_task_assignment( + db: Session, + task: Task, + assigned_by: User, + ) -> Optional[Notification]: + """Notify user when they are assigned to a task.""" + if not task.assignee_id or task.assignee_id == assigned_by.id: + return None + + return NotificationService.create_notification( + db=db, + user_id=task.assignee_id, + notification_type="assignment", + reference_type="task", + reference_id=task.id, + title=f"You've been assigned to: {task.title}", + message=f"Assigned by {assigned_by.name}", + ) + + @staticmethod + def notify_blocker( + db: Session, + task: Task, + reported_by: User, + reason: str, + ) -> List[Notification]: + """Notify project owner when a task is blocked.""" + notifications = [] + + # Notify project owner + project = task.project + if project and project.owner_id and project.owner_id != reported_by.id: + notification = NotificationService.create_notification( + db=db, + user_id=project.owner_id, + notification_type="blocker", + reference_type="task", + reference_id=task.id, + title=f"Task blocked: {task.title}", + message=f"Reported by {reported_by.name}: {reason[:100]}...", + ) + notifications.append(notification) + + return notifications + + @staticmethod + def notify_blocker_resolved( + db: Session, + task: Task, + resolved_by: User, + reporter_id: str, + ) -> Optional[Notification]: + """Notify the original reporter when a blocker is resolved.""" + if reporter_id == resolved_by.id: + return None + + return NotificationService.create_notification( + db=db, + user_id=reporter_id, + notification_type="blocker_resolved", + reference_type="task", + reference_id=task.id, + title=f"Blocker resolved: {task.title}", + message=f"Resolved by {resolved_by.name}", + ) + + @staticmethod + def count_mentions(content: str) -> int: + """Count the number of @mentions in content.""" + pattern = r'@([a-zA-Z0-9._-]+(?:@[a-zA-Z0-9.-]+)?)' + matches = re.findall(pattern, content) + return len(matches) + + @staticmethod + def parse_mentions(content: str) -> List[str]: + """Extract @mentions from comment content. Returns list of email usernames.""" + # Match @username patterns (alphanumeric and common email chars before @domain) + pattern = r'@([a-zA-Z0-9._-]+(?:@[a-zA-Z0-9.-]+)?)' + matches = re.findall(pattern, content) + return matches[:NotificationService.MAX_MENTIONS_PER_COMMENT] + + @staticmethod + def process_mentions( + db: Session, + comment: Comment, + task: Task, + author: User, + ) -> List[Notification]: + """Process mentions in a comment and create notifications.""" + notifications = [] + mentioned_usernames = NotificationService.parse_mentions(comment.content) + + if not mentioned_usernames: + return notifications + + # Find users by email or name + for username in mentioned_usernames: + # Try to find user by email first + user = db.query(User).filter( + (User.email.ilike(f"{username}%")) | (User.name.ilike(f"%{username}%")) + ).first() + + if user and user.id != author.id: + # Create mention record + mention = Mention( + id=str(uuid.uuid4()), + comment_id=comment.id, + mentioned_user_id=user.id, + ) + db.add(mention) + + # Create notification + notification = NotificationService.create_notification( + db=db, + user_id=user.id, + notification_type="mention", + reference_type="comment", + reference_id=comment.id, + title=f"{author.name} mentioned you in: {task.title}", + message=comment.content[:100] + ("..." if len(comment.content) > 100 else ""), + ) + notifications.append(notification) + + return notifications + + @staticmethod + def notify_comment_reply( + db: Session, + comment: Comment, + task: Task, + author: User, + parent_author_id: str, + ) -> Optional[Notification]: + """Notify original commenter when someone replies.""" + if parent_author_id == author.id: + return None + + return NotificationService.create_notification( + db=db, + user_id=parent_author_id, + notification_type="comment", + reference_type="comment", + reference_id=comment.id, + title=f"{author.name} replied to your comment on: {task.title}", + message=comment.content[:100] + ("..." if len(comment.content) > 100 else ""), + ) diff --git a/backend/app/services/websocket_manager.py b/backend/app/services/websocket_manager.py new file mode 100644 index 0000000..0e70914 --- /dev/null +++ b/backend/app/services/websocket_manager.py @@ -0,0 +1,82 @@ +import json +import asyncio +from typing import Dict, Set, Optional +from fastapi import WebSocket +from app.core.redis import get_redis_sync + + +class ConnectionManager: + """Manager for WebSocket connections.""" + + def __init__(self): + # user_id -> set of WebSocket connections + self.active_connections: Dict[str, Set[WebSocket]] = {} + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket, user_id: str): + """Accept and track a new WebSocket connection.""" + await websocket.accept() + async with self._lock: + if user_id not in self.active_connections: + self.active_connections[user_id] = set() + self.active_connections[user_id].add(websocket) + + async def disconnect(self, websocket: WebSocket, user_id: str): + """Remove a WebSocket connection.""" + async with self._lock: + if user_id in self.active_connections: + self.active_connections[user_id].discard(websocket) + if not self.active_connections[user_id]: + del self.active_connections[user_id] + + async def send_personal_message(self, message: dict, user_id: str): + """Send a message to all connections of a specific user.""" + if user_id in self.active_connections: + disconnected = set() + for connection in self.active_connections[user_id]: + try: + await connection.send_json(message) + except Exception: + disconnected.add(connection) + + # Clean up disconnected connections + for conn in disconnected: + await self.disconnect(conn, user_id) + + async def broadcast(self, message: dict): + """Broadcast a message to all connected users.""" + for user_id in list(self.active_connections.keys()): + await self.send_personal_message(message, user_id) + + def is_connected(self, user_id: str) -> bool: + """Check if a user has any active connections.""" + return user_id in self.active_connections and len(self.active_connections[user_id]) > 0 + + +# Global connection manager instance +manager = ConnectionManager() + + +async def publish_notification(user_id: str, notification_data: dict): + """ + Publish a notification to a user via WebSocket. + + This can be called from anywhere in the application to send + real-time notifications to connected users. + """ + message = { + "type": "notification", + "data": notification_data, + } + await manager.send_personal_message(message, user_id) + + +async def publish_notification_count_update(user_id: str, unread_count: int): + """ + Publish an unread count update to a user. + """ + message = { + "type": "unread_count", + "data": {"unread_count": unread_count}, + } + await manager.send_personal_message(message, user_id) diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 622690b..9972ac9 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -10,7 +10,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.core.config import settings from app.core.database import Base -from app.models import User, Role, Department +from app.models import ( + User, Role, Department, Space, Project, TaskStatus, Task, WorkloadSnapshot, + Comment, Mention, Notification, Blocker +) config = context.config diff --git a/backend/migrations/versions/004_collaboration_tables.py b/backend/migrations/versions/004_collaboration_tables.py new file mode 100644 index 0000000..37b3306 --- /dev/null +++ b/backend/migrations/versions/004_collaboration_tables.py @@ -0,0 +1,96 @@ +"""Collaboration tables (comments, mentions, notifications, blockers) + +Revision ID: 004 +Revises: 003 +Create Date: 2024-01-XX + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '004' +down_revision = '003' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create notification_type_enum + notification_type_enum = sa.Enum( + 'mention', 'assignment', 'blocker', 'status_change', 'comment', 'blocker_resolved', + name='notification_type_enum' + ) + notification_type_enum.create(op.get_bind(), checkfirst=True) + + # Create pjctrl_comments table + op.create_table( + 'pjctrl_comments', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('task_id', sa.String(36), sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), nullable=False), + sa.Column('parent_comment_id', sa.String(36), sa.ForeignKey('pjctrl_comments.id', ondelete='CASCADE'), nullable=True), + sa.Column('author_id', sa.String(36), sa.ForeignKey('pjctrl_users.id'), nullable=False), + sa.Column('content', sa.Text, nullable=False), + sa.Column('is_edited', sa.Boolean, server_default='0', nullable=False), + sa.Column('is_deleted', sa.Boolean, server_default='0', nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + op.create_index('idx_comments_task', 'pjctrl_comments', ['task_id']) + op.create_index('idx_comments_author', 'pjctrl_comments', ['author_id']) + op.create_index('idx_comments_parent', 'pjctrl_comments', ['parent_comment_id']) + + # Create pjctrl_mentions table + op.create_table( + 'pjctrl_mentions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('comment_id', sa.String(36), sa.ForeignKey('pjctrl_comments.id', ondelete='CASCADE'), nullable=False), + sa.Column('mentioned_user_id', sa.String(36), sa.ForeignKey('pjctrl_users.id'), nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + ) + op.create_index('idx_mentions_comment', 'pjctrl_mentions', ['comment_id']) + op.create_index('idx_mentions_user', 'pjctrl_mentions', ['mentioned_user_id']) + + # Create pjctrl_notifications table + op.create_table( + 'pjctrl_notifications', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('user_id', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='CASCADE'), nullable=False), + sa.Column('type', notification_type_enum, nullable=False), + sa.Column('reference_type', sa.String(50), nullable=False), + sa.Column('reference_id', sa.String(36), nullable=False), + sa.Column('title', sa.String(200), nullable=False), + sa.Column('message', sa.Text, nullable=True), + sa.Column('is_read', sa.Boolean, server_default='0', nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('read_at', sa.DateTime, nullable=True), + ) + op.create_index('idx_notifications_user', 'pjctrl_notifications', ['user_id']) + op.create_index('idx_notifications_user_unread', 'pjctrl_notifications', ['user_id', 'is_read']) + op.create_index('idx_notifications_reference', 'pjctrl_notifications', ['reference_type', 'reference_id']) + + # Create pjctrl_blockers table + op.create_table( + 'pjctrl_blockers', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('task_id', sa.String(36), sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), nullable=False), + sa.Column('reported_by', sa.String(36), sa.ForeignKey('pjctrl_users.id'), nullable=False), + sa.Column('reason', sa.Text, nullable=False), + sa.Column('resolved_by', sa.String(36), sa.ForeignKey('pjctrl_users.id'), nullable=True), + sa.Column('resolution_note', sa.Text, nullable=True), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('resolved_at', sa.DateTime, nullable=True), + ) + op.create_index('idx_blockers_task', 'pjctrl_blockers', ['task_id']) + op.create_index('idx_blockers_reported_by', 'pjctrl_blockers', ['reported_by']) + + +def downgrade() -> None: + op.drop_table('pjctrl_blockers') + op.drop_table('pjctrl_notifications') + op.drop_table('pjctrl_mentions') + op.drop_table('pjctrl_comments') + + # Drop enum type + notification_type_enum = sa.Enum(name='notification_type_enum') + notification_type_enum.drop(op.get_bind(), checkfirst=True) diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py new file mode 100644 index 0000000..84b139d --- /dev/null +++ b/backend/tests/test_collaboration.py @@ -0,0 +1,420 @@ +import pytest +import uuid +from app.models import User, Space, Project, Task, TaskStatus, Comment, Notification, Blocker + + +@pytest.fixture +def test_user(db): + """Create a test user.""" + user = User( + id=str(uuid.uuid4()), + email="testuser@example.com", + name="Test User", + role_id="00000000-0000-0000-0000-000000000003", + is_active=True, + is_system_admin=False, + ) + db.add(user) + db.commit() + return user + + +@pytest.fixture +def user_token(client, mock_redis, test_user): + """Get a user token for testing.""" + from app.core.security import create_access_token, create_token_payload + + token_data = create_token_payload( + user_id=test_user.id, + email=test_user.email, + role="engineer", + department_id=None, + is_system_admin=False, + ) + token = create_access_token(token_data) + mock_redis.setex(f"session:{test_user.id}", 900, token) + return token + + +@pytest.fixture +def test_space(db): + """Create a test space.""" + space = Space( + id=str(uuid.uuid4()), + name="Test Space", + description="A test space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + db.commit() + return space + + +@pytest.fixture +def test_project(db, test_space): + """Create a test project.""" + project = Project( + id=str(uuid.uuid4()), + space_id=test_space.id, + title="Test Project", + description="A test project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + db.commit() + return project + + +@pytest.fixture +def test_status(db, test_project): + """Create a test task status.""" + status = TaskStatus( + id=str(uuid.uuid4()), + project_id=test_project.id, + name="To Do", + color="#3498db", + position=0, + ) + db.add(status) + db.commit() + return status + + +@pytest.fixture +def test_task(db, test_project, test_status): + """Create a test task.""" + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Test Task", + description="A test task", + status_id=test_status.id, + created_by="00000000-0000-0000-0000-000000000001", + ) + db.add(task) + db.commit() + return task + + +class TestComments: + """Tests for Comments API.""" + + def test_create_comment(self, client, admin_token, test_task): + """Test creating a comment.""" + response = client.post( + f"/api/tasks/{test_task.id}/comments", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"content": "This is a test comment"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["content"] == "This is a test comment" + assert data["task_id"] == test_task.id + assert data["is_edited"] is False + assert data["is_deleted"] is False + + def test_list_comments(self, client, admin_token, db, test_task): + """Test listing comments.""" + # Create a comment first + comment = Comment( + id=str(uuid.uuid4()), + task_id=test_task.id, + author_id="00000000-0000-0000-0000-000000000001", + content="Test comment", + ) + db.add(comment) + db.commit() + + response = client.get( + f"/api/tasks/{test_task.id}/comments", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["comments"]) == 1 + assert data["comments"][0]["content"] == "Test comment" + + def test_update_comment(self, client, admin_token, db, test_task): + """Test updating a comment.""" + comment = Comment( + id=str(uuid.uuid4()), + task_id=test_task.id, + author_id="00000000-0000-0000-0000-000000000001", + content="Original content", + ) + db.add(comment) + db.commit() + + response = client.put( + f"/api/comments/{comment.id}", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"content": "Updated content"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["content"] == "Updated content" + assert data["is_edited"] is True + + def test_delete_comment(self, client, admin_token, db, test_task): + """Test deleting a comment (soft delete).""" + comment = Comment( + id=str(uuid.uuid4()), + task_id=test_task.id, + author_id="00000000-0000-0000-0000-000000000001", + content="To be deleted", + ) + db.add(comment) + db.commit() + + response = client.delete( + f"/api/comments/{comment.id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + # Verify soft delete + db.refresh(comment) + assert comment.is_deleted is True + + def test_mention_limit(self, client, admin_token, test_task): + """Test that @mention limit is enforced.""" + # Create content with more than 10 mentions + mentions = " ".join([f"@user{i}" for i in range(15)]) + response = client.post( + f"/api/tasks/{test_task.id}/comments", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"content": f"Test with many mentions: {mentions}"}, + ) + assert response.status_code == 400 + assert "10 mentions" in response.json()["detail"] + + +class TestNotifications: + """Tests for Notifications API.""" + + def test_list_notifications(self, client, admin_token, db): + """Test listing notifications.""" + # Create a notification + notification = Notification( + id=str(uuid.uuid4()), + user_id="00000000-0000-0000-0000-000000000001", + type="mention", + reference_type="comment", + reference_id=str(uuid.uuid4()), + title="Test notification", + message="Test message", + ) + db.add(notification) + db.commit() + + response = client.get( + "/api/notifications", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert data["unread_count"] >= 1 + + def test_mark_notification_as_read(self, client, admin_token, db): + """Test marking a notification as read.""" + notification = Notification( + id=str(uuid.uuid4()), + user_id="00000000-0000-0000-0000-000000000001", + type="assignment", + reference_type="task", + reference_id=str(uuid.uuid4()), + title="New assignment", + ) + db.add(notification) + db.commit() + + response = client.put( + f"/api/notifications/{notification.id}/read", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["is_read"] is True + assert data["read_at"] is not None + + def test_mark_all_as_read(self, client, admin_token, db): + """Test marking all notifications as read.""" + # Create multiple unread notifications + for i in range(3): + notification = Notification( + id=str(uuid.uuid4()), + user_id="00000000-0000-0000-0000-000000000001", + type="comment", + reference_type="task", + reference_id=str(uuid.uuid4()), + title=f"Notification {i}", + ) + db.add(notification) + db.commit() + + response = client.put( + "/api/notifications/read-all", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["updated_count"] >= 3 + + def test_get_unread_count(self, client, admin_token, db): + """Test getting unread notification count.""" + # Create unread notifications + for i in range(2): + notification = Notification( + id=str(uuid.uuid4()), + user_id="00000000-0000-0000-0000-000000000001", + type="blocker", + reference_type="task", + reference_id=str(uuid.uuid4()), + title=f"Blocker {i}", + ) + db.add(notification) + db.commit() + + response = client.get( + "/api/notifications/unread-count", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["unread_count"] >= 2 + + +class TestBlockers: + """Tests for Blockers API.""" + + def test_create_blocker(self, client, admin_token, test_task): + """Test creating a blocker.""" + response = client.post( + f"/api/tasks/{test_task.id}/blockers", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"reason": "Waiting for external dependency"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["reason"] == "Waiting for external dependency" + assert data["resolved_at"] is None + + def test_resolve_blocker(self, client, admin_token, db, test_task): + """Test resolving a blocker.""" + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=test_task.id, + reported_by="00000000-0000-0000-0000-000000000001", + reason="Test blocker", + ) + db.add(blocker) + test_task.blocker_flag = True + db.commit() + + response = client.put( + f"/api/blockers/{blocker.id}/resolve", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"resolution_note": "Issue resolved by updating config"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["resolved_at"] is not None + assert data["resolution_note"] == "Issue resolved by updating config" + + # Verify task blocker_flag is cleared + db.refresh(test_task) + assert test_task.blocker_flag is False + + def test_list_blockers(self, client, admin_token, db, test_task): + """Test listing blockers for a task.""" + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=test_task.id, + reported_by="00000000-0000-0000-0000-000000000001", + reason="Test blocker", + ) + db.add(blocker) + db.commit() + + response = client.get( + f"/api/tasks/{test_task.id}/blockers", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["blockers"][0]["reason"] == "Test blocker" + + def test_cannot_create_duplicate_active_blocker(self, client, admin_token, db, test_task): + """Test that duplicate active blockers are prevented.""" + # Create first blocker + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=test_task.id, + reported_by="00000000-0000-0000-0000-000000000001", + reason="First blocker", + ) + db.add(blocker) + db.commit() + + # Try to create second blocker + response = client.post( + f"/api/tasks/{test_task.id}/blockers", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"reason": "Second blocker"}, + ) + assert response.status_code == 400 + assert "already has an unresolved blocker" in response.json()["detail"] + + +class TestUserSearch: + """Tests for User Search API.""" + + def test_search_users(self, client, admin_token, db): + """Test searching users by name.""" + # Create test users + user1 = User( + id=str(uuid.uuid4()), + email="john@example.com", + name="John Doe", + is_active=True, + ) + user2 = User( + id=str(uuid.uuid4()), + email="jane@example.com", + name="Jane Doe", + is_active=True, + ) + db.add_all([user1, user2]) + db.commit() + + response = client.get( + "/api/users/search?q=Doe", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 2 + + def test_search_users_by_email(self, client, admin_token, db): + """Test searching users by email.""" + user = User( + id=str(uuid.uuid4()), + email="searchtest@example.com", + name="Search Test", + is_active=True, + ) + db.add(user) + db.commit() + + response = client.get( + "/api/users/search?q=searchtest", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert any(u["email"] == "searchtest@example.com" for u in data) diff --git a/frontend/src/components/BlockerDialog.tsx b/frontend/src/components/BlockerDialog.tsx new file mode 100644 index 0000000..f0edb66 --- /dev/null +++ b/frontend/src/components/BlockerDialog.tsx @@ -0,0 +1,218 @@ +import { useState, useEffect } from 'react' +import { blockersApi, Blocker } from '../services/collaboration' + +interface BlockerDialogProps { + taskId: string + taskTitle: string + isBlocked: boolean + onClose: () => void + onBlockerChange: () => void +} + +export function BlockerDialog({ + taskId, + taskTitle, + isBlocked, + onClose, + onBlockerChange, +}: BlockerDialogProps) { + const [blockers, setBlockers] = useState([]) + const [loading, setLoading] = useState(true) + const [reason, setReason] = useState('') + const [resolutionNote, setResolutionNote] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [activeBlocker, setActiveBlocker] = useState(null) + + useEffect(() => { + const fetchBlockers = async () => { + try { + const response = await blockersApi.list(taskId) + setBlockers(response.blockers) + // Find active blocker (unresolved) + const active = response.blockers.find(b => !b.resolved_at) + setActiveBlocker(active || null) + } catch (err) { + setError('Failed to load blockers') + } finally { + setLoading(false) + } + } + fetchBlockers() + }, [taskId]) + + const handleCreateBlocker = async (e: React.FormEvent) => { + e.preventDefault() + if (!reason.trim()) return + + try { + setSubmitting(true) + const blocker = await blockersApi.create(taskId, reason) + setBlockers(prev => [blocker, ...prev]) + setActiveBlocker(blocker) + setReason('') + setError(null) + onBlockerChange() + } catch (err) { + setError('Failed to create blocker') + } finally { + setSubmitting(false) + } + } + + const handleResolveBlocker = async (e: React.FormEvent) => { + e.preventDefault() + if (!activeBlocker || !resolutionNote.trim()) return + + try { + setSubmitting(true) + const updated = await blockersApi.resolve(activeBlocker.id, resolutionNote) + setBlockers(prev => prev.map(b => (b.id === updated.id ? updated : b))) + setActiveBlocker(null) + setResolutionNote('') + setError(null) + onBlockerChange() + } catch (err) { + setError('Failed to resolve blocker') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

+ {isBlocked ? 'Task Blocked' : 'Mark as Blocked'} +

+ +
+ +
+

Task: {taskTitle}

+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading...
+ ) : ( + <> + {/* Active blocker */} + {activeBlocker && ( +
+

+ Active Blocker +

+

{activeBlocker.reason}

+

+ Reported by {activeBlocker.reporter.name} on{' '} + {new Date(activeBlocker.created_at).toLocaleString()} +

+ + {/* Resolve form */} +
+ +