add create and list comment api

This commit is contained in:
hjlarry
2025-08-22 16:46:30 +08:00
parent e082b6d599
commit 5fa01132b9
7 changed files with 655 additions and 55 deletions

View File

@ -65,6 +65,7 @@ from .app import (
statistic, statistic,
workflow, workflow,
workflow_app_log, workflow_app_log,
workflow_comment,
workflow_draft_variable, workflow_draft_variable,
workflow_run, workflow_run,
workflow_statistic, workflow_statistic,

View File

@ -76,7 +76,7 @@ def handle_user_connect(sid, data):
sio.enter_room(sid, workflow_id) sio.enter_room(sid, workflow_id)
broadcast_online_users(workflow_id) broadcast_online_users(workflow_id)
# Notify user of their status # Notify user of their status
sio.emit("status", {"isLeader": is_leader}, room=sid) 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 re-election if the leader disconnected
handle_leader_disconnect(workflow_id, user_id) handle_leader_disconnect(workflow_id, user_id)
broadcast_online_users(workflow_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}" leader_key = f"workflow_leader:{workflow_id}"
current_leader = redis_client.get(leader_key) current_leader = redis_client.get(leader_key)
if not current_leader: if not current_leader:
# No leader exists, make this user the leader # No leader exists, make this user the leader
redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour
return user_id 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): 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}" leader_key = f"workflow_leader:{workflow_id}"
current_leader = redis_client.get(leader_key) current_leader = redis_client.get(leader_key)
if current_leader: 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: if current_leader == disconnected_user_id:
# Leader disconnected, elect a new leader # Leader disconnected, elect a new leader
users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
if users_json: if users_json:
# Get the first remaining user as new leader # Get the first remaining user as new leader
new_leader_id = list(users_json.keys())[0] new_leader_id = list(users_json.keys())[0]
if isinstance(new_leader_id, bytes): 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) redis_client.set(leader_key, new_leader_id, ex=3600)
# Notify all users about the new leader # Notify all users about the new leader
broadcast_leader_change(workflow_id, new_leader_id) broadcast_leader_change(workflow_id, new_leader_id)
else: else:
@ -152,13 +152,13 @@ def broadcast_leader_change(workflow_id, new_leader_id):
Broadcast leader change to all users in the workflow. Broadcast leader change to all users in the workflow.
""" """
users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
for user_id, user_info_json in users_json.items(): for user_id, user_info_json in users_json.items():
try: try:
user_info = json.loads(user_info_json) user_info = json.loads(user_info_json)
user_sid = user_info.get("sid") user_sid = user_info.get("sid")
if user_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) sio.emit("status", {"isLeader": is_leader}, room=user_sid)
except Exception: except Exception:
continue continue
@ -170,7 +170,7 @@ def get_current_leader(workflow_id):
""" """
leader_key = f"workflow_leader:{workflow_id}" leader_key = f"workflow_leader:{workflow_id}"
leader = redis_client.get(leader_key) 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): def broadcast_online_users(workflow_id):
@ -184,15 +184,11 @@ def broadcast_online_users(workflow_id):
users.append(json.loads(user_info_json)) users.append(json.loads(user_info_json))
except Exception: except Exception:
continue continue
# Get current leader # Get current leader
leader_id = get_current_leader(workflow_id) leader_id = get_current_leader(workflow_id)
sio.emit("online_users", { sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_id}, room=workflow_id)
"workflow_id": workflow_id,
"users": users,
"leader": leader_id
}, room=workflow_id)
@sio.on("collaboration_event") @sio.on("collaboration_event")

View File

