From fb4d9f354c8fd3e72ccbde9fd195ca2a791d3324 Mon Sep 17 00:00:00 2001 From: James Morton Date: Wed, 22 Apr 2026 09:55:33 +0100 Subject: [PATCH 1/7] feat(comments): allow authors and team members to edit comments - Add updatedAt column to comments table (migration 0047) - Surface isEdited flag through the full data pipeline to clients - Inline edit form in comment thread: textarea, Save/Cancel, Escape/Cmd+Enter - (edited) marker shown in comment header after any edit - REST PATCH and MCP update_comment now use userEditComment (writes history) --- .../admin/feedback/detail/post-utils.ts | 1 + .../suggestions/merge-preview-modal.tsx | 1 + .../src/components/admin/roadmap-modal.tsx | 2 + .../src/components/public/comment-thread.tsx | 109 +- apps/web/src/lib/client/mutations/comments.ts | 1 + .../src/lib/client/queries/portal-detail.ts | 1 + .../domains/comments/comment.permissions.ts | 5 +- .../server/domains/comments/comment.query.ts | 1 + .../domains/posts/post.public.detail.ts | 5 + .../lib/server/domains/posts/post.types.ts | 1 + apps/web/src/lib/server/mcp/tools.ts | 6 +- apps/web/src/lib/shared/comment-tree.ts | 3 + .../src/routes/api/v1/comments/$commentId.ts | 13 +- packages/db/drizzle/0047_true_jimmy_woo.sql | 1 + packages/db/drizzle/meta/0047_snapshot.json | 8226 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema/posts.ts | 1 + 17 files changed, 8364 insertions(+), 22 deletions(-) create mode 100644 packages/db/drizzle/0047_true_jimmy_woo.sql create mode 100644 packages/db/drizzle/meta/0047_snapshot.json diff --git a/apps/web/src/components/admin/feedback/detail/post-utils.ts b/apps/web/src/components/admin/feedback/detail/post-utils.ts index d04cbdc92..cbcddf8a7 100644 --- a/apps/web/src/components/admin/feedback/detail/post-utils.ts +++ b/apps/web/src/components/admin/feedback/detail/post-utils.ts @@ -23,6 +23,7 @@ export function toPortalComments(post: PostDetails): PublicCommentView[] { parentId: c.parentId as CommentId | null, isTeamMember: c.isTeamMember, isPrivate: c.isPrivate, + isEdited: false, avatarUrl: (c.principalId && post.avatarUrls?.[c.principalId]) || null, statusChange: c.statusChange ?? null, reactions: c.reactions, diff --git a/apps/web/src/components/admin/feedback/suggestions/merge-preview-modal.tsx b/apps/web/src/components/admin/feedback/suggestions/merge-preview-modal.tsx index bbdfdbcbf..107fae1fd 100644 --- a/apps/web/src/components/admin/feedback/suggestions/merge-preview-modal.tsx +++ b/apps/web/src/components/admin/feedback/suggestions/merge-preview-modal.tsx @@ -97,6 +97,7 @@ function MergePreviewContent({ !!c.deletedAt && !!c.deletedByPrincipalId && c.deletedByPrincipalId !== c.principalId, parentId: c.parentId as CommentId | null, isTeamMember: c.isTeamMember, + isEdited: false, avatarUrl: c.avatarUrl ?? null, statusChange: c.statusChange ?? null, reactions: c.reactions, diff --git a/apps/web/src/components/admin/roadmap-modal.tsx b/apps/web/src/components/admin/roadmap-modal.tsx index 6cde29a7c..0708c0ee8 100644 --- a/apps/web/src/components/admin/roadmap-modal.tsx +++ b/apps/web/src/components/admin/roadmap-modal.tsx @@ -74,6 +74,7 @@ function toPortalPostView(post: PostDetails): PublicPostDetailView { !!c.deletedAt && !!c.deletedByPrincipalId && c.deletedByPrincipalId !== c.principalId, parentId: c.parentId as CommentId | null, isTeamMember: c.isTeamMember, + isEdited: false, avatarUrl: (c.principalId && post.avatarUrls?.[c.principalId]) || null, reactions: c.reactions, replies: c.replies.map((r) => ({ @@ -87,6 +88,7 @@ function toPortalPostView(post: PostDetails): PublicPostDetailView { !!r.deletedAt && !!r.deletedByPrincipalId && r.deletedByPrincipalId !== r.principalId, parentId: r.parentId as CommentId | null, isTeamMember: r.isTeamMember, + isEdited: false, avatarUrl: (r.principalId && post.avatarUrls?.[r.principalId]) || null, reactions: r.reactions, replies: [], diff --git a/apps/web/src/components/public/comment-thread.tsx b/apps/web/src/components/public/comment-thread.tsx index 6fd2d38dd..562abd69b 100644 --- a/apps/web/src/components/public/comment-thread.tsx +++ b/apps/web/src/components/public/comment-thread.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useIntl } from 'react-intl' import { ArrowRightIcon, @@ -9,7 +9,7 @@ import { LockClosedIcon, MapPinIcon, } from '@heroicons/react/24/solid' -import { TrashIcon } from '@heroicons/react/24/outline' +import { PencilSquareIcon, TrashIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -17,6 +17,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { TimeAgo } from '@/components/ui/time-ago' import { REACTION_EMOJIS } from '@/lib/shared/db-types' import { addReactionFn, removeReactionFn } from '@/lib/server/functions/comments' +import { useEditComment } from '@/lib/client/mutations/portal-comments' import type { CommentReactionCount } from '@/lib/shared' import type { PublicCommentView } from '@/lib/client/queries/portal-detail' import { cn, getInitials } from '@/lib/shared/utils' @@ -298,11 +299,27 @@ function CommentItem({ const [reactions, setReactions] = useState(comment.reactions) const [isPending, setIsPending] = useState(false) const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState(comment.content) + const [editError, setEditError] = useState(null) + const editTextareaRef = useRef(null) + + const editMutation = useEditComment({ + commentId: comment.id as CommentId, + postId, + }) useEffect(() => { setReactions(comment.reactions) }, [comment.reactions]) + useEffect(() => { + if (isEditing) { + editTextareaRef.current?.focus() + editTextareaRef.current?.setSelectionRange(editContent.length, editContent.length) + } + }, [isEditing]) + const isDeleted = !!comment.deletedAt const canNest = depth < MAX_NESTING_DEPTH const hasReplies = comment.replies.length > 0 @@ -315,11 +332,14 @@ function CommentItem({ depth === 0 && !isDeleted && !comment.isPrivate - // Can delete: not already deleted, and user is author or team member + // Can edit/delete: not already deleted, and user is author or team member + // Server re-checks; client heuristic avoids showing the button to unrelated users + const isAuthor = !!user?.principalId && comment.principalId === user.principalId + const canEdit = !isDeleted && (isTeamMember || isAuthor) const canDelete = !isDeleted && !!onDeleteComment && - (isTeamMember || (!!user?.principalId && comment.principalId === user.principalId)) + (isTeamMember || isAuthor) const isBeingDeleted = deletingCommentId === comment.id // Can restore: deleted, team member, and restore handler provided const canRestore = isDeleted && isTeamMember && !!onRestoreComment @@ -342,6 +362,18 @@ function CommentItem({ } } + async function handleSaveEdit(): Promise { + const trimmed = editContent.trim() + if (!trimmed) return + setEditError(null) + try { + await editMutation.mutateAsync(trimmed) + setIsEditing(false) + } catch (err) { + setEditError(err instanceof Error ? err.message : 'Failed to save edit') + } + } + // Deleted comment placeholder (portal view - when not a team member admin) if (isDeleted && !isTeamMember) { return ( @@ -519,12 +551,60 @@ function CommentItem({ )} · + {comment.isEdited && ( + + {intl.formatMessage({ + id: 'portal.commentThread.edited', + defaultMessage: '(edited)', + })} + + )} - {/* Comment content */} -

- {comment.content} -

+ {/* Comment content — switches to an edit form when isEditing */} + {isEditing ? ( +
+