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,
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<string>(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') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
)}
</div>

View File

@ -107,12 +107,14 @@ function CommentListWithTabs() {
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>
@ -128,7 +130,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading]
[comments, handleAddReply, isLoading, space?.membership?.role]
);
if (isCommentsLoading) {
@ -152,7 +154,33 @@ function CommentListWithTabs() {
<div style={{ paddingBottom: "200px" }}>
{comments?.items
.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>
</ScrollArea>
);
@ -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}
/>
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
userSpaceRole={userSpaceRole}
/>
</div>
))}

View File

@ -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) {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
{canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{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
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}

View File

@ -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);
}
}

View File

@ -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<Comment> {
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<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> {
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;
editedAt: Timestamp | null;
id: Generated<string>;
lastEditedById: string | null;
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
resolvedById: string | null;
selection: string | null;
spaceId: string;
type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}