feat(comments): implement comment resolution for the community build

Add comment resolve/re-open as a community feature, written from scratch on top
of the infrastructure already present in the community codebase: the
resolved_at/resolved_by_id columns, the COMMENT_RESOLVED notification job, the
resolveCommentMark collaboration handler, the commentResolved websocket event,
the comment service/types and the Open/Resolved tabs. No Enterprise-Edition code
is reused and there is no EE feature gating — resolving is available to anyone
who can comment.

Backend:
- add POST /comments/resolve (ResolveCommentDto) guarded by validateCanComment;
  reject resolving replies
- add CommentService.resolveComment: set/clear resolvedAt/resolvedById, sync the
  inline comment mark via collaboration handleYjsEvent, queue
  COMMENT_RESOLVED_NOTIFICATION (only when another user resolves), emit the
  commentResolved websocket event and write a resolve/reopen audit log

Frontend:
- add useResolveCommentMutation with optimistic update + rollback
- add ResolveComment toggle button
- wire the resolve button and menu item into comment-list-item / comment-menu,
  gated on canComment for parent comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-16 23:31:03 +03:00
parent 0e069ddba0
commit c758a36dd2
7 changed files with 326 additions and 2 deletions

View File

@@ -7,9 +7,11 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useDeleteCommentMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
@@ -39,6 +41,7 @@ function CommentListItem({
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
@@ -75,6 +78,22 @@ function CommentListItem({
}
}
async function handleResolveComment() {
try {
const isResolved = comment.resolvedAt != null;
await resolveCommentMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
}
function handleCommentClick(comment: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
@@ -112,11 +131,24 @@ function CommentListItem({
</Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && (
<ResolveComment
editor={editor}
commentId={comment.id}
pageId={comment.pageId}
resolvedAt={comment.resolvedAt}
/>
)}
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
canComment={canComment}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
)}
</div>

View File

@@ -1,18 +1,26 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
type CommentMenuProps = {
onEditComment: () => void;
onDeleteComment: () => void;
onResolveComment?: () => void;
canEdit?: boolean;
canComment?: boolean;
isResolved?: boolean;
isParentComment?: boolean;
};
function CommentMenu({
onEditComment,
onDeleteComment,
onResolveComment,
canEdit = true,
canComment = true,
isResolved = false,
isParentComment = false,
}: CommentMenuProps) {
const { t } = useTranslation();
@@ -47,6 +55,20 @@ function CommentMenu({
{t("Edit comment")}
</Menu.Item>
)}
{isParentComment && canComment && (
<Menu.Item
onClick={onResolveComment}
leftSection={
isResolved ? (
<IconCircleCheckFilled size={14} />
) : (
<IconCircleCheck size={14} />
)
}
>
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}

View File

@@ -0,0 +1,64 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
interface ResolveCommentProps {
editor: Editor | null;
commentId: string;
pageId: string;
resolvedAt?: Date;
}
function ResolveComment({
editor,
commentId,
pageId,
resolvedAt,
}: ResolveCommentProps) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<Tooltip
label={isResolved ? t("Re-open comment") : t("Resolve comment")}
position="top"
>
<ActionIcon
onClick={handleResolveToggle}
variant="subtle"
color={isResolved ? "green" : "gray"}
size="sm"
loading={resolveCommentMutation.isPending}
disabled={resolveCommentMutation.isPending}
>
{isResolved ? (
<IconCircleCheckFilled size={18} />
) : (
<IconCircleCheck size={18} />
)}
</ActionIcon>
</Tooltip>
);
}
export default ResolveComment;

View File

@@ -8,11 +8,13 @@ import {
createComment,
deleteComment,
getPageComments,
resolveComment,
updateComment,
} from "@/features/comment/services/comment-service";
import {
ICommentParams,
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
@@ -157,3 +159,86 @@ export function useDeleteCommentMutation(pageId?: string) {
},
});
}
function updateCommentInCache(
cache: InfiniteData<IPagination<IComment>>,
commentId: string,
updater: (comment: IComment) => IComment,
): InfiniteData<IPagination<IComment>> {
return {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === commentId ? updater(comment) : comment,
),
})),
};
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
const cache = previousCache as
| InfiniteData<IPagination<IComment>>
| undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? "optimistic" : null,
resolvedBy: variables.resolved
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
: null,
})),
);
}
return { previousCache };
},
onError: (_err, variables, context) => {
if (context?.previousCache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
context.previousCache,
);
}
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
onSuccess: (data: IComment, variables) => {
const cache = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(data.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
}
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
});
},
});
}