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
This commit is contained in:
Philipinho
2025-07-25 01:00:12 -07:00
parent f9317b99a9
commit 03a58c2969
10 changed files with 283 additions and 78 deletions

View File

@ -14,6 +14,7 @@ import {
useDeleteCommentMutation, useDeleteCommentMutation,
useUpdateCommentMutation, useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query"; } from "@/features/comment/queries/comment-query";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types"; import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
@ -24,12 +25,14 @@ interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string; pageId: string;
canComment: boolean; canComment: boolean;
userSpaceRole?: string;
} }
function CommentListItem({ function CommentListItem({
comment, comment,
pageId, pageId,
canComment, canComment,
userSpaceRole,
}: CommentListItemProps) { }: CommentListItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
@ -39,6 +42,7 @@ function CommentListItem({
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const isCloudEE = useIsCloudEE(); 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) { function handleCommentClick(comment: IComment) {
const el = document.querySelector( const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`, `.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') && (
<CommentMenu <CommentMenu
onEditComment={handleEditToggle} onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment} onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/> />
)} )}
</div> </div>

View File

@ -107,12 +107,14 @@ function CommentListWithTabs() {
comment={comment} comment={comment}
pageId={page?.id} pageId={page?.id}
canComment={canComment} canComment={canComment}
userSpaceRole={space?.membership?.role}
/> />
<MemoizedChildComments <MemoizedChildComments
comments={comments} comments={comments}
parentId={comment.id} parentId={comment.id}
pageId={page?.id} pageId={page?.id}
canComment={canComment} canComment={canComment}
userSpaceRole={space?.membership?.role}
/> />
</div> </div>
@ -128,7 +130,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading] [comments, handleAddReply, isLoading, space?.membership?.role]
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@ -152,7 +154,33 @@ function CommentListWithTabs() {
<div style={{ paddingBottom: "200px" }}> <div style={{ paddingBottom: "200px" }}>
{comments?.items {comments?.items
.filter((comment: IComment) => comment.parentCommentId === null) .filter((comment: IComment) => comment.parentCommentId === null)
.map(renderComments)} .map((comment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
</Paper>
))}
</div> </div>
</ScrollArea> </ScrollArea>
); );
@ -221,12 +249,14 @@ interface ChildCommentsProps {
parentId: string; parentId: string;
pageId: string; pageId: string;
canComment: boolean; canComment: boolean;
userSpaceRole?: string;
} }
const ChildComments = ({ const ChildComments = ({
comments, comments,
parentId, parentId,
pageId, pageId,
canComment, canComment,
userSpaceRole,
}: ChildCommentsProps) => { }: ChildCommentsProps) => {
const getChildComments = useCallback( const getChildComments = useCallback(
(parentId: string) => (parentId: string) =>
@ -244,12 +274,14 @@ const ChildComments = ({
comment={childComment} comment={childComment}
pageId={pageId} pageId={pageId}
canComment={canComment} canComment={canComment}
userSpaceRole={userSpaceRole}
/> />
<MemoizedChildComments <MemoizedChildComments
comments={comments} comments={comments}
parentId={childComment.id} parentId={childComment.id}
pageId={pageId} pageId={pageId}
canComment={canComment} canComment={canComment}
userSpaceRole={userSpaceRole}
/> />
</div> </div>
))} ))}

View File

@ -1,15 +1,28 @@
import { ActionIcon, Menu } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } 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 { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
onDeleteComment: () => 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 { t } = useTranslation();
const isCloudEE = useIsCloudEE();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}> {canEdit && (
{t("Edit comment")} <Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
</Menu.Item> {t("Edit comment")}
</Menu.Item>
)}
{isParentComment && (
isCloudEE ? (
<Menu.Item
onClick={onResolveComment}
leftSection={
isResolved ?
<IconCircleCheckFilled size={14} /> :
<IconCircleCheck size={14} />
}
>
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={t("Available in enterprise edition")} position="left">
<Menu.Item
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")}
</Menu.Item>
</Tooltip>
)
)}
<Menu.Item <Menu.Item
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
onClick={openDeleteModal} onClick={openDeleteModal}

View File

