From 5fa01132b96cd55bf8439e09a51ed42d85e91ccd Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 22 Aug 2025 16:46:30 +0800 Subject: [PATCH] add create and list comment api --- api/controllers/console/__init__.py | 1 + api/controllers/console/app/online_user.py | 40 ++- .../console/app/workflow_comment.py | 228 +++++++++++++++++ api/fields/workflow_comment_fields.py | 116 +++++++++ ...08_22_0001-add_workflow_comments_tables.py | 6 +- api/models/comment.py | 83 +++--- api/services/workflow_comment_service.py | 236 ++++++++++++++++++ 7 files changed, 655 insertions(+), 55 deletions(-) create mode 100644 api/controllers/console/app/workflow_comment.py create mode 100644 api/fields/workflow_comment_fields.py create mode 100644 api/services/workflow_comment_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 5055fbcc01..c59e92709d 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -65,6 +65,7 @@ from .app import ( statistic, workflow, workflow_app_log, + workflow_comment, workflow_draft_variable, workflow_run, workflow_statistic, diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 1e054c3690..a4f0dea592 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -76,7 +76,7 @@ def handle_user_connect(sid, data): sio.enter_room(sid, workflow_id) broadcast_online_users(workflow_id) - + # Notify user of their status sio.emit("status", {"isLeader": is_leader}, room=sid) @@ -98,7 +98,7 @@ def handle_disconnect(sid): # Handle leader re-election if the leader disconnected handle_leader_disconnect(workflow_id, user_id) - + broadcast_online_users(workflow_id) @@ -109,13 +109,13 @@ def get_or_set_leader(workflow_id, user_id): """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) - + if not current_leader: # No leader exists, make this user the leader redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour return user_id - - return current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader + + return current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader def handle_leader_disconnect(workflow_id, disconnected_user_id): @@ -124,22 +124,22 @@ def handle_leader_disconnect(workflow_id, disconnected_user_id): """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) - + if current_leader: - current_leader = current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader - + current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader + if current_leader == disconnected_user_id: # Leader disconnected, elect a new leader users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - + if users_json: # Get the first remaining user as new leader new_leader_id = list(users_json.keys())[0] if isinstance(new_leader_id, bytes): - new_leader_id = new_leader_id.decode('utf-8') - + new_leader_id = new_leader_id.decode("utf-8") + redis_client.set(leader_key, new_leader_id, ex=3600) - + # Notify all users about the new leader broadcast_leader_change(workflow_id, new_leader_id) else: @@ -152,13 +152,13 @@ def broadcast_leader_change(workflow_id, new_leader_id): Broadcast leader change to all users in the workflow. """ users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - + for user_id, user_info_json in users_json.items(): try: user_info = json.loads(user_info_json) user_sid = user_info.get("sid") if user_sid: - is_leader = (user_id.decode('utf-8') if isinstance(user_id, bytes) else user_id) == new_leader_id + is_leader = (user_id.decode("utf-8") if isinstance(user_id, bytes) else user_id) == new_leader_id sio.emit("status", {"isLeader": is_leader}, room=user_sid) except Exception: continue @@ -170,7 +170,7 @@ def get_current_leader(workflow_id): """ leader_key = f"workflow_leader:{workflow_id}" leader = redis_client.get(leader_key) - return leader.decode('utf-8') if leader and isinstance(leader, bytes) else leader + return leader.decode("utf-8") if leader and isinstance(leader, bytes) else leader def broadcast_online_users(workflow_id): @@ -184,15 +184,11 @@ def broadcast_online_users(workflow_id): users.append(json.loads(user_info_json)) except Exception: continue - + # Get current leader leader_id = get_current_leader(workflow_id) - - sio.emit("online_users", { - "workflow_id": workflow_id, - "users": users, - "leader": leader_id - }, room=workflow_id) + + sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_id}, room=workflow_id) @sio.on("collaboration_event") diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py new file mode 100644 index 0000000000..ea2c7699dc --- /dev/null +++ b/api/controllers/console/app/workflow_comment.py @@ -0,0 +1,228 @@ +import logging + +from flask_restful import Resource, marshal_with, reqparse + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from fields.workflow_comment_fields import ( + workflow_comment_basic_fields, + workflow_comment_create_fields, + workflow_comment_detail_fields, + workflow_comment_reply_create_fields, + workflow_comment_reply_update_fields, + workflow_comment_resolve_fields, + workflow_comment_update_fields, +) +from libs.login import current_user, login_required +from models import App +from services.workflow_comment_service import WorkflowCommentService + +logger = logging.getLogger(__name__) + + +class WorkflowCommentListApi(Resource): + """API for listing and creating workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_basic_fields, envelope="data") + def get(self, app_model: App): + """Get all comments for a workflow.""" + comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id) + + return comments + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_create_fields) + def post(self, app_model: App): + """Create a new workflow comment.""" + parser = reqparse.RequestParser() + parser.add_argument("position_x", type=float, required=True, location="json") + parser.add_argument("position_y", type=float, required=True, location="json") + parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) + args = parser.parse_args() + + result = WorkflowCommentService.create_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + created_by=current_user.id, + content=args.content, + position_x=args.position_x, + position_y=args.position_y, + mentioned_user_ids=args.mentioned_user_ids, + ) + + return result, 201 + + +class WorkflowCommentDetailApi(Resource): + """API for managing individual workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_detail_fields) + def get(self, app_model: App, comment_id: str): + """Get a specific workflow comment.""" + comment = WorkflowCommentService.get_comment( + tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_update_fields) + def put(self, app_model: App, comment_id: str): + """Update a workflow comment.""" + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) + args = parser.parse_args() + + comment = WorkflowCommentService.update_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + content=args.content, + mentioned_user_ids=args.mentioned_user_ids, + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + def delete(self, app_model: App, comment_id: str): + """Delete a workflow comment.""" + WorkflowCommentService.delete_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return {"message": "Comment deleted successfully"}, 200 + + +class WorkflowCommentResolveApi(Resource): + """API for resolving and reopening workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_resolve_fields) + def post(self, app_model: App, comment_id: str): + """Resolve a workflow comment.""" + comment = WorkflowCommentService.resolve_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_resolve_fields) + def delete(self, app_model: App, comment_id: str): + """Reopen a resolved workflow comment.""" + comment = WorkflowCommentService.reopen_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return comment + + +class WorkflowCommentReplyApi(Resource): + """API for managing comment replies.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_reply_create_fields) + def post(self, app_model: App, comment_id: str): + """Add a reply to a workflow comment.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + args = parser.parse_args() + + reply = WorkflowCommentService.create_reply( + comment_id=comment_id, content=args.content, created_by=current_user.id + ) + + return reply, 201 + + +class WorkflowCommentReplyDetailApi(Resource): + """API for managing individual comment replies.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_reply_update_fields) + def put(self, app_model: App, comment_id: str, reply_id: str): + """Update a comment reply.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + args = parser.parse_args() + + reply = WorkflowCommentService.update_reply(reply_id=reply_id, user_id=current_user.id, content=args.content) + + return reply + + @login_required + @setup_required + @account_initialization_required + @get_app_model + def delete(self, app_model: App, comment_id: str, reply_id: str): + """Delete a comment reply.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + WorkflowCommentService.delete_reply(reply_id=reply_id, user_id=current_user.id) + + return {"message": "Reply deleted successfully"}, 200 + + +# Register API routes +api.add_resource(WorkflowCommentListApi, "/apps//workflow/comments") +api.add_resource(WorkflowCommentDetailApi, "/apps//workflow/comments/") +api.add_resource(WorkflowCommentResolveApi, "/apps//workflow/comments//resolve") +api.add_resource(WorkflowCommentReplyApi, "/apps//workflow/comments//replies") +api.add_resource( + WorkflowCommentReplyDetailApi, "/apps//workflow/comments//replies/" +) diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py new file mode 100644 index 0000000000..7b1f0a08bc --- /dev/null +++ b/api/fields/workflow_comment_fields.py @@ -0,0 +1,116 @@ +from flask_restful import fields + +from libs.helper import TimestampField + +# Basic account fields for comment creators/resolvers +comment_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} + +# Comment mention fields +workflow_comment_mention_fields = { + "mentioned_user_id": fields.String, + "mentioned_user_account": fields.Nested(comment_account_fields, allow_null=True), +} + +# Comment reply fields +workflow_comment_reply_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} + +# Participant info for showing avatars +workflow_comment_participant_fields = { + "id": fields.String, + "name": fields.String, + "email": fields.String, + "avatar": fields.String, +} + +# Basic comment fields (for list views) +workflow_comment_basic_fields = { + "id": fields.String, + "position_x": fields.Float, + "position_y": fields.Float, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, + "updated_at": TimestampField, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), + "reply_count": fields.Integer, + "mention_count": fields.Integer, + "participants": fields.List(fields.Nested(workflow_comment_participant_fields)), +} + +# Detailed comment fields (for single comment view) +workflow_comment_detail_fields = { + "id": fields.String, + "position_x": fields.Float, + "position_y": fields.Float, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, + "updated_at": TimestampField, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), + "replies": fields.List(fields.Nested(workflow_comment_reply_fields)), + "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), +} + +# Comment creation response fields (simplified) +workflow_comment_create_fields = { + "id": fields.String, + "created_at": TimestampField, +} + +# Comment update response fields +workflow_comment_update_fields = { + "id": fields.String, + "content": fields.String, + "updated_at": TimestampField, + "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), +} + +# Comment resolve response fields +workflow_comment_resolve_fields = { + "id": fields.String, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), +} + +# Comment pagination fields +workflow_comment_pagination_fields = { + "data": fields.List(fields.Nested(workflow_comment_basic_fields), attribute="data"), + "has_more": fields.Boolean, + "total": fields.Integer, + "page": fields.Integer, + "limit": fields.Integer, +} + +# Reply creation response fields +workflow_comment_reply_create_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} + +# Reply update response fields +workflow_comment_reply_update_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} 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 index 8a4bd5da36..c3f4b56e75 100644 --- 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 @@ -24,9 +24,8 @@ def upgrade(): 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('position_x', sa.Float(), nullable=False), + sa.Column('position_y', sa.Float(), 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), @@ -39,7 +38,6 @@ def upgrade(): # 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 diff --git a/api/models/comment.py b/api/models/comment.py index 9af8bd7ef7..a06563e22f 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -17,16 +17,15 @@ if TYPE_CHECKING: 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 @@ -42,26 +41,19 @@ class WorkflowComment(Base): __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) + position_x: Mapped[float] = mapped_column(db.Float) + position_y: Mapped[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() - ) + 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() + 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) @@ -87,10 +79,45 @@ class WorkflowComment(Base): return db.session.get(Account, self.resolved_by) return None + @property + def reply_count(self): + """Get reply count.""" + return len(self.replies) + + @property + def mention_count(self): + """Get mention count.""" + return len(self.mentions) + + @property + def participants(self): + """Get all participants (creator + repliers + mentioned users).""" + participant_ids = set() + + # Add comment creator + participant_ids.add(self.created_by) + + # Add reply creators + for reply in self.replies: + participant_ids.add(reply.created_by) + + # Add mentioned users + for mention in self.mentions: + participant_ids.add(mention.mentioned_user_id) + + # Get account objects + participants = [] + for user_id in participant_ids: + account = db.session.get(Account, user_id) + if account: + participants.append(account) + + return participants + class WorkflowCommentReply(Base): """Workflow comment reply model. - + Attributes: id: Reply ID comment_id: Parent comment ID @@ -107,17 +134,15 @@ class WorkflowCommentReply(Base): ) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) - comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + comment_id: Mapped[str] = mapped_column( + StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), 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() - ) + created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp()) # Relationships - comment: Mapped["WorkflowComment"] = relationship( - "WorkflowComment", back_populates="replies" - ) + comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") @property def created_by_account(self): @@ -127,10 +152,10 @@ class WorkflowCommentReply(Base): 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 @@ -145,15 +170,15 @@ class WorkflowCommentMention(Base): ) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) - comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + comment_id: Mapped[str] = mapped_column( + StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False + ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # Relationships - comment: Mapped["WorkflowComment"] = relationship( - "WorkflowComment", back_populates="mentions" - ) + 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 + return db.session.get(Account, self.mentioned_user_id) diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py new file mode 100644 index 0000000000..edb3850a88 --- /dev/null +++ b/api/services/workflow_comment_service.py @@ -0,0 +1,236 @@ +import logging +from typing import Optional + +from sqlalchemy import desc, select +from sqlalchemy.orm import Session, selectinload +from werkzeug.exceptions import Forbidden, NotFound + +from extensions.ext_database import db +from libs.helper import uuid_value +from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply + +logger = logging.getLogger(__name__) + + +class WorkflowCommentService: + """Service for managing workflow comments.""" + + @staticmethod + def get_comments(tenant_id: str, app_id: str) -> list[WorkflowComment]: + """Get all comments for a workflow.""" + with Session(db.engine) as session: + # Get all comments with eager loading + stmt = ( + select(WorkflowComment) + .options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions)) + .where(WorkflowComment.tenant_id == tenant_id, WorkflowComment.app_id == app_id) + .order_by(desc(WorkflowComment.created_at)) + ) + + comments = session.scalars(stmt).all() + return comments + + @staticmethod + def get_comment(tenant_id: str, app_id: str, comment_id: str) -> WorkflowComment: + """Get a specific comment.""" + with Session(db.engine) as session: + stmt = ( + select(WorkflowComment) + .options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions)) + .where( + WorkflowComment.id == comment_id, + WorkflowComment.tenant_id == tenant_id, + WorkflowComment.app_id == app_id, + ) + ) + comment = session.scalar(stmt) + + if not comment: + raise NotFound("Comment not found") + + return comment + + @staticmethod + def create_comment( + tenant_id: str, + app_id: str, + created_by: str, + content: str, + position_x: float, + position_y: float, + mentioned_user_ids: Optional[list[str]] = None, + ) -> WorkflowComment: + """Create a new workflow comment.""" + if len(content.strip()) == 0: + raise ValueError("Comment content cannot be empty") + + if len(content) > 1000: + raise ValueError("Comment content cannot exceed 1000 characters") + with Session(db.engine) as session: + comment = WorkflowComment( + tenant_id=tenant_id, + app_id=app_id, + position_x=position_x, + position_y=position_y, + content=content, + created_by=created_by, + ) + + session.add(comment) + session.flush() # Get the comment ID for mentions + + # Create mentions if specified + mentioned_user_ids = mentioned_user_ids or [] + for user_id in mentioned_user_ids: + if isinstance(user_id, str) and uuid_value(user_id): + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id) + session.add(mention) + + session.commit() + + # Return only what we need - id and created_at + return {"id": comment.id, "created_at": comment.created_at} + + @staticmethod + def update_comment( + tenant_id: str, + app_id: str, + comment_id: str, + user_id: str, + content: str, + mentioned_user_ids: Optional[list[str]] = None, + ) -> WorkflowComment: + """Update a workflow comment.""" + + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + # Only the creator can update the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can update it") + + if len(content.strip()) == 0: + raise ValueError("Comment content cannot be empty") + + if len(content) > 1000: + raise ValueError("Comment content cannot exceed 1000 characters") + + comment.content = content + + # Update mentions - first remove existing mentions + existing_mentions = ( + db.session.query(WorkflowCommentMention).filter(WorkflowCommentMention.comment_id == comment.id).all() + ) + for mention in existing_mentions: + db.session.delete(mention) + + # Add new mentions + mentioned_user_ids = mentioned_user_ids or [] + for user_id_str in mentioned_user_ids: + if isinstance(user_id_str, str) and uuid_value(user_id_str): + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id_str) + db.session.add(mention) + + db.session.commit() + return comment + + @staticmethod + def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None: + """Delete a workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + # Only the creator can delete the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can delete it") + + db.session.delete(comment) + db.session.commit() + + @staticmethod + def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: + """Resolve a workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + if comment.resolved: + return comment + + comment.resolved = True + comment.resolved_at = db.func.current_timestamp() + comment.resolved_by = user_id + + db.session.commit() + return comment + + @staticmethod + def reopen_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: + """Reopen a resolved workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + if not comment.resolved: + return comment + + comment.resolved = False + comment.resolved_at = None + comment.resolved_by = None + + db.session.commit() + return comment + + @staticmethod + def create_reply(comment_id: str, content: str, created_by: str) -> WorkflowCommentReply: + """Add a reply to a workflow comment.""" + # Check if comment exists + comment = db.session.get(WorkflowComment, comment_id) + if not comment: + raise NotFound("Comment not found") + + if len(content.strip()) == 0: + raise ValueError("Reply content cannot be empty") + + if len(content) > 1000: + raise ValueError("Reply content cannot exceed 1000 characters") + + reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by) + + db.session.add(reply) + db.session.commit() + return reply + + @staticmethod + def update_reply(reply_id: str, user_id: str, content: str) -> WorkflowCommentReply: + """Update a comment reply.""" + reply = db.session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") + + # Only the creator can update the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can update it") + + if len(content.strip()) == 0: + raise ValueError("Reply content cannot be empty") + + if len(content) > 1000: + raise ValueError("Reply content cannot exceed 1000 characters") + + reply.content = content + db.session.commit() + return reply + + @staticmethod + def delete_reply(reply_id: str, user_id: str) -> None: + """Delete a comment reply.""" + reply = db.session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") + + # Only the creator can delete the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can delete it") + + db.session.delete(reply) + db.session.commit() + + @staticmethod + def validate_comment_access(comment_id: str, tenant_id: str, app_id: str) -> WorkflowComment: + """Validate that a comment belongs to the specified tenant and app.""" + return WorkflowCommentService.get_comment(tenant_id, app_id, comment_id)