- 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>
97 lines
4.6 KiB
Python
97 lines
4.6 KiB
Python
"""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)
|