diff --git a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py new file mode 100644 index 0000000000..8a4bd5da36 --- /dev/null +++ b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py @@ -0,0 +1,80 @@ +"""add workflow comments tables + +Revision ID: add_workflow_comments_tables +Revises: 1c9ba48be8e4 +Create Date: 2025-08-22 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'add_workflow_comments_tables' +down_revision = '1c9ba48be8e4' +branch_labels: None = None +depends_on: None = None + + +def upgrade(): + # Create workflow_comments table + op.create_table( + 'workflow_comments', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('node_id', sa.String(length=255), nullable=True), + sa.Column('position_x', sa.Float(), nullable=True), + sa.Column('position_y', sa.Float(), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('resolved_by', postgresql.UUID(), nullable=True), + sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey') + ) + + # Create indexes for workflow_comments + op.create_index('workflow_comments_app_idx', 'workflow_comments', ['tenant_id', 'app_id']) + op.create_index('workflow_comments_node_idx', 'workflow_comments', ['tenant_id', 'node_id']) + op.create_index('workflow_comments_created_at_idx', 'workflow_comments', ['created_at']) + + # Create workflow_comment_replies table + op.create_table( + 'workflow_comment_replies', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', postgresql.UUID(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_replies_comment_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') + ) + + # Create indexes for workflow_comment_replies + op.create_index('comment_replies_comment_idx', 'workflow_comment_replies', ['comment_id']) + op.create_index('comment_replies_created_at_idx', 'workflow_comment_replies', ['created_at']) + + # Create workflow_comment_mentions table + op.create_table( + 'workflow_comment_mentions', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', postgresql.UUID(), nullable=False), + sa.Column('mentioned_user_id', postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_mentions_comment_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey') + ) + + # Create indexes for workflow_comment_mentions + op.create_index('comment_mentions_comment_idx', 'workflow_comment_mentions', ['comment_id']) + op.create_index('comment_mentions_user_idx', 'workflow_comment_mentions', ['mentioned_user_id']) + + +def downgrade(): + # Drop tables in reverse order due to foreign key constraints + op.drop_table('workflow_comment_mentions') + op.drop_table('workflow_comment_replies') + op.drop_table('workflow_comments') \ No newline at end of file diff --git a/api/models/__init__.py b/api/models/__init__.py index 1b4bdd32e4..83c94c232e 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -9,6 +9,11 @@ from .account import ( TenantStatus, ) from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint +from .comment import ( + WorkflowComment, + WorkflowCommentMention, + WorkflowCommentReply, +) from .dataset import ( AppDatasetJoin, Dataset, @@ -171,6 +176,9 @@ __all__ = [ "Workflow", "WorkflowAppLog", "WorkflowAppLogCreatedFrom", + "WorkflowComment", + "WorkflowCommentMention", + "WorkflowCommentReply", "WorkflowNodeExecutionModel", "WorkflowNodeExecutionTriggeredFrom", "WorkflowRun", diff --git a/api/models/comment.py b/api/models/comment.py new file mode 100644 index 0000000000..9af8bd7ef7 --- /dev/null +++ b/api/models/comment.py @@ -0,0 +1,159 @@ +"""Workflow comment models.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Index, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .account import Account +from .base import Base +from .engine import db +from .types import StringUUID + +if TYPE_CHECKING: + pass + + +class WorkflowComment(Base): + """Workflow comment model for canvas commenting functionality. + + Comments are associated with apps rather than specific workflow versions, + since an app has only one draft workflow at a time and comments should persist + across workflow version changes. + + Attributes: + id: Comment ID + tenant_id: Workspace ID + app_id: App ID (primary association, comments belong to apps) + node_id: Node ID (optional, for node-specific comments) + position_x: X coordinate on canvas + position_y: Y coordinate on canvas + content: Comment content + created_by: Creator account ID + created_at: Creation time + updated_at: Last update time + resolved: Whether comment is resolved + resolved_at: Resolution time + resolved_by: Resolver account ID + """ + + __tablename__ = "workflow_comments" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"), + Index("workflow_comments_app_idx", "tenant_id", "app_id"), + Index("workflow_comments_node_idx", "tenant_id", "node_id"), + Index("workflow_comments_created_at_idx", "created_at"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[Optional[str]] = mapped_column(db.String(255)) + position_x: Mapped[Optional[float]] = mapped_column(db.Float) + position_y: Mapped[Optional[float]] = mapped_column(db.Float) + content: Mapped[str] = mapped_column(db.Text, nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=func.current_timestamp() + ) + updated_at: Mapped[datetime] = mapped_column( + db.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp() + ) + resolved: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false")) + resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime) + resolved_by: Mapped[Optional[str]] = mapped_column(StringUUID) + + # Relationships + replies: Mapped[list["WorkflowCommentReply"]] = relationship( + "WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan" + ) + mentions: Mapped[list["WorkflowCommentMention"]] = relationship( + "WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan" + ) + + @property + def created_by_account(self): + """Get creator account.""" + return db.session.get(Account, self.created_by) + + @property + def resolved_by_account(self): + """Get resolver account.""" + if self.resolved_by: + return db.session.get(Account, self.resolved_by) + return None + + +class WorkflowCommentReply(Base): + """Workflow comment reply model. + + Attributes: + id: Reply ID + comment_id: Parent comment ID + content: Reply content + created_by: Creator account ID + created_at: Creation time + """ + + __tablename__ = "workflow_comment_replies" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comment_replies_pkey"), + Index("comment_replies_comment_idx", "comment_id"), + Index("comment_replies_created_at_idx", "created_at"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + content: Mapped[str] = mapped_column(db.Text, nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=func.current_timestamp() + ) + + # Relationships + comment: Mapped["WorkflowComment"] = relationship( + "WorkflowComment", back_populates="replies" + ) + + @property + def created_by_account(self): + """Get creator account.""" + return db.session.get(Account, self.created_by) + + +class WorkflowCommentMention(Base): + """Workflow comment mention model. + + Mentions are only for internal accounts since end users + cannot access workflow canvas and commenting features. + + Attributes: + id: Mention ID + comment_id: Parent comment ID + mentioned_user_id: Mentioned account ID + """ + + __tablename__ = "workflow_comment_mentions" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"), + Index("comment_mentions_comment_idx", "comment_id"), + Index("comment_mentions_user_idx", "mentioned_user_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + # Relationships + comment: Mapped["WorkflowComment"] = relationship( + "WorkflowComment", back_populates="mentions" + ) + + @property + def mentioned_user_account(self): + """Get mentioned account.""" + return db.session.get(Account, self.mentioned_user_id) \ No newline at end of file