@ -53,9 +53,11 @@ export class CommentController {
} }
return this.commentService.create( return this.commentService.create(
user.id, {
page.id, userId: user.id,
workspace.id, page,
workspaceId: workspace.id,
},
createCommentDto, createCommentDto,
); );
} }
@ -67,7 +69,6 @@ export class CommentController {
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
// @AuthWorkspace() workspace: Workspace,
) { ) {
const page = await this.pageRepo.findById(input.pageId); const page = await this.pageRepo.findById(input.pageId);
if (!page) { if (!page) {
@ -89,12 +90,7 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
const page = await this.pageRepo.findById(comment.pageId); const ability = await this.spaceAbility.createForUser(user, comment.spaceId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@ -103,19 +99,76 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
//TODO: only comment creators can update their comments const comment = await this.commentRepo.findById(dto.commentId);
return this.commentService.update( if (!comment) {
updateCommentDto.commentId, throw new NotFoundException('Comment not found');
updateCommentDto, }
const ability = await this.spaceAbility.createForUser(
user, 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) @HttpCode(HttpStatus.OK)
@Post('delete') @Post('delete')
remove(@Body() input: CommentIdDto, @AuthUser() user: User) { async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
// TODO: only comment creators and admins can delete their comments const comment = await this.commentRepo.findById(input.commentId);
return this.commentService.remove(input.commentId, user); 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);
} }
} }

View File

@ -7,16 +7,18 @@ import {
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
export class CommentService { export class CommentService {
constructor( constructor(
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
@ -31,11 +33,10 @@ export class CommentService {
} }
async create( async create(
userId: string, opts: { userId: string; page: Page; workspaceId: string },
pageId: string,
workspaceId: string,
createCommentDto: CreateCommentDto, createCommentDto: CreateCommentDto,
) { ) {
const { userId, page, workspaceId } = opts;
const commentContent = JSON.parse(createCommentDto.content); const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) { if (createCommentDto.parentCommentId) {
@ -43,7 +44,7 @@ export class CommentService {
createCommentDto.parentCommentId, createCommentDto.parentCommentId,
); );
if (!parentComment || parentComment.pageId !== pageId) { if (!parentComment || parentComment.pageId !== page.id) {
throw new BadRequestException('Parent comment not found'); throw new BadRequestException('Parent comment not found');
} }
@ -52,17 +53,16 @@ export class CommentService {
} }
} }
const createdComment = await this.commentRepo.insertComment({ return await this.commentRepo.insertComment({
pageId: pageId, pageId: page.id,
content: commentContent, content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250), selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline', type: 'inline',
parentCommentId: createCommentDto?.parentCommentId, parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
spaceId: page.spaceId,
}); });
return createdComment;
} }
async findByPageId( async findByPageId(
@ -75,26 +75,16 @@ export class CommentService {
throw new BadRequestException('Page not found'); throw new BadRequestException('Page not found');
} }
const pageComments = await this.commentRepo.findPageComments( return await this.commentRepo.findPageComments(pageId, pagination);
pageId,
pagination,
);
return pageComments;
} }
async update( async update(
commentId: string, comment: Comment,
updateCommentDto: UpdateCommentDto, updateCommentDto: UpdateCommentDto,
authUser: User, authUser: User,
): Promise<Comment> { ): Promise<Comment> {
const commentContent = JSON.parse(updateCommentDto.content); 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) { if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only edit your own comments'); throw new ForbiddenException('You can only edit your own comments');
} }
@ -105,26 +95,14 @@ export class CommentService {
{ {
content: commentContent, content: commentContent,
editedAt: editedAt, editedAt: editedAt,
updatedAt: editedAt,
}, },
commentId, comment.id,
); );
comment.content = commentContent; comment.content = commentContent;
comment.editedAt = editedAt; comment.editedAt = editedAt;
comment.updatedAt = editedAt;
return comment; return comment;
} }
async remove(commentId: string, authUser: User): Promise<void> {
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);
}
} }

View File

@ -1,14 +0,0 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('comments')
.addColumn('resolved_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute();
}

View File

@ -0,0 +1,61 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// 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<any>): Promise<void> {
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();
}

View File

@ -94,4 +94,25 @@ export class CommentRepo {
async deleteComment(commentId: string): Promise<void> { async deleteComment(commentId: string): Promise<void> {
await this.db.deleteFrom('comments').where('id', '=', commentId).execute(); await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
} }
async hasChildren(commentId: string): Promise<boolean> {
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<boolean> {
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;
}
} }

View File

@ -119,12 +119,15 @@ export interface Comments {
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
editedAt: Timestamp | null; editedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
lastEditedById: string | null;
pageId: string; pageId: string;
parentCommentId: string | null; parentCommentId: string | null;
resolvedAt: Timestamp | null; resolvedAt: Timestamp | null;
resolvedById: string | null; resolvedById: string | null;
selection: string | null; selection: string | null;
spaceId: string;
type: string | null; type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }