From c758a36dd2eee1aeb27be6655601ce74fe7cc06a Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Tue, 16 Jun 2026 23:31:03 +0300 Subject: [PATCH] feat(comments): implement comment resolution for the community build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../comment/components/comment-list-item.tsx | 32 +++++++ .../comment/components/comment-menu.tsx | 24 +++++- .../comment/components/resolve-comment.tsx | 64 ++++++++++++++ .../features/comment/queries/comment-query.ts | 85 +++++++++++++++++++ .../src/core/comment/comment.controller.ts | 50 +++++++++++ .../src/core/comment/comment.service.ts | 64 +++++++++++++- .../core/comment/dto/resolve-comment.dto.ts | 9 ++ 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/comment/components/resolve-comment.tsx create mode 100644 apps/server/src/core/comment/dto/resolve-comment.dto.ts diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 87ac46ba..a53e326a 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -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(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({
+ {!comment.parentCommentId && canComment && ( + + )} + {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( )}
diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index d5f2cf73..7fcce8a1 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -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")} )} + {isParentComment && canComment && ( + + ) : ( + + ) + } + > + {isResolved ? t("Re-open comment") : t("Resolve comment")} + + )} } onClick={openDeleteModal} diff --git a/apps/client/src/features/comment/components/resolve-comment.tsx b/apps/client/src/features/comment/components/resolve-comment.tsx new file mode 100644 index 00000000..a5c791d2 --- /dev/null +++ b/apps/client/src/features/comment/components/resolve-comment.tsx @@ -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 ( + + + {isResolved ? ( + + ) : ( + + )} + + + ); +} + +export default ResolveComment; diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 5dfd0af8..5d637610 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -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>, + commentId: string, + updater: (comment: IComment) => IComment, +): InfiniteData> { + 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> + | 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> | 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"), + }); + }, + }); +} diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 22458848..94f0d585 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -8,10 +8,12 @@ import { Inject, NotFoundException, ForbiddenException, + BadRequestException, } from '@nestjs/common'; import { CommentService } from './comment.service'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; +import { ResolveCommentDto } from './dto/resolve-comment.dto'; import { PageIdDto, CommentIdDto } from './dto/comments.input'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; @@ -139,6 +141,54 @@ export class CommentController { return this.commentService.update(comment, dto, user); } + @HttpCode(HttpStatus.OK) + @Post('resolve') + async resolve( + @Body() dto: ResolveCommentDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const comment = await this.commentRepo.findById(dto.commentId, { + includeCreator: true, + includeResolvedBy: true, + }); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + + // Only top-level comments can be resolved; replies follow their parent. + if (comment.parentCommentId) { + throw new BadRequestException('Only parent comments can be resolved'); + } + + const page = await this.pageRepo.findById(comment.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + await this.pageAccessService.validateCanComment(page, user, workspace.id); + + const updated = await this.commentService.resolveComment( + comment, + dto.resolved, + user, + ); + + this.auditService.log({ + event: dto.resolved + ? AuditEvent.COMMENT_RESOLVED + : AuditEvent.COMMENT_REOPENED, + resourceType: AuditResource.COMMENT, + resourceId: comment.id, + spaceId: comment.spaceId, + metadata: { + pageId: comment.pageId, + }, + }); + + return updated; + } + @HttpCode(HttpStatus.OK) @Post('delete') async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index e888ef50..1f6d8d4f 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -17,7 +17,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils'; -import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface'; +import { + ICommentNotificationJob, + ICommentResolvedNotificationJob, +} from '../../integrations/queue/constants/queue.interface'; import { WsService } from '../../ws/ws.service'; @Injectable() @@ -206,6 +209,65 @@ export class CommentService { return comment; } + async resolveComment( + comment: Comment, + resolved: boolean, + authUser: User, + ): Promise { + const resolvedAt = resolved ? new Date() : null; + const resolvedById = resolved ? authUser.id : null; + + await this.commentRepo.updateComment( + { resolvedAt, resolvedById }, + comment.id, + ); + + // Reflect the resolved state on the inline comment mark in the + // collaborative document so all connected clients stay in sync. + const documentName = `page.${comment.pageId}`; + try { + await this.collaborationGateway.handleYjsEvent( + 'resolveCommentMark', + documentName, + { commentId: comment.id, resolved, user: authUser }, + ); + } catch (error) { + this.logger.warn( + `Failed to update comment mark for comment ${comment.id}`, + error, + ); + } + + // Notify the comment author when someone else resolves their comment. + if (resolved && comment.creatorId !== authUser.id) { + const jobData: ICommentResolvedNotificationJob = { + commentId: comment.id, + commentCreatorId: comment.creatorId, + pageId: comment.pageId, + spaceId: comment.spaceId, + workspaceId: comment.workspaceId, + actorId: authUser.id, + }; + await this.notificationQueue.add( + QueueJob.COMMENT_RESOLVED_NOTIFICATION, + jobData, + ); + } + + const updatedComment = await this.commentRepo.findById(comment.id, { + includeCreator: true, + includeResolvedBy: true, + }); + + this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, { + operation: 'commentResolved', + pageId: comment.pageId, + comment: updatedComment, + }); + + return updatedComment; + } + private async queueCommentNotification( content: any, oldMentionIds: string[], diff --git a/apps/server/src/core/comment/dto/resolve-comment.dto.ts b/apps/server/src/core/comment/dto/resolve-comment.dto.ts new file mode 100644 index 00000000..73bc9cd2 --- /dev/null +++ b/apps/server/src/core/comment/dto/resolve-comment.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsUUID } from 'class-validator'; + +export class ResolveCommentDto { + @IsUUID() + commentId: string; + + @IsBoolean() + resolved: boolean; +}