@ -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/<uuid:app_id>/workflow/comments")
api.add_resource(WorkflowCommentDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
api.add_resource(WorkflowCommentResolveApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
api.add_resource(WorkflowCommentReplyApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
api.add_resource(
WorkflowCommentReplyDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>"
)

View File

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

View File

@ -24,9 +24,8 @@ def upgrade():
sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('tenant_id', postgresql.UUID(), nullable=False), sa.Column('tenant_id', postgresql.UUID(), nullable=False),
sa.Column('app_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=False),
sa.Column('position_x', sa.Float(), nullable=True), sa.Column('position_y', sa.Float(), nullable=False),
sa.Column('position_y', sa.Float(), nullable=True),
sa.Column('content', sa.Text(), nullable=False), sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by', postgresql.UUID(), 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('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
@ -39,7 +38,6 @@ def upgrade():
# Create indexes for workflow_comments # Create indexes for workflow_comments
op.create_index('workflow_comments_app_idx', 'workflow_comments', ['tenant_id', 'app_id']) 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']) op.create_index('workflow_comments_created_at_idx', 'workflow_comments', ['created_at'])
# Create workflow_comment_replies table # Create workflow_comment_replies table

View File

@ -17,16 +17,15 @@ if TYPE_CHECKING:
class WorkflowComment(Base): class WorkflowComment(Base):
"""Workflow comment model for canvas commenting functionality. """Workflow comment model for canvas commenting functionality.
Comments are associated with apps rather than specific workflow versions, 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 since an app has only one draft workflow at a time and comments should persist
across workflow version changes. across workflow version changes.
Attributes: Attributes:
id: Comment ID id: Comment ID
tenant_id: Workspace ID tenant_id: Workspace ID
app_id: App ID (primary association, comments belong to apps) 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_x: X coordinate on canvas
position_y: Y coordinate on canvas position_y: Y coordinate on canvas
content: Comment content content: Comment content
@ -42,26 +41,19 @@ class WorkflowComment(Base):
__table_args__ = ( __table_args__ = (
db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"), db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"),
Index("workflow_comments_app_idx", "tenant_id", "app_id"), 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"), Index("workflow_comments_created_at_idx", "created_at"),
) )
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_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[float] = mapped_column(db.Float)
position_x: Mapped[Optional[float]] = mapped_column(db.Float) position_y: Mapped[float] = mapped_column(db.Float)
position_y: Mapped[Optional[float]] = mapped_column(db.Float)
content: Mapped[str] = mapped_column(db.Text, nullable=False) content: Mapped[str] = mapped_column(db.Text, nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
db.DateTime, nullable=False, server_default=func.current_timestamp()
)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
db.DateTime, db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
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: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false"))
resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime) 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 db.session.get(Account, self.resolved_by)
return None 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): class WorkflowCommentReply(Base):
"""Workflow comment reply model. """Workflow comment reply model.
Attributes: Attributes:
id: Reply ID id: Reply ID
comment_id: Parent comment 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()")) 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) content: Mapped[str] = mapped_column(db.Text, nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
db.DateTime, nullable=False, server_default=func.current_timestamp()
)
# Relationships # Relationships
comment: Mapped["WorkflowComment"] = relationship( comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies")
"WorkflowComment", back_populates="replies"
)
@property @property
def created_by_account(self): def created_by_account(self):
@ -127,10 +152,10 @@ class WorkflowCommentReply(Base):
class WorkflowCommentMention(Base): class WorkflowCommentMention(Base):
"""Workflow comment mention model. """Workflow comment mention model.
Mentions are only for internal accounts since end users Mentions are only for internal accounts since end users
cannot access workflow canvas and commenting features. cannot access workflow canvas and commenting features.
Attributes: Attributes:
id: Mention ID id: Mention ID
comment_id: Parent comment 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()")) 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) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# Relationships # Relationships
comment: Mapped["WorkflowComment"] = relationship( comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions")
"WorkflowComment", back_populates="mentions"
)
@property @property
def mentioned_user_account(self): def mentioned_user_account(self):
"""Get mentioned account.""" """Get mentioned account."""
return db.session.get(Account, self.mentioned_user_id) return db.session.get(Account, self.mentioned_user_id)

View File

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