+
+
+
+ {user?.name}
+
+
+ {user?.email}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 934be3af..cf61ac39 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={}
onClick={handleDeletePage}
>
- {t("Delete")}
+ {t("Move to trash")}
>
)}
diff --git a/apps/client/src/features/page/hooks/use-delete-page-modal.tsx b/apps/client/src/features/page/hooks/use-delete-page-modal.tsx
index 23f14e13..78ebc3e6 100644
--- a/apps/client/src/features/page/hooks/use-delete-page-modal.tsx
+++ b/apps/client/src/features/page/hooks/use-delete-page-modal.tsx
@@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
type UseDeleteModalProps = {
onConfirm: () => void;
+ isPermanent?: boolean;
};
export function useDeletePageModal() {
const { t } = useTranslation();
- const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
+ const openDeleteModal = ({
+ onConfirm,
+ isPermanent = false,
+ }: UseDeleteModalProps) => {
modals.openConfirmModal({
- title: t("Are you sure you want to delete this page?"),
+ title: isPermanent
+ ? t("Are you sure you want to delete this page?")
+ : t("Move this page to trash?"),
children: (
- {t(
- "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
- )}
+ {isPermanent
+ ? t(
+ "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
+ )
+ : t("Pages in trash will be permanently deleted after 30 days.")}
),
centered: true,
- labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ labels: {
+ confirm: isPermanent ? t("Delete") : t("Move to trash"),
+ cancel: t("Cancel"),
+ },
confirmProps: { color: "red" },
onConfirm,
});
};
return { openDeleteModal } as const;
-}
+}
\ No newline at end of file
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts
index 6a460c68..5668de44 100644
--- a/apps/client/src/features/page/queries/page-query.ts
+++ b/apps/client/src/features/page/queries/page-query.ts
@@ -5,8 +5,8 @@ import {
UseInfiniteQueryResult,
useMutation,
useQuery,
- useQueryClient,
UseQueryResult,
+ keepPreviousData,
} from "@tanstack/react-query";
import {
createPage,
@@ -18,6 +18,8 @@ import {
getPageBreadcrumbs,
getRecentChanges,
getAllSidebarPages,
+ getDeletedPages,
+ restorePage,
} from "@/features/page/services/page-service";
import {
IMovePage,
@@ -26,12 +28,17 @@ import {
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
-import { IPagination } from "@/lib/types.ts";
+import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
+import { useAtom } from "jotai";
+import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
+import { SimpleTree } from "react-arborist";
+import { SpaceTreeNode } from "@/features/page/tree/types";
+import { useQueryEmit } from "@/features/websocket/use-query-emit";
export function usePageQuery(
pageInput: Partial,
@@ -70,10 +77,7 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
- const pageBySlug = queryClient.getQueryData([
- "pages",
- data.slugId,
- ]);
+ const pageBySlug = queryClient.getQueryData(["pages", data.slugId]);
const pageById = queryClient.getQueryData(["pages", data.id]);
if (pageBySlug) {
@@ -87,7 +91,13 @@ export function updatePageData(data: IPage) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
- invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
+ invalidateOnUpdatePage(
+ data.spaceId,
+ data.parentPageId,
+ data.id,
+ data.title,
+ data.icon,
+ );
}
export function useUpdateTitlePageMutation() {
@@ -102,7 +112,29 @@ export function useUpdatePageMutation() {
onSuccess: (data) => {
updatePage(data);
- invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
+ invalidateOnUpdatePage(
+ data.spaceId,
+ data.parentPageId,
+ data.id,
+ data.title,
+ data.icon,
+ );
+ },
+ });
+}
+
+export function useRemovePageMutation() {
+ return useMutation({
+ mutationFn: (pageId: string) => deletePage(pageId, false),
+ onSuccess: () => {
+ notifications.show({ message: "Page moved to trash" });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["trash-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ notifications.show({ message: "Failed to delete page", color: "red" });
},
});
}
@@ -110,10 +142,16 @@ export function useUpdatePageMutation() {
export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
- mutationFn: (pageId: string) => deletePage(pageId),
+ mutationFn: (pageId: string) => deletePage(pageId, true),
onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId);
+
+ // Invalidate to refresh trash lists
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["trash-list"].includes(item.queryKey[0] as string),
+ });
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
@@ -130,7 +168,87 @@ export function useMovePageMutation() {
});
}
-export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult, unknown>> {
+export function useRestorePageMutation() {
+ const [treeData, setTreeData] = useAtom(treeDataAtom);
+ const emit = useQueryEmit();
+
+ return useMutation({
+ mutationFn: (pageId: string) => restorePage(pageId),
+ onSuccess: async (restoredPage) => {
+ notifications.show({ message: "Page restored successfully" });
+
+ // Add the restored page back to the tree
+ const treeApi = new SimpleTree(treeData);
+
+ // Check if the page already exists in the tree (it shouldn't)
+ if (!treeApi.find(restoredPage.id)) {
+ // Create the tree node data with hasChildren from backend
+ const nodeData: SpaceTreeNode = {
+ id: restoredPage.id,
+ slugId: restoredPage.slugId,
+ name: restoredPage.title || "Untitled",
+ icon: restoredPage.icon,
+ position: restoredPage.position,
+ spaceId: restoredPage.spaceId,
+ parentPageId: restoredPage.parentPageId,
+ hasChildren: restoredPage.hasChildren || false,
+ children: [],
+ };
+
+ // Determine the parent and index
+ const parentId = restoredPage.parentPageId || null;
+ let index = 0;
+
+ if (parentId) {
+ const parentNode = treeApi.find(parentId);
+ if (parentNode) {
+ index = parentNode.children?.length || 0;
+ }
+ } else {
+ // Root level page
+ index = treeApi.data.length;
+ }
+
+ // Add the node to the tree
+ treeApi.create({
+ parentId,
+ index,
+ data: nodeData,
+ });
+
+ // Update the tree data
+ setTreeData(treeApi.data);
+
+ // Emit websocket event to sync with other users
+ setTimeout(() => {
+ emit({
+ operation: "addTreeNode",
+ spaceId: restoredPage.spaceId,
+ payload: {
+ parentId,
+ index,
+ data: nodeData,
+ },
+ });
+ }, 50);
+ }
+
+ // await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
+
+ // Also invalidate deleted pages query to refresh the trash list
+ await queryClient.invalidateQueries({
+ queryKey: ["trash-list", restoredPage.spaceId],
+ });
+ },
+ onError: (error) => {
+ notifications.show({ message: "Failed to restore page", color: "red" });
+ },
+ });
+}
+
+export function useGetSidebarPagesQuery(
+ data: SidebarPagesParams | null,
+): UseInfiniteQueryResult, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
@@ -188,6 +306,20 @@ export function useRecentChangesQuery(
});
}
+export function useDeletedPagesQuery(
+ spaceId: string,
+ params?: QueryParams,
+): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ["trash-list", spaceId, params],
+ queryFn: () => getDeletedPages(spaceId, params),
+ enabled: !!spaceId,
+ placeholderData: keepPreviousData,
+ refetchOnMount: true,
+ staleTime: 0,
+ });
+}
+
export function invalidateOnCreatePage(data: Partial) {
const newPage: Partial = {
creatorId: data.creatorId,
@@ -202,34 +334,40 @@ export function invalidateOnCreatePage(data: Partial) {
};
let queryKey: QueryKey = null;
- if (data.parentPageId===null) {
- queryKey = ['root-sidebar-pages', data.spaceId];
- }else{
- queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
+ if (data.parentPageId === null) {
+ queryKey = ["root-sidebar-pages", data.spaceId];
+ } else {
+ queryKey = [
+ "sidebar-pages",
+ { pageId: data.parentPageId, spaceId: data.spaceId },
+ ];
}
//update all sidebar pages
- queryClient.setQueryData>>>(queryKey, (old) => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map((page,index) => {
- if (index === old.pages.length - 1) {
- return {
- ...page,
- items: [...page.items, newPage],
- };
- }
- return page;
- }),
- };
- });
+ queryClient.setQueryData>>>(
+ queryKey,
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page, index) => {
+ if (index === old.pages.length - 1) {
+ return {
+ ...page,
+ items: [...page.items, newPage],
+ };
+ }
+ return page;
+ }),
+ };
+ },
+ );
//update sidebar haschildren
- if (data.parentPageId!==null){
+ if (data.parentPageId !== null) {
//update sub sidebar pages haschildern
const subSideBarMatches = queryClient.getQueriesData({
- queryKey: ['sidebar-pages'],
+ queryKey: ["sidebar-pages"],
exact: false,
});
@@ -241,8 +379,10 @@ export function invalidateOnCreatePage(data: Partial) {
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
- sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
- )
+ sidebarPage.id === data.parentPageId
+ ? { ...sidebarPage, hasChildren: true }
+ : sidebarPage,
+ ),
})),
};
});
@@ -250,7 +390,7 @@ export function invalidateOnCreatePage(data: Partial) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
- queryKey: ['root-sidebar-pages', data.spaceId],
+ queryKey: ["root-sidebar-pages", data.spaceId],
exact: false,
});
@@ -262,8 +402,10 @@ export function invalidateOnCreatePage(data: Partial) {
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
- sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
- )
+ sidebarPage.id === data.parentPageId
+ ? { ...sidebarPage, hasChildren: true }
+ : sidebarPage,
+ ),
})),
};
});
@@ -276,27 +418,38 @@ export function invalidateOnCreatePage(data: Partial) {
});
}
-export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
+export function invalidateOnUpdatePage(
+ spaceId: string,
+ parentPageId: string,
+ id: string,
+ title: string,
+ icon: string,
+) {
let queryKey: QueryKey = null;
- if(parentPageId===null){
- queryKey = ['root-sidebar-pages', spaceId];
- }else{
- queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
+ if (parentPageId === null) {
+ queryKey = ["root-sidebar-pages", spaceId];
+ } else {
+ queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
}
//update all sidebar pages
- queryClient.setQueryData>>(queryKey, (old) => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map((page) => ({
- ...page,
- items: page.items.map((sidebarPage: IPage) =>
- sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
- )
- })),
- };
- });
-
+ queryClient.setQueryData>>(
+ queryKey,
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((sidebarPage: IPage) =>
+ sidebarPage.id === id
+ ? { ...sidebarPage, title: title, icon: icon }
+ : sidebarPage,
+ ),
+ })),
+ };
+ },
+ );
+
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId],
@@ -311,7 +464,7 @@ export function invalidateOnMovePage() {
});
//invalidate all sub sidebar pages
queryClient.invalidateQueries({
- queryKey: ['sidebar-pages'],
+ queryKey: ["sidebar-pages"],
});
// ---
}
@@ -320,7 +473,8 @@ export function invalidateOnDeletePage(pageId: string) {
//update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
- query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
+ query.queryKey[0] === "root-sidebar-pages" ||
+ query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key, d]) => {
@@ -330,14 +484,16 @@ export function invalidateOnDeletePage(pageId: string) {
...old,
pages: old.pages.map((page) => ({
...page,
- items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
+ items: page.items.filter(
+ (sidebarPage: IPage) => sidebarPage.id !== pageId,
+ ),
})),
};
});
});
-
+
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
-}
\ No newline at end of file
+}
diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts
index ad2be4f7..cf42ec6f 100644
--- a/apps/client/src/features/page/services/page-service.ts
+++ b/apps/client/src/features/page/services/page-service.ts
@@ -8,6 +8,7 @@ import {
IPageInput,
SidebarPagesParams,
} from '@/features/page/types/page.types';
+import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
@@ -30,8 +31,21 @@ export async function updatePage(data: Partial): Promise {
return req.data;
}
-export async function deletePage(pageId: string): Promise {
- await api.post("/pages/delete", { pageId });
+export async function deletePage(pageId: string, permanentlyDelete = false): Promise {
+ await api.post("/pages/delete", { pageId, permanentlyDelete });
+}
+
+export async function getDeletedPages(
+ spaceId: string,
+ params?: QueryParams,
+): Promise> {
+ const req = await api.post("/pages/trash", { spaceId, ...params });
+ return req.data;
+}
+
+export async function restorePage(pageId: string): Promise {
+ const response = await api.post("/pages/restore", { pageId });
+ return response.data;
}
export async function movePage(data: IMovePage): Promise {
diff --git a/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx
new file mode 100644
index 00000000..ef35b02d
--- /dev/null
+++ b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx
@@ -0,0 +1,41 @@
+import { Modal, Text, ScrollArea } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
+
+interface Props {
+ opened: boolean;
+ onClose: () => void;
+ pageTitle: string;
+ pageContent: any;
+}
+
+export default function TrashPageContentModal({
+ opened,
+ onClose,
+ pageTitle,
+ pageContent,
+}: Props) {
+ const { t } = useTranslation();
+ const title = pageTitle || t("Untitled");
+
+ return (
+
+
+
+
+
+
+ {t("Preview")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx
index dad5f1e4..e8abcae8 100644
--- a/apps/client/src/features/page/tree/components/space-tree.tsx
+++ b/apps/client/src/features/page/tree/components/space-tree.tsx
@@ -633,7 +633,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
- {t("Delete")}
+ {t("Move to trash")}
>
)}
diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
index 17537d65..b2a58f30 100644
--- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
+++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
@@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate, useParams } from "react-router-dom";
import {
useCreatePageMutation,
- useDeletePageMutation,
+ useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
@@ -28,7 +28,7 @@ export function useTreeMutation(spaceId: string) {
const tree = useMemo(() => new SimpleTree(data), [data]);
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
- const deletePageMutation = useDeletePageMutation();
+ const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
@@ -225,7 +225,7 @@ export function useTreeMutation(spaceId: string) {
const onDelete: DeleteHandler = async (args: { ids: string[] }) => {
try {
- await deletePageMutation.mutateAsync(args.ids[0]);
+ await removePageMutation.mutateAsync(args.ids[0]);
const node = tree.find(args.ids[0]);
if (!node) {
diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts
index f97c4514..5526ef5a 100644
--- a/apps/client/src/features/page/types/page.types.ts
+++ b/apps/client/src/features/page/types/page.types.ts
@@ -20,6 +20,7 @@ export interface IPage {
hasChildren: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
+ deletedBy: IDeletedBy;
space: Partial;
}
@@ -34,6 +35,12 @@ interface ILastUpdatedBy {
avatarUrl: string;
}
+interface IDeletedBy {
+ id: string;
+ name: string;
+ avatarUrl: string;
+}
+
export interface IMovePage {
pageId: string;
position?: string;
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
index 5dbd420a..f0235b3e 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
@@ -14,6 +14,7 @@ import {
IconPlus,
IconSearch,
IconSettings,
+ IconTrash,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
import React from "react";
@@ -206,6 +207,7 @@ interface SpaceMenuProps {
}
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const { t } = useTranslation();
+ const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
@@ -253,6 +255,14 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
>
{t("Space settings")}
+
+ }
+ >
+ {t("Trash")}
+
diff --git a/apps/client/src/pages/space/trash.tsx b/apps/client/src/pages/space/trash.tsx
new file mode 100644
index 00000000..53ca04cd
--- /dev/null
+++ b/apps/client/src/pages/space/trash.tsx
@@ -0,0 +1,227 @@
+import { useParams } from "react-router-dom";
+import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
+import {
+ Container,
+ Title,
+ Table,
+ Group,
+ ActionIcon,
+ Text,
+ Alert,
+ Stack,
+ Menu,
+} from "@mantine/core";
+import {
+ IconInfoCircle,
+ IconDots,
+ IconRestore,
+ IconTrash,
+ IconFileDescription,
+} from "@tabler/icons-react";
+import {
+ useDeletedPagesQuery,
+ useRestorePageMutation,
+ useDeletePageMutation,
+} from "@/features/page/queries/page-query";
+import { modals } from "@mantine/modals";
+import { useTranslation } from "react-i18next";
+import { formattedDate } from "@/lib/time";
+import { useState } from "react";
+import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
+import { UserInfo } from "@/components/common/user-info.tsx";
+import Paginate from "@/components/common/paginate.tsx";
+import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
+
+export default function SpaceTrash() {
+ const { t } = useTranslation();
+ const { spaceSlug } = useParams();
+ const { page, setPage } = usePaginateAndSearch();
+ const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
+ const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
+ page, limit: 50
+ });
+ const restorePageMutation = useRestorePageMutation();
+ const deletePageMutation = useDeletePageMutation();
+
+ const [selectedPage, setSelectedPage] = useState<{
+ title: string;
+ content: any;
+ } | null>(null);
+ const [modalOpened, setModalOpened] = useState(false);
+
+ const handleRestorePage = async (pageId: string) => {
+ await restorePageMutation.mutateAsync(pageId);
+ };
+
+ const handleDeletePage = async (pageId: string) => {
+ await deletePageMutation.mutateAsync(pageId);
+ };
+
+ const openDeleteModal = (pageId: string, pageTitle: string) => {
+ modals.openConfirmModal({
+ title: t("Are you sure you want to delete this page?"),
+ children: (
+
+ {t(
+ "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
+ { title: pageTitle || "Untitled" },
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => handleDeletePage(pageId),
+ });
+ };
+
+ const openRestoreModal = (pageId: string, pageTitle: string) => {
+ modals.openConfirmModal({
+ title: t("Restore page"),
+ children: (
+
+ {t("Restore '{{title}}' and its sub-pages?", {
+ title: pageTitle || "Untitled",
+ })}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Restore"), cancel: t("Cancel") },
+ confirmProps: { color: "blue" },
+ onConfirm: () => handleRestorePage(pageId),
+ });
+ };
+
+ const hasPages = deletedPages && deletedPages.items.length > 0;
+
+ const handlePageClick = (page: any) => {
+ setSelectedPage({ title: page.title, content: page.content });
+ setModalOpened(true);
+ };
+
+ return (
+
+
+
+ {t("Trash")}
+
+
+ } variant="light" color="red">
+
+ {t("Pages in trash will be permanently deleted after 30 days.")}
+
+
+
+ {isLoading || !deletedPages ? (
+ <>>
+ ) : hasPages ? (
+
+
+
+
+ {t("Page")}
+
+ {t("Deleted by")}
+
+
+ {t("Deleted at")}
+
+
+
+
+
+ {deletedPages.items.map((page) => (
+
+
+ handlePageClick(page)}
+ >
+ {page.icon || (
+
+
+
+ )}
+
+
+ {page.title || t("Untitled")}
+
+
+
+
+
+
+
+
+
+ {formattedDate(page.deletedAt)}
+
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ {t("No pages in trash")}
+
+ )}
+
+ {deletedPages && deletedPages.items.length > 0 && (
+
+ )}
+
+
+ {selectedPage && (
+ setModalOpened(false)}
+ pageTitle={selectedPage.title}
+ pageContent={selectedPage.content}
+ />
+ )}
+
+ );
+}
diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts
index cd4430f6..935c20a2 100644
--- a/apps/server/src/core/attachment/processors/attachment.processor.ts
+++ b/apps/server/src/core/attachment/processors/attachment.processor.ts
@@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
super();
}
- async process(job: Job): Promise {
+ async process(job: Job): Promise {
try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
@@ -20,6 +20,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
}
+ if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
+ await this.attachmentService.handleDeletePageAttachments(
+ job.data.pageId,
+ );
+ }
} catch (err) {
throw err;
}
diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts
index 2a1aae34..6472c671 100644
--- a/apps/server/src/core/attachment/services/attachment.service.ts
+++ b/apps/server/src/core/attachment/services/attachment.service.ts
@@ -321,4 +321,50 @@ export class AttachmentService {
throw err;
}
}
+
+ async handleDeletePageAttachments(pageId: string) {
+ try {
+ // Fetch attachments for this page from database
+ const attachments = await this.db
+ .selectFrom('attachments')
+ .select(['id', 'filePath'])
+ .where('pageId', '=', pageId)
+ .execute();
+
+ if (!attachments || attachments.length === 0) {
+ return;
+ }
+
+ const failedDeletions = [];
+
+ await Promise.all(
+ attachments.map(async (attachment) => {
+ try {
+ // Delete from storage
+ await this.storageService.delete(attachment.filePath);
+ // Delete from database
+ await this.attachmentRepo.deleteAttachmentById(attachment.id);
+ } catch (err) {
+ failedDeletions.push(attachment.id);
+ this.logger.error(
+ `Failed to delete attachment ${attachment.id} for page ${pageId}:`,
+ err,
+ );
+ }
+ }),
+ );
+
+ if (failedDeletions.length > 0) {
+ this.logger.warn(
+ `Failed to delete ${failedDeletions.length} attachments for page ${pageId}`,
+ );
+ }
+ } catch (err) {
+ this.logger.error(
+ `Error in handleDeletePageAttachments for page ${pageId}:`,
+ err,
+ );
+ throw err;
+ }
+ }
}
diff --git a/apps/server/src/core/page/dto/deleted-page.dto.ts b/apps/server/src/core/page/dto/deleted-page.dto.ts
new file mode 100644
index 00000000..d0264bb7
--- /dev/null
+++ b/apps/server/src/core/page/dto/deleted-page.dto.ts
@@ -0,0 +1,7 @@
+import { IsOptional, IsString } from 'class-validator';
+
+export class DeletedPageDto {
+ @IsOptional()
+ @IsString()
+ spaceId: string;
+}
\ No newline at end of file
diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts
index 1c1f7864..e897d3a5 100644
--- a/apps/server/src/core/page/dto/page.dto.ts
+++ b/apps/server/src/core/page/dto/page.dto.ts
@@ -31,3 +31,9 @@ export class PageInfoDto extends PageIdDto {
@IsBoolean()
includeContent: boolean;
}
+
+export class DeletePageDto extends PageIdDto {
+ @IsOptional()
+ @IsBoolean()
+ permanentlyDelete?: boolean;
+}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index 565ecd1e..c3d2e5c5 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -13,7 +13,12 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
-import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
+import {
+ PageHistoryIdDto,
+ PageIdDto,
+ PageInfoDto,
+ DeletePageDto,
+} from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -29,6 +34,7 @@ import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
+import { DeletedPageDto } from './dto/deleted-page.dto';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -100,7 +106,35 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('delete')
- async delete(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
+ async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
+ const page = await this.pageRepo.findById(deletePageDto.pageId);
+
+ if (!page) {
+ throw new NotFoundException('Page not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, page.spaceId);
+
+ if (deletePageDto.permanentlyDelete) {
+ // Permanent deletion requires space admin permissions
+ if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
+ throw new ForbiddenException(
+ 'Only space admins can permanently delete pages',
+ );
+ }
+ await this.pageService.forceDelete(deletePageDto.pageId);
+ } else {
+ // Soft delete requires page manage permissions
+ if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+ await this.pageService.remove(deletePageDto.pageId, user.id);
+ }
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('restore')
+ async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) {
@@ -111,13 +145,14 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
- await this.pageService.forceDelete(pageIdDto.pageId);
- }
- @HttpCode(HttpStatus.OK)
- @Post('restore')
- async restore(@Body() pageIdDto: PageIdDto) {
- // await this.pageService.restore(deletePageDto.id);
+ await this.pageRepo.restorePage(pageIdDto.pageId);
+
+ // Return the restored page data with hasChildren info
+ const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
+ includeHasChildren: true,
+ });
+ return restoredPage;
}
@HttpCode(HttpStatus.OK)
@@ -146,6 +181,31 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination);
}
+ @HttpCode(HttpStatus.OK)
+ @Post('trash')
+ async getDeletedPages(
+ @Body() deletedPageDto: DeletedPageDto,
+ @Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
+ ) {
+ if (deletedPageDto.spaceId) {
+ const ability = await this.spaceAbility.createForUser(
+ user,
+ deletedPageDto.spaceId,
+ );
+
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+
+ return this.pageService.getDeletedSpacePages(
+ deletedPageDto.spaceId,
+ pagination,
+ );
+ }
+ }
+
+ // TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts
index fd336537..42693e3d 100644
--- a/apps/server/src/core/page/page.module.ts
+++ b/apps/server/src/core/page/page.module.ts
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
+import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module';
@Module({
controllers: [PageController],
- providers: [PageService, PageHistoryService],
+ providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule]
})
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 4f96e0ca..3820bbe0 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -17,8 +17,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
-import { ExpressionBuilder } from 'kysely';
-import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@@ -37,6 +35,9 @@ import {
} from '../dto/duplicate-page.dto';
import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
+import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable()
export class PageService {
@@ -47,6 +48,7 @@ export class PageService {
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
+ @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async findById(
@@ -169,23 +171,6 @@ export class PageService {
});
}
- withHasChildren(eb: ExpressionBuilder) {
- return eb
- .selectFrom('pages as child')
- .select((eb) =>
- eb
- .case()
- .when(eb.fn.countAll(), '>', 0)
- .then(true)
- .else(false)
- .end()
- .as('count'),
- )
- .whereRef('child.parentPageId', '=', 'pages.id')
- .limit(1)
- .as('hasChildren');
- }
-
async getSidebarPages(
spaceId: string,
pagination: PaginationOptions,
@@ -202,9 +187,11 @@ export class PageService {
'parentPageId',
'spaceId',
'creatorId',
+ 'deletedAt',
])
- .select((eb) => this.withHasChildren(eb))
+ .select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', 'asc')
+ .where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId);
if (pageId) {
@@ -527,9 +514,11 @@ export class PageService {
'position',
'parentPageId',
'spaceId',
+ 'deletedAt',
])
- .select((eb) => this.withHasChildren(eb))
+ .select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
+ .where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
@@ -541,6 +530,7 @@ export class PageService {
'p.position',
'p.parentPageId',
'p.spaceId',
+ 'p.deletedAt',
])
.select(
exp
@@ -555,11 +545,13 @@ export class PageService {
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
+ .where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
- .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
+ .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
+ .where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
@@ -583,98 +575,58 @@ export class PageService {
return await this.pageRepo.getRecentPages(userId, pagination);
}
+ async getDeletedSpacePages(
+ spaceId: string,
+ pagination: PaginationOptions,
+ ): Promise> {
+ return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
+ }
+
async forceDelete(pageId: string): Promise {
- await this.pageRepo.deletePage(pageId);
+ // Get all descendant IDs (including the page itself) using recursive CTE
+ const descendants = await this.db
+ .withRecursive('page_descendants', (db) =>
+ db
+ .selectFrom('pages')
+ .select(['id'])
+ .where('id', '=', pageId)
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .select(['p.id'])
+ .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
+ ),
+ )
+ .selectFrom('page_descendants')
+ .selectAll()
+ .execute();
+
+ const pageIds = descendants.map((d) => d.id);
+
+ // Queue attachment deletion for all pages with unique job IDs to prevent duplicates
+ for (const id of pageIds) {
+ await this.attachmentQueue.add(
+ QueueJob.DELETE_PAGE_ATTACHMENTS,
+ {
+ pageId: id,
+ },
+ {
+ jobId: `delete-page-attachments-${id}`,
+ attempts: 3,
+ backoff: {
+ type: 'exponential',
+ delay: 5000,
+ },
+ },
+ );
+ }
+
+ if (pageIds.length > 0) {
+ await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
+ }
+ }
+
+ async remove(pageId: string, userId: string): Promise {
+ await this.pageRepo.removePage(pageId, userId);
}
}
-
-/*
- // TODO: page deletion and restoration
- async delete(pageId: string): Promise {
- await this.dataSource.transaction(async (manager: EntityManager) => {
- const page = await manager
- .createQueryBuilder(Page, 'page')
- .where('page.id = :pageId', { pageId })
- .select(['page.id', 'page.workspaceId'])
- .getOne();
-
- if (!page) {
- throw new NotFoundException(`Page not found`);
- }
- await this.softDeleteChildrenRecursive(page.id, manager);
- await this.pageOrderingService.removePageFromHierarchy(page, manager);
-
- await manager.softDelete(Page, pageId);
- });
- }
-
- private async softDeleteChildrenRecursive(
- parentId: string,
- manager: EntityManager,
- ): Promise {
- const childrenPage = await manager
- .createQueryBuilder(Page, 'page')
- .where('page.parentPageId = :parentId', { parentId })
- .select(['page.id', 'page.title', 'page.parentPageId'])
- .getMany();
-
- for (const child of childrenPage) {
- await this.softDeleteChildrenRecursive(child.id, manager);
- await manager.softDelete(Page, child.id);
- }
- }
-
- async restore(pageId: string): Promise {
- await this.dataSource.transaction(async (manager: EntityManager) => {
- const isDeleted = await manager
- .createQueryBuilder(Page, 'page')
- .where('page.id = :pageId', { pageId })
- .withDeleted()
- .getCount();
-
- if (!isDeleted) {
- return;
- }
-
- await manager.recover(Page, { id: pageId });
-
- await this.restoreChildrenRecursive(pageId, manager);
-
- // Fetch the page details to find out its parent and workspace
- const restoredPage = await manager
- .createQueryBuilder(Page, 'page')
- .where('page.id = :pageId', { pageId })
- .select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId'])
- .getOne();
-
- if (!restoredPage) {
- throw new NotFoundException(`Restored page not found.`);
- }
-
- // add page back to its hierarchy
- await this.pageOrderingService.addPageToOrder(
- restoredPage.spaceId,
- pageId,
- restoredPage.parentPageId,
- );
- });
- }
-
- private async restoreChildrenRecursive(
- parentId: string,
- manager: EntityManager,
- ): Promise {
- const childrenPage = await manager
- .createQueryBuilder(Page, 'page')
- .setLock('pessimistic_write')
- .where('page.parentPageId = :parentId', { parentId })
- .select(['page.id', 'page.title', 'page.parentPageId'])
- .withDeleted()
- .getMany();
-
- for (const child of childrenPage) {
- await this.restoreChildrenRecursive(child.id, manager);
- await manager.recover(Page, { id: child.id });
- }
- }
-*/
diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts
new file mode 100644
index 00000000..6d50d874
--- /dev/null
+++ b/apps/server/src/core/page/services/trash-cleanup.service.ts
@@ -0,0 +1,116 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Interval } from '@nestjs/schedule';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
+import { QueueJob, QueueName } from '../../../integrations/queue/constants';
+
+@Injectable()
+export class TrashCleanupService {
+ private readonly logger = new Logger(TrashCleanupService.name);
+ private readonly RETENTION_DAYS = 30;
+
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
+ ) {}
+
+ @Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
+ async cleanupOldTrash() {
+ try {
+ this.logger.log('Starting trash cleanup job');
+
+ const retentionDate = new Date();
+ retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
+
+ // Get all pages that were deleted more than 30 days ago
+ const oldDeletedPages = await this.db
+ .selectFrom('pages')
+ .select(['id', 'spaceId', 'workspaceId'])
+ .where('deletedAt', '<', retentionDate)
+ .execute();
+
+ if (oldDeletedPages.length === 0) {
+ this.logger.debug('No old trash items to clean up');
+ return;
+ }
+
+ this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
+
+ // Process each page
+ for (const page of oldDeletedPages) {
+ try {
+ await this.cleanupPage(page.id);
+ } catch (error) {
+ this.logger.error(
+ `Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
+ }
+
+ this.logger.debug('Trash cleanup job completed');
+ } catch (error) {
+ this.logger.error(
+ 'Trash cleanup job failed',
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
+ }
+
+ private async cleanupPage(pageId: string) {
+ // Get all descendants using recursive CTE (including the page itself)
+ const descendants = await this.db
+ .withRecursive('page_descendants', (db) =>
+ db
+ .selectFrom('pages')
+ .select(['id'])
+ .where('id', '=', pageId)
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .select(['p.id'])
+ .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
+ ),
+ )
+ .selectFrom('page_descendants')
+ .selectAll()
+ .execute();
+
+ const pageIds = descendants.map((d) => d.id);
+
+ this.logger.debug(
+ `Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
+ );
+
+ // Queue attachment deletion for all pages with unique job IDs to prevent duplicates
+ for (const id of pageIds) {
+ await this.attachmentQueue.add(
+ QueueJob.DELETE_PAGE_ATTACHMENTS,
+ {
+ pageId: id,
+ },
+ {
+ jobId: `delete-page-attachments-${id}`,
+ attempts: 3,
+ backoff: {
+ type: 'exponential',
+ delay: 5000,
+ },
+ },
+ );
+ }
+
+ try {
+ if (pageIds.length > 0) {
+ await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
+ }
+ } catch (error) {
+ // Log but don't throw - pages might have been deleted by another node
+ this.logger.warn(
+ `Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ }
+ }
+}
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 52a69707..14b40dce 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -22,6 +22,24 @@ export class PageRepo {
private spaceMemberRepo: SpaceMemberRepo,
) {}
+ withHasChildren(eb: ExpressionBuilder) {
+ return eb
+ .selectFrom('pages as child')
+ .select((eb) =>
+ eb
+ .case()
+ .when(eb.fn.countAll(), '>', 0)
+ .then(true)
+ .else(false)
+ .end()
+ .as('count'),
+ )
+ .whereRef('child.parentPageId', '=', 'pages.id')
+ .where('child.deletedAt', 'is', null)
+ .limit(1)
+ .as('hasChildren');
+ }
+
private baseFields: Array = [
'id',
'slugId',
@@ -50,6 +68,7 @@ export class PageRepo {
includeCreator?: boolean;
includeLastUpdatedBy?: boolean;
includeContributors?: boolean;
+ includeHasChildren?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
@@ -60,7 +79,10 @@ export class PageRepo {
.selectFrom('pages')
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
- .$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
+ .$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
+ .$if(opts?.includeHasChildren, (qb) =>
+ qb.select((eb) => this.withHasChildren(eb)),
+ );
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
@@ -139,12 +161,107 @@ export class PageRepo {
await query.execute();
}
+ async removePage(pageId: string, deletedById: string): Promise {
+ const currentDate = new Date();
+
+ const descendants = await this.db
+ .withRecursive('page_descendants', (db) =>
+ db
+ .selectFrom('pages')
+ .select(['id'])
+ .where('id', '=', pageId)
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .select(['p.id'])
+ .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
+ ),
+ )
+ .selectFrom('page_descendants')
+ .selectAll()
+ .execute();
+
+ const pageIds = descendants.map((d) => d.id);
+
+ await this.db
+ .updateTable('pages')
+ .set({
+ deletedById: deletedById,
+ deletedAt: currentDate,
+ })
+ .where('id', 'in', pageIds)
+ .execute();
+ }
+
+ async restorePage(pageId: string): Promise {
+ // First, check if the page being restored has a deleted parent
+ const pageToRestore = await this.db
+ .selectFrom('pages')
+ .select(['id', 'parentPageId'])
+ .where('id', '=', pageId)
+ .executeTakeFirst();
+
+ if (!pageToRestore) {
+ return;
+ }
+
+ // Check if the parent is also deleted
+ let shouldDetachFromParent = false;
+ if (pageToRestore.parentPageId) {
+ const parent = await this.db
+ .selectFrom('pages')
+ .select(['id', 'deletedAt'])
+ .where('id', '=', pageToRestore.parentPageId)
+ .executeTakeFirst();
+
+ // If parent is deleted, we should detach this page from it
+ shouldDetachFromParent = parent?.deletedAt !== null;
+ }
+
+ // Find all descendants to restore
+ const pages = await this.db
+ .withRecursive('page_descendants', (db) =>
+ db
+ .selectFrom('pages')
+ .select(['id'])
+ .where('id', '=', pageId)
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .select(['p.id'])
+ .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
+ ),
+ )
+ .selectFrom('page_descendants')
+ .selectAll()
+ .execute();
+
+ const pageIds = pages.map((p) => p.id);
+
+ // Restore all pages, but only detach the root page if its parent is deleted
+ await this.db
+ .updateTable('pages')
+ .set({ deletedById: null, deletedAt: null })
+ .where('id', 'in', pageIds)
+ .execute();
+
+ // If we need to detach the restored page from its deleted parent
+ if (shouldDetachFromParent) {
+ await this.db
+ .updateTable('pages')
+ .set({ parentPageId: null })
+ .where('id', '=', pageId)
+ .execute();
+ }
+ }
+
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId)
+ .where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, {
@@ -163,6 +280,7 @@ export class PageRepo {
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
+ .where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
@@ -175,6 +293,41 @@ export class PageRepo {
return result;
}
+ async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
+ const query = this.db
+ .selectFrom('pages')
+ .select(this.baseFields)
+ .select('content')
+ .select((eb) => this.withSpace(eb))
+ .select((eb) => this.withDeletedBy(eb))
+ .where('spaceId', '=', spaceId)
+ .where('deletedAt', 'is not', null)
+ // Only include pages that are either root pages (no parent) or whose parent is not deleted
+ // This prevents showing orphaned pages when their parent has been soft-deleted
+ .where((eb) =>
+ eb.or([
+ eb('parentPageId', 'is', null),
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom('pages as parent')
+ .select('parent.id')
+ .where('parent.id', '=', eb.ref('pages.parentPageId'))
+ .where('parent.deletedAt', 'is not', null),
+ ),
+ ),
+ ]),
+ )
+ .orderBy('deletedAt', 'desc');
+
+ const result = executeWithPagination(query, {
+ page: pagination.page,
+ perPage: pagination.limit,
+ });
+
+ return result;
+ }
+
withSpace(eb: ExpressionBuilder) {
return jsonObjectFrom(
eb
@@ -202,6 +355,15 @@ export class PageRepo {
).as('lastUpdatedBy');
}
+ withDeletedBy(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('users')
+ .select(['users.id', 'users.name', 'users.avatarUrl'])
+ .whereRef('users.id', '=', 'pages.deletedById'),
+ ).as('deletedBy');
+ }
+
withContributors(eb: ExpressionBuilder) {
return jsonArrayFrom(
eb