mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 18:51:10 +10:00
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:
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
{canEdit && (
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
{t("Edit comment")}
|
{t("Edit comment")}
|
||||||
</Menu.Item>
|
</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}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/server/src/database/types/db.d.ts
vendored
3
apps/server/src/database/types/db.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: 4d63297df0...35e3440f42
Reference in New Issue
Block a user