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

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

View File

@@ -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<Comment> {
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[],

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsUUID } from 'class-validator';
export class ResolveCommentDto {
@IsUUID()
commentId: string;
@IsBoolean()
resolved: boolean;
}