fix(activity): address code review feedback

- Fix activity:created WS payload to match frontend expectations
  (issue_id at top level, entry as TimelineEntry object)
- Promote child comments to top-level when parent is deleted
  (both in handleDeleteComment and WS comment:deleted handler)
- Enforce one-level reply nesting: reject replies to replies with 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 22:02:25 +08:00
parent e7fe6ea79b
commit ba3c8e1b3f
3 changed files with 33 additions and 6 deletions

View file

@ -315,7 +315,13 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
const handleDeleteComment = async (commentId: string) => {
try {
await api.deleteComment(commentId);
setTimeline((prev) => prev.filter((e) => e.id !== commentId));
setTimeline((prev) =>
prev
// Promote replies of deleted comment to top-level
.map((e) => e.parent_id === commentId ? { ...e, parent_id: null } : e)
// Remove the deleted comment
.filter((e) => e.id !== commentId)
);
} catch {
toast.error("Failed to delete comment");
}
@ -379,7 +385,11 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
useCallback((payload: unknown) => {
const { comment_id, issue_id } = payload as CommentDeletedPayload;
if (issue_id === id) {
setTimeline((prev) => prev.filter((e) => e.id !== comment_id));
setTimeline((prev) =>
prev
.map((e) => e.parent_id === comment_id ? { ...e, parent_id: null } : e)
.filter((e) => e.id !== comment_id)
);
}
}, [id]),
);

View file

@ -186,19 +186,26 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
}
// publishActivityEvent sends an activity:created event for WS broadcasting.
// Payload matches frontend ActivityCreatedPayload: { issue_id, entry: TimelineEntry }
func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) {
actorType := ""
if activity.ActorType.Valid {
actorType = activity.ActorType.String
}
action := activity.Action
bus.Publish(events.Event{
Type: protocol.EventActivityCreated,
WorkspaceID: original.WorkspaceID,
ActorType: original.ActorType,
ActorID: original.ActorID,
Payload: map[string]any{
"activity": map[string]any{
"issue_id": util.UUIDToString(activity.IssueID),
"entry": map[string]any{
"type": "activity",
"id": util.UUIDToString(activity.ID),
"issue_id": util.UUIDToString(activity.IssueID),
"actor_type": util.TextToPtr(activity.ActorType),
"actor_type": actorType,
"actor_id": util.UUIDToString(activity.ActorID),
"action": activity.Action,
"action": &action,
"details": json.RawMessage(activity.Details),
"created_at": util.TimestampToString(activity.CreatedAt),
},

View file

@ -94,6 +94,16 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
var parentID pgtype.UUID
if req.ParentID != nil {
parentID = parseUUID(*req.ParentID)
// Only allow one level of nesting: parent must be a top-level comment
parent, err := h.Queries.GetComment(r.Context(), parentID)
if err != nil {
writeError(w, http.StatusBadRequest, "parent comment not found")
return
}
if parent.ParentID.Valid {
writeError(w, http.StatusBadRequest, "replies to replies are not supported")
return
}
}
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{