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