From 03a58c2969574427ea03105e30d1d9d8cbe6a34f Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 25 Jul 2025 01:00:12 -0700 Subject: [PATCH] feat: add space_id to comments and allow space admins to delete any comment - Add space_id column to comments table with data migration from pages - Add last_edited_by_id, resolved_by_id, and updated_at columns to comments - Update comment deletion permissions to allow space admins to delete any comment - Backfill space_id on old comments --- .../comment/components/comment-list-item.tsx | 35 +++++++- .../components/comment-list-with-tabs.tsx | 36 +++++++- .../comment/components/comment-menu.tsx | 50 +++++++++-- .../src/core/comment/comment.controller.ts | 89 +++++++++++++++---- .../src/core/comment/comment.service.ts | 50 +++-------- ...3T012831-add-resolved_by_id-to-comments.ts | 14 --- ...0250725T052004-add-new-comments-columns.ts | 61 +++++++++++++ .../database/repos/comment/comment.repo.ts | 21 +++++ apps/server/src/database/types/db.d.ts | 3 + apps/server/src/ee | 2 +- 10 files changed, 283 insertions(+), 78 deletions(-) delete mode 100644 apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts create mode 100644 apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.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 b34dbd35..ebf27196 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -14,6 +14,7 @@ import { useDeleteCommentMutation, useUpdateCommentMutation, } from "@/features/comment/queries/comment-query"; +import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; @@ -24,12 +25,14 @@ interface CommentListItemProps { comment: IComment; pageId: string; canComment: boolean; + userSpaceRole?: string; } function CommentListItem({ comment, pageId, canComment, + userSpaceRole, }: CommentListItemProps) { const { t } = useTranslation(); const { hovered, ref } = useHover(); @@ -39,6 +42,7 @@ function CommentListItem({ const [content, setContent] = useState(comment.content); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); + const resolveCommentMutation = useResolveCommentMutation(); const [currentUser] = useAtom(currentUserAtom); const emit = useQueryEmit(); const isCloudEE = useIsCloudEE(); @@ -82,6 +86,31 @@ function CommentListItem({ } } + async function handleResolveComment() { + if (!isCloudEE) return; + + 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); + } + + emit({ + operation: "invalidateComment", + pageId: pageId, + }); + } 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}"]`, @@ -127,10 +156,14 @@ function CommentListItem({ /> )} - {currentUser?.user?.id === comment.creatorId && ( + {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( )} diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index cef83e85..792cac40 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -107,12 +107,14 @@ function CommentListWithTabs() { comment={comment} pageId={page?.id} canComment={canComment} + userSpaceRole={space?.membership?.role} /> @@ -128,7 +130,7 @@ function CommentListWithTabs() { )} ), - [comments, handleAddReply, isLoading] + [comments, handleAddReply, isLoading, space?.membership?.role] ); if (isCommentsLoading) { @@ -152,7 +154,33 @@ function CommentListWithTabs() {
{comments?.items .filter((comment: IComment) => comment.parentCommentId === null) - .map(renderComments)} + .map((comment) => ( + +
+ + +
+
+ ))}
); @@ -221,12 +249,14 @@ interface ChildCommentsProps { parentId: string; pageId: string; canComment: boolean; + userSpaceRole?: string; } const ChildComments = ({ comments, parentId, pageId, canComment, + userSpaceRole, }: ChildCommentsProps) => { const getChildComments = useCallback( (parentId: string) => @@ -244,12 +274,14 @@ const ChildComments = ({ comment={childComment} pageId={pageId} canComment={canComment} + userSpaceRole={userSpaceRole} /> ))} diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index 051c415a..0726c36f 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -1,15 +1,28 @@ -import { ActionIcon, Menu } from "@mantine/core"; -import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { ActionIcon, Menu, Tooltip } from "@mantine/core"; +import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react"; import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; type CommentMenuProps = { onEditComment: () => void; onDeleteComment: () => void; + onResolveComment?: () => void; + canEdit?: boolean; + isResolved?: boolean; + isParentComment?: boolean; }; -function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) { +function CommentMenu({ + onEditComment, + onDeleteComment, + onResolveComment, + canEdit = true, + isResolved = false, + isParentComment = false +}: CommentMenuProps) { const { t } = useTranslation(); + const isCloudEE = useIsCloudEE(); //@ts-ignore const openDeleteModal = () => @@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) { - }> - {t("Edit comment")} - + {canEdit && ( + }> + {t("Edit comment")} + + )} + {isParentComment && ( + isCloudEE ? ( + : + + } + > + {isResolved ? t("Re-open comment") : t("Resolve comment")} + + ) : ( + + } + > + {t("Resolve comment")} + + + ) + )} } onClick={openDeleteModal} diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index fcb19723..ea9447b6 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -53,9 +53,11 @@ export class CommentController { } return this.commentService.create( - user.id, - page.id, - workspace.id, + { + userId: user.id, + page, + workspaceId: workspace.id, + }, createCommentDto, ); } @@ -67,7 +69,6 @@ export class CommentController { @Body() pagination: PaginationOptions, @AuthUser() user: User, - // @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(input.pageId); if (!page) { @@ -89,12 +90,7 @@ export class CommentController { throw new NotFoundException('Comment not found'); } - const page = await this.pageRepo.findById(comment.pageId); - if (!page) { - throw new NotFoundException('Page not found'); - } - - const ability = await this.spaceAbility.createForUser(user, page.spaceId); + const ability = await this.spaceAbility.createForUser(user, comment.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } @@ -103,19 +99,76 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('update') - update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) { - //TODO: only comment creators can update their comments - return this.commentService.update( - updateCommentDto.commentId, - updateCommentDto, + async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) { + const comment = await this.commentRepo.findById(dto.commentId); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + + const ability = await this.spaceAbility.createForUser( user, + comment.spaceId, ); + + // must be a space member with edit permission + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException( + 'You must have space edit permission to edit comments', + ); + } + + return this.commentService.update(comment, dto, user); } @HttpCode(HttpStatus.OK) @Post('delete') - remove(@Body() input: CommentIdDto, @AuthUser() user: User) { - // TODO: only comment creators and admins can delete their comments - return this.commentService.remove(input.commentId, user); + async delete(@Body() input: CommentIdDto, @AuthUser() user: User) { + const comment = await this.commentRepo.findById(input.commentId); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + + const ability = await this.spaceAbility.createForUser( + user, + comment.spaceId, + ); + + // must be a space member with edit permission + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + // Check if user is the comment owner + const isOwner = comment.creatorId === user.id; + + if (isOwner) { + /* + // Check if comment has children from other users + const hasChildrenFromOthers = + await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id); + + // Owner can delete if no children from other users + if (!hasChildrenFromOthers) { + await this.commentRepo.deleteComment(comment.id); + return; + } + + // If has children from others, only space admin can delete + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException( + 'Only space admins can delete comments with replies from other users', + ); + }*/ + await this.commentRepo.deleteComment(comment.id); + return; + } + + // Space admin can delete any comment + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException( + 'You can only delete your own comments or must be a space admin', + ); + } + await this.commentRepo.deleteComment(comment.id); } } diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 4f64d94b..61fb15c9 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -7,16 +7,18 @@ import { import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; -import { Comment, User } from '@docmost/db/types/entity.types'; +import { Comment, Page, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class CommentService { constructor( private commentRepo: CommentRepo, private pageRepo: PageRepo, + private spaceMemberRepo: SpaceMemberRepo, ) {} async findById(commentId: string) { @@ -31,11 +33,10 @@ export class CommentService { } async create( - userId: string, - pageId: string, - workspaceId: string, + opts: { userId: string; page: Page; workspaceId: string }, createCommentDto: CreateCommentDto, ) { + const { userId, page, workspaceId } = opts; const commentContent = JSON.parse(createCommentDto.content); if (createCommentDto.parentCommentId) { @@ -43,7 +44,7 @@ export class CommentService { createCommentDto.parentCommentId, ); - if (!parentComment || parentComment.pageId !== pageId) { + if (!parentComment || parentComment.pageId !== page.id) { throw new BadRequestException('Parent comment not found'); } @@ -52,17 +53,16 @@ export class CommentService { } } - const createdComment = await this.commentRepo.insertComment({ - pageId: pageId, + return await this.commentRepo.insertComment({ + pageId: page.id, content: commentContent, selection: createCommentDto?.selection?.substring(0, 250), type: 'inline', parentCommentId: createCommentDto?.parentCommentId, creatorId: userId, workspaceId: workspaceId, + spaceId: page.spaceId, }); - - return createdComment; } async findByPageId( @@ -75,26 +75,16 @@ export class CommentService { throw new BadRequestException('Page not found'); } - const pageComments = await this.commentRepo.findPageComments( - pageId, - pagination, - ); - - return pageComments; + return await this.commentRepo.findPageComments(pageId, pagination); } async update( - commentId: string, + comment: Comment, updateCommentDto: UpdateCommentDto, authUser: User, ): Promise { const commentContent = JSON.parse(updateCommentDto.content); - const comment = await this.commentRepo.findById(commentId); - if (!comment) { - throw new NotFoundException('Comment not found'); - } - if (comment.creatorId !== authUser.id) { throw new ForbiddenException('You can only edit your own comments'); } @@ -105,26 +95,14 @@ export class CommentService { { content: commentContent, editedAt: editedAt, + updatedAt: editedAt, }, - commentId, + comment.id, ); comment.content = commentContent; comment.editedAt = editedAt; + comment.updatedAt = editedAt; return comment; } - - async remove(commentId: string, authUser: User): Promise { - const comment = await this.commentRepo.findById(commentId); - - if (!comment) { - throw new NotFoundException('Comment not found'); - } - - if (comment.creatorId !== authUser.id) { - throw new ForbiddenException('You can only delete your own comments'); - } - - await this.commentRepo.deleteComment(commentId); - } } diff --git a/apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts b/apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts deleted file mode 100644 index 7eb98792..00000000 --- a/apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Kysely } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('comments') - .addColumn('resolved_by_id', 'uuid', (col) => - col.references('users.id').onDelete('cascade'), - ) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute(); -} diff --git a/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts b/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts new file mode 100644 index 00000000..03fd9318 --- /dev/null +++ b/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts @@ -0,0 +1,61 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Add last_edited_by_id column to comments table + await db.schema + .alterTable('comments') + .addColumn('last_edited_by_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade'), + ) + .execute(); + + // Add resolved_by_id column to comments table + await db.schema + .alterTable('comments') + .addColumn('resolved_by_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade'), + ) + .execute(); + + // Add updated_at timestamp column to comments table + await db.schema + .alterTable('comments') + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + // Add space_id column to comments table + await db.schema + .alterTable('comments') + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade'), + ) + .execute(); + + // Backfill space_id from the related pages + await db + .updateTable('comments as c') + .set((eb) => ({ + space_id: eb.ref('p.space_id'), + })) + .from('pages as p') + .whereRef('c.page_id', '=', 'p.id') + .execute(); + + // Make space_id NOT NULL after populating data + await db.schema + .alterTable('comments') + .alterColumn('space_id', (col) => col.setNotNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('comments') + .dropColumn('last_edited_by_id') + .execute(); + await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute(); + await db.schema.alterTable('comments').dropColumn('updated_at').execute(); + await db.schema.alterTable('comments').dropColumn('space_id').execute(); +} diff --git a/apps/server/src/database/repos/comment/comment.repo.ts b/apps/server/src/database/repos/comment/comment.repo.ts index 11d2f149..965bd611 100644 --- a/apps/server/src/database/repos/comment/comment.repo.ts +++ b/apps/server/src/database/repos/comment/comment.repo.ts @@ -94,4 +94,25 @@ export class CommentRepo { async deleteComment(commentId: string): Promise { await this.db.deleteFrom('comments').where('id', '=', commentId).execute(); } + + async hasChildren(commentId: string): Promise { + const result = await this.db + .selectFrom('comments') + .select((eb) => eb.fn.count('id').as('count')) + .where('parentCommentId', '=', commentId) + .executeTakeFirst(); + + return Number(result?.count) > 0; + } + + async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise { + const result = await this.db + .selectFrom('comments') + .select((eb) => eb.fn.count('id').as('count')) + .where('parentCommentId', '=', commentId) + .where('creatorId', '!=', userId) + .executeTakeFirst(); + + return Number(result?.count) > 0; + } } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index ab1394c0..e8662649 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -119,12 +119,15 @@ export interface Comments { deletedAt: Timestamp | null; editedAt: Timestamp | null; id: Generated; + lastEditedById: string | null; pageId: string; parentCommentId: string | null; resolvedAt: Timestamp | null; resolvedById: string | null; selection: string | null; + spaceId: string; type: string | null; + updatedAt: Generated; workspaceId: string; } diff --git a/apps/server/src/ee b/apps/server/src/ee index 4d63297d..35e3440f 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 4d63297df08a5c74dc8565f769dc3b83486686d2 +Subproject commit 35e3440f422ae1120147c6e290b19f54c74f1364