From ec12e80423b5048dc67f144ddc6960daf3e604a4 Mon Sep 17 00:00:00 2001 From: Eddy Oyieko <67474838+mobley-trent@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:20:49 +0300 Subject: [PATCH] feat: trash for deleted pages in space (#325) * initial commit * added recycle bin modal, updated api routes * updated page service & controller, recycle bin modal * updated page-query.ts, use-tree-mutation.ts, recycled-pages.ts * removed quotes from openRestorePageModal prompt * Updated page.repo.ts * move button to space menu * fix react issues * opted to reload to enact changes in the client * lint * hide deleted pages in recents, handle restore child page * fix null check * WIP * WIP * feat: implement dedicated trash page - Replace modal-based trash view with dedicated route `/s/:spaceSlug/trash` - Add pagination support for deleted pages - Other improvements * fix translation * trash cleanup cron * cleanup --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 19 +- apps/client/src/App.tsx | 2 + .../src/components/common/user-info.tsx | 24 ++ .../components/header/page-header-menu.tsx | 2 +- .../page/hooks/use-delete-page-modal.tsx | 25 +- .../src/features/page/queries/page-query.ts | 274 ++++++++++++++---- .../features/page/services/page-service.ts | 18 +- .../components/trash-page-content-modal.tsx | 41 +++ .../page/tree/components/space-tree.tsx | 2 +- .../page/tree/hooks/use-tree-mutation.ts | 6 +- .../src/features/page/types/page.types.ts | 7 + .../components/sidebar/space-sidebar.tsx | 10 + apps/client/src/pages/space/trash.tsx | 227 +++++++++++++++ .../processors/attachment.processor.ts | 7 +- .../attachment/services/attachment.service.ts | 46 +++ .../src/core/page/dto/deleted-page.dto.ts | 7 + apps/server/src/core/page/dto/page.dto.ts | 6 + apps/server/src/core/page/page.controller.ts | 76 ++++- apps/server/src/core/page/page.module.ts | 3 +- .../src/core/page/services/page.service.ts | 180 +++++------- .../page/services/trash-cleanup.service.ts | 116 ++++++++ .../src/database/repos/page/page.repo.ts | 164 ++++++++++- 22 files changed, 1062 insertions(+), 200 deletions(-) create mode 100644 apps/client/src/components/common/user-info.tsx create mode 100644 apps/client/src/features/page/trash/components/trash-page-content-modal.tsx create mode 100644 apps/client/src/pages/space/trash.tsx create mode 100644 apps/server/src/core/page/dto/deleted-page.dto.ts create mode 100644 apps/server/src/core/page/services/trash-cleanup.service.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bc5146af..7d6c55b2 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -403,7 +403,7 @@ "Replace (Enter)": "Replace (Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", "Replace all": "Replace all", - "View all spaces": "View all spaces" + "View all spaces": "View all spaces", "Error": "Error", "Failed to disable MFA": "Failed to disable MFA", "Disable two-factor authentication": "Disable two-factor authentication", @@ -469,5 +469,20 @@ "Enter one of your backup codes": "Enter one of your backup codes", "Backup code": "Backup code", "Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.", - "Verify": "Verify" + "Verify": "Verify", + "Trash": "Trash", + "Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.", + "Deleted": "Deleted", + "No pages in trash": "No pages in trash", + "Permanently delete page?": "Permanently delete page?", + "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.", + "Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?", + "Move to trash": "Move to trash", + "Move this page to trash?": "Move this page to trash?", + "Restore page": "Restore page", + "Page moved to trash": "Page moved to trash", + "Page restored successfully": "Page restored successfully", + "Deleted by": "Deleted by", + "Deleted at": "Deleted at", + "Preview": "Preview" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index f2772bb8..cb117734 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -34,6 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin"; import SpacesPage from "@/pages/spaces/spaces.tsx"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; +import SpaceTrash from "@/pages/space/trash.tsx"; export default function App() { const { t } = useTranslation(); @@ -80,6 +81,7 @@ export default function App() { } /> } /> } /> + } /> + +
+ + {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)} + + + + + + + + + + + } + onClick={() => + openRestoreModal(page.id, page.title) + } + > + {t("Restore")} + + } + onClick={() => openDeleteModal(page.id, page.title)} + > + {t("Delete")} + + + + +
+ ))} +
+
+
+ ) : ( + + {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