feat: implement collaboration module

- 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 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 20:45:07 +08:00
parent 61fe01cb6b
commit 3470428411
38 changed files with 3088 additions and 4 deletions

View File

@@ -0,0 +1,3 @@
from app.api.blockers.router import router
__all__ = ["router"]

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
from app.api.comments.router import router
__all__ = ["router"]

View File

@@ -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

View File

@@ -0,0 +1,3 @@
from app.api.notifications.router import router
__all__ = ["router"]

View File

@@ -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}

View File

@@ -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,

View File

@@ -0,0 +1,3 @@
from app.api.websocket.router import router
__all__ = ["router"]

View File

@@ -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=<jwt_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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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"
]

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""),
)

View File

@@ -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)