{!comment.parentCommentId && comment?.selection && (
-
handleCommentClick(comment)}>
+ handleCommentClick(comment)}
+ >
{comment?.selection}
)}
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
new file mode 100644
index 00000000..792cac40
--- /dev/null
+++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
@@ -0,0 +1,318 @@
+import React, { useState, useRef, useCallback, memo, useMemo } from "react";
+import { useParams } from "react-router-dom";
+import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
+import CommentListItem from "@/features/comment/components/comment-list-item";
+import {
+ useCommentsQuery,
+ useCreateCommentMutation,
+} from "@/features/comment/queries/comment-query";
+import CommentEditor from "@/features/comment/components/comment-editor";
+import CommentActions from "@/features/comment/components/comment-actions";
+import { useFocusWithin } from "@mantine/hooks";
+import { IComment } from "@/features/comment/types/comment.types.ts";
+import { usePageQuery } from "@/features/page/queries/page-query.ts";
+import { IPagination } from "@/lib/types.ts";
+import { extractPageSlugId } from "@/lib";
+import { useTranslation } from "react-i18next";
+import { useQueryEmit } from "@/features/websocket/use-query-emit";
+import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
+import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
+import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from "@/features/space/permissions/permissions.type.ts";
+
+function CommentListWithTabs() {
+ const { t } = useTranslation();
+ const { pageSlug } = useParams();
+ const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
+ const {
+ data: comments,
+ isLoading: isCommentsLoading,
+ isError,
+ } = useCommentsQuery({ pageId: page?.id, limit: 100 });
+ const createCommentMutation = useCreateCommentMutation();
+ const [isLoading, setIsLoading] = useState(false);
+ const emit = useQueryEmit();
+ const isCloudEE = useIsCloudEE();
+ const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
+
+ const spaceRules = space?.membership?.permissions;
+ const spaceAbility = useSpaceAbility(spaceRules);
+
+ const canComment: boolean = spaceAbility.can(
+ SpaceCaslAction.Manage,
+ SpaceCaslSubject.Page
+ );
+
+ // Separate active and resolved comments
+ const { activeComments, resolvedComments } = useMemo(() => {
+ if (!comments?.items) {
+ return { activeComments: [], resolvedComments: [] };
+ }
+
+ const parentComments = comments.items.filter(
+ (comment: IComment) => comment.parentCommentId === null
+ );
+
+ const active = parentComments.filter(
+ (comment: IComment) => !comment.resolvedAt
+ );
+ const resolved = parentComments.filter(
+ (comment: IComment) => comment.resolvedAt
+ );
+
+ return { activeComments: active, resolvedComments: resolved };
+ }, [comments]);
+
+ const handleAddReply = useCallback(
+ async (commentId: string, content: string) => {
+ try {
+ setIsLoading(true);
+ const commentData = {
+ pageId: page?.id,
+ parentCommentId: commentId,
+ content: JSON.stringify(content),
+ };
+
+ await createCommentMutation.mutateAsync(commentData);
+
+ emit({
+ operation: "invalidateComment",
+ pageId: page?.id,
+ });
+ } catch (error) {
+ console.error("Failed to post comment:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [createCommentMutation, page?.id]
+ );
+
+ const renderComments = useCallback(
+ (comment: IComment) => (
+
+
+
+
+
+
+ {!comment.resolvedAt && canComment && (
+ <>
+
+
+ >
+ )}
+
+ ),
+ [comments, handleAddReply, isLoading, space?.membership?.role]
+ );
+
+ if (isCommentsLoading) {
+ return <>>;
+ }
+
+ if (isError) {
+ return {t("Error loading comments.")}
;
+ }
+
+ const totalComments = activeComments.length + resolvedComments.length;
+
+ // If not cloud/enterprise, show simple list without tabs
+ if (!isCloudEE) {
+ if (totalComments === 0) {
+ return <>{t("No comments yet.")}>;
+ }
+
+ return (
+
+
+ {comments?.items
+ .filter((comment: IComment) => comment.parentCommentId === null)
+ .map((comment) => (
+
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {activeComments.length}
+
+ }
+ >
+ {t("Open")}
+
+
+ {resolvedComments.length}
+
+ }
+ >
+ {t("Resolved")}
+
+
+
+
+
+
+ {activeComments.length === 0 ? (
+
+ {t("No open comments.")}
+
+ ) : (
+ activeComments.map(renderComments)
+ )}
+
+
+
+ {resolvedComments.length === 0 ? (
+
+ {t("No resolved comments.")}
+
+ ) : (
+ resolvedComments.map(renderComments)
+ )}
+
+
+
+
+
+ );
+}
+
+interface ChildCommentsProps {
+ comments: IPagination;
+ parentId: string;
+ pageId: string;
+ canComment: boolean;
+ userSpaceRole?: string;
+}
+const ChildComments = ({
+ comments,
+ parentId,
+ pageId,
+ canComment,
+ userSpaceRole,
+}: ChildCommentsProps) => {
+ const getChildComments = useCallback(
+ (parentId: string) =>
+ comments.items.filter(
+ (comment: IComment) => comment.parentCommentId === parentId
+ ),
+ [comments.items]
+ );
+
+ return (
+
+ {getChildComments(parentId).map((childComment) => (
+
+
+
+
+ ))}
+
+ );
+};
+
+const MemoizedChildComments = memo(ChildComments);
+
+const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
+ const [content, setContent] = useState("");
+ const { ref, focused } = useFocusWithin();
+ const commentEditorRef = useRef(null);
+
+ const handleSave = useCallback(() => {
+ onSave(commentId, content);
+ setContent("");
+ commentEditorRef.current?.clearContent();
+ }, [commentId, content, onSave]);
+
+ return (
+
+
+ {focused && }
+
+ );
+};
+
+export default CommentListWithTabs;
diff --git a/apps/client/src/features/comment/components/comment-list.tsx b/apps/client/src/features/comment/components/comment-list.tsx
deleted file mode 100644
index 1aded495..00000000
--- a/apps/client/src/features/comment/components/comment-list.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import React, { useState, useRef, useCallback, memo } from "react";
-import { useParams } from "react-router-dom";
-import { Divider, Paper } from "@mantine/core";
-import CommentListItem from "@/features/comment/components/comment-list-item";
-import {
- useCommentsQuery,
- useCreateCommentMutation,
-} from "@/features/comment/queries/comment-query";
-import CommentEditor from "@/features/comment/components/comment-editor";
-import CommentActions from "@/features/comment/components/comment-actions";
-import { useFocusWithin } from "@mantine/hooks";
-import { IComment } from "@/features/comment/types/comment.types.ts";
-import { usePageQuery } from "@/features/page/queries/page-query.ts";
-import { IPagination } from "@/lib/types.ts";
-import { extractPageSlugId } from "@/lib";
-import { useTranslation } from "react-i18next";
-import { useQueryEmit } from "@/features/websocket/use-query-emit";
-
-function CommentList() {
- const { t } = useTranslation();
- const { pageSlug } = useParams();
- const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
- const {
- data: comments,
- isLoading: isCommentsLoading,
- isError,
- } = useCommentsQuery({ pageId: page?.id, limit: 100 });
- const createCommentMutation = useCreateCommentMutation();
- const [isLoading, setIsLoading] = useState(false);
- const emit = useQueryEmit();
-
- const handleAddReply = useCallback(
- async (commentId: string, content: string) => {
- try {
- setIsLoading(true);
- const commentData = {
- pageId: page?.id,
- parentCommentId: commentId,
- content: JSON.stringify(content),
- };
-
- await createCommentMutation.mutateAsync(commentData);
-
- emit({
- operation: "invalidateComment",
- pageId: page?.id,
- });
- } catch (error) {
- console.error("Failed to post comment:", error);
- } finally {
- setIsLoading(false);
- }
- },
- [createCommentMutation, page?.id],
- );
-
- const renderComments = useCallback(
- (comment: IComment) => (
-
-
-
-
-
-
-
-
-
-
- ),
- [comments, handleAddReply, isLoading],
- );
-
- if (isCommentsLoading) {
- return <>>;
- }
-
- if (isError) {
- return {t("Error loading comments.")}
;
- }
-
- if (!comments || comments.items.length === 0) {
- return <>{t("No comments yet.")}>;
- }
-
- return (
- <>
- {comments.items
- .filter((comment) => comment.parentCommentId === null)
- .map(renderComments)}
- >
- );
-}
-
-interface ChildCommentsProps {
- comments: IPagination;
- parentId: string;
- pageId: string;
-}
-const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
- const getChildComments = useCallback(
- (parentId: string) =>
- comments.items.filter(
- (comment: IComment) => comment.parentCommentId === parentId,
- ),
- [comments.items],
- );
-
- return (
-
- {getChildComments(parentId).map((childComment) => (
-
-
-
-
- ))}
-
- );
-};
-
-const MemoizedChildComments = memo(ChildComments);
-
-const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
- const [content, setContent] = useState("");
- const { ref, focused } = useFocusWithin();
- const commentEditorRef = useRef(null);
-
- const handleSave = useCallback(() => {
- onSave(commentId, content);
- setContent("");
- commentEditorRef.current?.clearContent();
- }, [commentId, content, onSave]);
-
- return (
-
-
- {focused && }
-
- );
-};
-
-export default CommentList;
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/client/src/features/comment/components/resolve-comment.tsx b/apps/client/src/features/comment/components/resolve-comment.tsx
deleted file mode 100644
index 9d7c4e7c..00000000
--- a/apps/client/src/features/comment/components/resolve-comment.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { ActionIcon } from "@mantine/core";
-import { IconCircleCheck } from "@tabler/icons-react";
-import { modals } from "@mantine/modals";
-import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
-import { useTranslation } from "react-i18next";
-
-function ResolveComment({ commentId, pageId, resolvedAt }) {
- const { t } = useTranslation();
- const resolveCommentMutation = useResolveCommentMutation();
-
- const isResolved = resolvedAt != null;
- const iconColor = isResolved ? "green" : "gray";
-
- //@ts-ignore
- const openConfirmModal = () =>
- modals.openConfirmModal({
- title: t("Are you sure you want to resolve this comment thread?"),
- centered: true,
- labels: { confirm: t("Confirm"), cancel: t("Cancel") },
- onConfirm: handleResolveToggle,
- });
-
- const handleResolveToggle = async () => {
- try {
- await resolveCommentMutation.mutateAsync({
- commentId,
- resolved: !isResolved,
- });
- //TODO: remove comment mark
- // Remove comment thread from state on resolve
- } catch (error) {
- console.error("Failed to toggle resolved state:", error);
- }
- };
-
- return (
-
-
-
- );
-}
-
-export default ResolveComment;
diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts
index 85b2e549..c10ca418 100644
--- a/apps/client/src/features/comment/queries/comment-query.ts
+++ b/apps/client/src/features/comment/queries/comment-query.ts
@@ -8,13 +8,11 @@ import {
createComment,
deleteComment,
getPageComments,
- resolveComment,
updateComment,
} from "@/features/comment/services/comment-service";
import {
ICommentParams,
IComment,
- IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
@@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
});
}
-export function useResolveCommentMutation() {
- const queryClient = useQueryClient();
- const { t } = useTranslation();
-
- return useMutation({
- mutationFn: (data: IResolveComment) => resolveComment(data),
- onSuccess: (data: IComment, variables) => {
- const currentComments = queryClient.getQueryData(
- RQ_KEY(data.pageId),
- ) as IComment[];
-
- /*
- if (currentComments) {
- const updatedComments = currentComments.map((comment) =>
- comment.id === variables.commentId
- ? { ...comment, ...data }
- : comment,
- );
- queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
- }*/
-
- notifications.show({ message: t("Comment resolved successfully") });
- },
- onError: (error) => {
- notifications.show({
- message: t("Failed to resolve comment"),
- color: "red",
- });
- },
- });
-}
+// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
diff --git a/apps/client/src/features/comment/types/comment.types.ts b/apps/client/src/features/comment/types/comment.types.ts
index a29e770e..6c8cc909 100644
--- a/apps/client/src/features/comment/types/comment.types.ts
+++ b/apps/client/src/features/comment/types/comment.types.ts
@@ -16,6 +16,7 @@ export interface IComment {
editedAt?: Date;
deletedAt?: Date;
creator: IUser;
+ resolvedBy?: IUser;
}
export interface ICommentData {
@@ -28,6 +29,7 @@ export interface ICommentData {
export interface IResolveComment {
commentId: string;
+ pageId: string;
resolved: boolean;
}
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 01f6dc55..5d88b208 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -75,7 +75,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
- yjsConnectionStatusAtom,
+ yjsConnectionStatusAtom
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
@@ -262,7 +262,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
- [pageId, editable, remoteProvider],
+ [pageId, editable, remoteProvider]
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@@ -278,7 +278,12 @@ export default function PageEditor({
}, 3000);
const handleActiveCommentEvent = (event) => {
- const { commentId } = event.detail;
+ const { commentId, resolved } = event.detail;
+
+ if (resolved) {
+ return;
+ }
+
setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true });
@@ -295,7 +300,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
- handleActiveCommentEvent,
+ handleActiveCommentEvent
);
};
}, []);
diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css
index 38acb74b..de60d710 100644
--- a/apps/client/src/features/editor/styles/core.css
+++ b/apps/client/src/features/editor/styles/core.css
@@ -142,6 +142,11 @@
.comment-mark {
background: rgba(255, 215, 0, 0.14);
border-bottom: 2px solid rgb(166, 158, 12);
+
+ &.resolved {
+ background: none;
+ border-bottom: none;
+ }
}
.comment-highlight {
@@ -187,7 +192,7 @@
mask-size: 100% 100%;
background-color: currentColor;
- & -open {
+ &-open {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
}
diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts
index 25b57df9..21561038 100644
--- a/apps/client/src/features/websocket/types/types.ts
+++ b/apps/client/src/features/websocket/types/types.ts
@@ -63,6 +63,20 @@ export type RefetchRootTreeNodeEvent = {
spaceId: string;
};
+export type ResolveCommentEvent = {
+ operation: "resolveComment";
+ pageId: string;
+ commentId: string;
+ resolved: boolean;
+ resolvedAt?: Date;
+ resolvedById?: string;
+ resolvedBy?: {
+ id: string;
+ name: string;
+ avatarUrl?: string | null;
+ };
+};
+
export type WebSocketEvent =
| InvalidateEvent
| InvalidateCommentsEvent
@@ -71,4 +85,5 @@ export type WebSocketEvent =
| AddTreeNodeEvent
| MoveTreeNodeEvent
| DeleteTreeNodeEvent
- | RefetchRootTreeNodeEvent;
+ | RefetchRootTreeNodeEvent
+ | ResolveCommentEvent;
diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts
index 29a815be..3aa95417 100644
--- a/apps/client/src/features/websocket/use-query-subscription.ts
+++ b/apps/client/src/features/websocket/use-query-subscription.ts
@@ -13,6 +13,7 @@ import {
} from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query";
import { queryClient } from "@/main.tsx";
+import { IComment } from "@/features/comment/types/comment.types";
export const useQuerySubscription = () => {
const queryClient = useQueryClient();
@@ -96,6 +97,30 @@ export const useQuerySubscription = () => {
});
break;
}
+ case "resolveComment": {
+ const currentComments = queryClient.getQueryData(
+ RQ_KEY(data.pageId),
+ ) as IPagination;
+
+ if (currentComments && currentComments.items) {
+ const updatedComments = currentComments.items.map((comment) =>
+ comment.id === data.commentId
+ ? {
+ ...comment,
+ resolvedAt: data.resolvedAt,
+ resolvedById: data.resolvedById,
+ resolvedBy: data.resolvedBy
+ }
+ : comment,
+ );
+
+ queryClient.setQueryData(RQ_KEY(data.pageId), {
+ ...currentComments,
+ items: updatedComments,
+ });
+ }
+ break;
+ }
}
});
}, [queryClient, socket]);
diff --git a/apps/client/src/hooks/use-is-cloud-ee.tsx b/apps/client/src/hooks/use-is-cloud-ee.tsx
new file mode 100644
index 00000000..148fe395
--- /dev/null
+++ b/apps/client/src/hooks/use-is-cloud-ee.tsx
@@ -0,0 +1,7 @@
+import { isCloud } from "@/lib/config";
+import { useLicense } from "@/ee/hooks/use-license";
+
+export const useIsCloudEE = () => {
+ const { hasLicenseKey } = useLicense();
+ return isCloud() || !!hasLicenseKey;
+};
\ No newline at end of file
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 b1d4ae4c..61fb15c9 100644
--- a/apps/server/src/core/comment/comment.service.ts
+++ b/apps/server/src/core/comment/comment.service.ts
@@ -7,21 +7,24 @@ 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) {
const comment = await this.commentRepo.findById(commentId, {
includeCreator: true,
+ includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -30,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) {
@@ -42,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');
}
@@ -51,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(
@@ -74,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');
}
@@ -104,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/20250725T052004-add-new-comments-columns.ts b/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts
new file mode 100644
index 00000000..74feb0e8
--- /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('set null'),
+ )
+ .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('set null'),
+ )
+ .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 8f957f42..965bd611 100644
--- a/apps/server/src/database/repos/comment/comment.repo.ts
+++ b/apps/server/src/database/repos/comment/comment.repo.ts
@@ -20,12 +20,13 @@ export class CommentRepo {
// todo, add workspaceId
async findById(
commentId: string,
- opts?: { includeCreator: boolean },
+ opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise {
return await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
+ .$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
.where('id', '=', commentId)
.executeTakeFirst();
}
@@ -35,6 +36,7 @@ export class CommentRepo {
.selectFrom('comments')
.selectAll('comments')
.select((eb) => this.withCreator(eb))
+ .select((eb) => this.withResolvedBy(eb))
.where('pageId', '=', pageId)
.orderBy('createdAt', 'asc');
@@ -80,7 +82,37 @@ export class CommentRepo {
).as('creator');
}
+ withResolvedBy(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('users')
+ .select(['users.id', 'users.name', 'users.avatarUrl'])
+ .whereRef('users.id', '=', 'comments.resolvedById'),
+ ).as('resolvedBy');
+ }
+
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 1d2051d4..e8662649 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -119,11 +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 64758945..35e3440f 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 647589454224fcba963f76f6d44539e33369ff39
+Subproject commit 35e3440f422ae1120147c6e290b19f54c74f1364
diff --git a/packages/editor-ext/src/lib/comment/comment.ts b/packages/editor-ext/src/lib/comment/comment.ts
index 2c338ee8..ec896357 100644
--- a/packages/editor-ext/src/lib/comment/comment.ts
+++ b/packages/editor-ext/src/lib/comment/comment.ts
@@ -1,5 +1,6 @@
import { Mark, mergeAttributes } from "@tiptap/core";
import { commentDecoration } from "./comment-decoration";
+import { Plugin } from "@tiptap/pm/state";
export interface ICommentOptions {
HTMLAttributes: Record;
@@ -19,6 +20,7 @@ declare module "@tiptap/core" {
unsetCommentDecoration: () => ReturnType;
setComment: (commentId: string) => ReturnType;
unsetComment: (commentId: string) => ReturnType;
+ setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
};
}
}
@@ -53,6 +55,17 @@ export const Comment = Mark.create({
};
},
},
+ resolved: {
+ default: false,
+ parseHTML: (element) => element.hasAttribute("data-resolved"),
+ renderHTML: (attributes) => {
+ if (!attributes.resolved) return {};
+
+ return {
+ "data-resolved": "true",
+ };
+ },
+ },
};
},
@@ -60,9 +73,18 @@ export const Comment = Mark.create({
return [
{
tag: "span[data-comment-id]",
- getAttrs: (el) =>
- !!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() &&
- null,
+ getAttrs: (el) => {
+ const element = el as HTMLSpanElement;
+ const commentId = element.getAttribute("data-comment-id")?.trim();
+ const resolved = element.hasAttribute("data-resolved");
+
+ if (!commentId) return false;
+
+ return {
+ commentId,
+ resolved,
+ };
+ },
},
];
},
@@ -87,7 +109,8 @@ export const Comment = Mark.create({
(commentId) =>
({ commands }) => {
if (!commentId) return false;
- return commands.setMark(this.name, { commentId });
+ // Just add the new mark, do not remove existing ones
+ return commands.setMark(this.name, { commentId, resolved: false });
},
unsetComment:
(commentId) =>
@@ -101,7 +124,7 @@ export const Comment = Mark.create({
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
- mark.attrs.commentId === commentId,
+ mark.attrs.commentId === commentId
);
if (commentMark) {
@@ -109,6 +132,37 @@ export const Comment = Mark.create({
}
});
+ return dispatch?.(tr);
+ },
+ setCommentResolved:
+ (commentId, resolved) =>
+ ({ tr, dispatch }) => {
+ if (!commentId) return false;
+
+ tr.doc.descendants((node, pos) => {
+ const from = pos;
+ const to = pos + node.nodeSize;
+
+ const commentMark = node.marks.find(
+ (mark) =>
+ mark.type.name === this.name &&
+ mark.attrs.commentId === commentId
+ );
+
+ if (commentMark) {
+ // Remove the existing mark and add a new one with updated resolved state
+ tr = tr.removeMark(from, to, commentMark);
+ tr = tr.addMark(
+ from,
+ to,
+ this.type.create({
+ commentId: commentMark.attrs.commentId,
+ resolved: resolved,
+ })
+ );
+ }
+ });
+
return dispatch?.(tr);
},
};
@@ -116,13 +170,15 @@ export const Comment = Mark.create({
renderHTML({ HTMLAttributes }) {
const commentId = HTMLAttributes?.["data-comment-id"] || null;
+ const resolved = HTMLAttributes?.["data-resolved"] || false;
if (typeof window === "undefined" || typeof document === "undefined") {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
- class: 'comment-mark',
+ class: resolved ? "comment-mark resolved" : "comment-mark",
"data-comment-id": commentId,
+ ...(resolved && { "data-resolved": "true" }),
}),
0,
];
@@ -131,9 +187,14 @@ export const Comment = Mark.create({
const elem = document.createElement("span");
Object.entries(
- mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
).forEach(([attr, val]) => elem.setAttribute(attr, val));
+ // Add resolved class if the comment is resolved
+ if (resolved) {
+ elem.classList.add("resolved");
+ }
+
elem.addEventListener("click", (e) => {
const selection = document.getSelection();
if (selection.type === "Range") return;
@@ -141,7 +202,7 @@ export const Comment = Mark.create({
this.storage.activeCommentId = commentId;
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
bubbles: true,
- detail: { commentId },
+ detail: { commentId, resolved },
});
elem.dispatchEvent(commentEventClick);
@@ -150,9 +211,7 @@ export const Comment = Mark.create({
return elem;
},
- // @ts-ignore
addProseMirrorPlugins(): Plugin[] {
- // @ts-ignore
return [commentDecoration()];
},
});