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>
This commit is contained in:
Eddy Oyieko
2025-07-29 23:20:49 +03:00
committed by GitHub
parent 28fcb11cb4
commit ec12e80423
22 changed files with 1062 additions and 200 deletions
@@ -403,7 +403,7 @@
"Replace (Enter)": "Replace (Enter)", "Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all", "Replace all": "Replace all",
"View all spaces": "View all spaces" "View all spaces": "View all spaces",
"Error": "Error", "Error": "Error",
"Failed to disable MFA": "Failed to disable MFA", "Failed to disable MFA": "Failed to disable MFA",
"Disable two-factor authentication": "Disable two-factor authentication", "Disable two-factor authentication": "Disable two-factor authentication",
@@ -469,5 +469,20 @@
"Enter one of your backup codes": "Enter one of your backup codes", "Enter one of your backup codes": "Enter one of your backup codes",
"Backup code": "Backup code", "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.", "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"
} }
+2
View File
@@ -34,6 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx"; import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/trash.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -80,6 +81,7 @@ export default function App() {
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
<Route path={"/spaces"} element={<SpacesPage />} /> <Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} /> <Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route <Route
path={"/s/:spaceSlug/p/:pageSlug"} path={"/s/:spaceSlug/p/:pageSlug"}
element={ element={
@@ -0,0 +1,24 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import { User } from "server/dist/database/types/entity.types";
interface UserInfoProps {
user: User;
size?: string;
}
export function UserInfo({ user, size }: UserInfoProps) {
return (
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user?.name}
</Text>
<Text fz="xs" c="dimmed">
{user?.email}
</Text>
</div>
</Group>
);
}
@@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />} leftSection={<IconTrash size={16} />}
onClick={handleDeletePage} onClick={handleDeletePage}
> >
{t("Delete")} {t("Move to trash")}
</Menu.Item> </Menu.Item>
</> </>
)} )}
@@ -4,22 +4,33 @@ import { useTranslation } from "react-i18next";
type UseDeleteModalProps = { type UseDeleteModalProps = {
onConfirm: () => void; onConfirm: () => void;
isPermanent?: boolean;
}; };
export function useDeletePageModal() { export function useDeletePageModal() {
const { t } = useTranslation(); const { t } = useTranslation();
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => { const openDeleteModal = ({
onConfirm,
isPermanent = false,
}: UseDeleteModalProps) => {
modals.openConfirmModal({ 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: ( children: (
<Text size="sm"> <Text size="sm">
{t( {isPermanent
? t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "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.")}
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") }, labels: {
confirm: isPermanent ? t("Delete") : t("Move to trash"),
cancel: t("Cancel"),
},
confirmProps: { color: "red" }, confirmProps: { color: "red" },
onConfirm, onConfirm,
}); });
@@ -5,8 +5,8 @@ import {
UseInfiniteQueryResult, UseInfiniteQueryResult,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient,
UseQueryResult, UseQueryResult,
keepPreviousData,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
createPage, createPage,
@@ -18,6 +18,8 @@ import {
getPageBreadcrumbs, getPageBreadcrumbs,
getRecentChanges, getRecentChanges,
getAllSidebarPages, getAllSidebarPages,
getDeletedPages,
restorePage,
} from "@/features/page/services/page-service"; } from "@/features/page/services/page-service";
import { import {
IMovePage, IMovePage,
@@ -26,12 +28,17 @@ import {
SidebarPagesParams, SidebarPagesParams,
} from "@/features/page/types/page.types"; } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils"; import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; 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( export function usePageQuery(
pageInput: Partial<IPageInput>, pageInput: Partial<IPageInput>,
@@ -70,10 +77,7 @@ export function useCreatePageMutation() {
} }
export function updatePageData(data: IPage) { export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>([ const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
"pages",
data.slugId,
]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]); const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) { if (pageBySlug) {
@@ -87,7 +91,13 @@ export function updatePageData(data: IPage) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); 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() { export function useUpdateTitlePageMutation() {
@@ -102,7 +112,29 @@ export function useUpdatePageMutation() {
onSuccess: (data) => { onSuccess: (data) => {
updatePage(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() { export function useDeletePageMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: (pageId: string) => deletePage(pageId), mutationFn: (pageId: string) => deletePage(pageId, true),
onSuccess: (data, pageId) => { onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") }); notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId); invalidateOnDeletePage(pageId);
// Invalidate to refresh trash lists
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
});
}, },
onError: (error) => { onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" }); notifications.show({ message: t("Failed to delete page"), color: "red" });
@@ -130,7 +168,87 @@ export function useMovePageMutation() {
}); });
} }
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, 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<SpaceTreeNode>(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<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: ["sidebar-pages", data],
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
@@ -188,6 +306,20 @@ export function useRecentChangesQuery(
}); });
} }
export function useDeletedPagesQuery(
spaceId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IPage>, 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<IPage>) { export function invalidateOnCreatePage(data: Partial<IPage>) {
const newPage: Partial<IPage> = { const newPage: Partial<IPage> = {
creatorId: data.creatorId, creatorId: data.creatorId,
@@ -203,13 +335,18 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (data.parentPageId === null) { if (data.parentPageId === null) {
queryKey = ['root-sidebar-pages', data.spaceId]; queryKey = ["root-sidebar-pages", data.spaceId];
} else { } else {
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}] queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
} }
//update all sidebar pages //update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => { queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
queryKey,
(old) => {
if (!old) return old; if (!old) return old;
return { return {
...old, ...old,
@@ -223,13 +360,14 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
return page; return page;
}), }),
}; };
}); },
);
//update sidebar haschildren //update sidebar haschildren
if (data.parentPageId !== null) { if (data.parentPageId !== null) {
//update sub sidebar pages haschildern //update sub sidebar pages haschildern
const subSideBarMatches = queryClient.getQueriesData({ const subSideBarMatches = queryClient.getQueriesData({
queryKey: ['sidebar-pages'], queryKey: ["sidebar-pages"],
exact: false, exact: false,
}); });
@@ -241,8 +379,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
pages: old.pages.map((page) => ({ pages: old.pages.map((page) => ({
...page, ...page,
items: page.items.map((sidebarPage: IPage) => 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<IPage>) {
//update root sidebar pages haschildern //update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({ const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ['root-sidebar-pages', data.spaceId], queryKey: ["root-sidebar-pages", data.spaceId],
exact: false, exact: false,
}); });
@@ -262,8 +402,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
pages: old.pages.map((page) => ({ pages: old.pages.map((page) => ({
...page, ...page,
items: page.items.map((sidebarPage: IPage) => items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage sidebarPage.id === data.parentPageId
) ? { ...sidebarPage, hasChildren: true }
: sidebarPage,
),
})), })),
}; };
}); });
@@ -276,26 +418,37 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
}); });
} }
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; let queryKey: QueryKey = null;
if (parentPageId === null) { if (parentPageId === null) {
queryKey = ['root-sidebar-pages', spaceId]; queryKey = ["root-sidebar-pages", spaceId];
} else { } else {
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}] queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
} }
//update all sidebar pages //update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => { queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
queryKey,
(old) => {
if (!old) return old; if (!old) return old;
return { return {
...old, ...old,
pages: old.pages.map((page) => ({ pages: old.pages.map((page) => ({
...page, ...page,
items: page.items.map((sidebarPage: IPage) => items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage sidebarPage.id === id
) ? { ...sidebarPage, title: title, icon: icon }
: sidebarPage,
),
})), })),
}; };
}); },
);
//update recent changes //update recent changes
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -311,7 +464,7 @@ export function invalidateOnMovePage() {
}); });
//invalidate all sub sidebar pages //invalidate all sub sidebar pages
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['sidebar-pages'], queryKey: ["sidebar-pages"],
}); });
// --- // ---
} }
@@ -320,7 +473,8 @@ export function invalidateOnDeletePage(pageId: string) {
//update all sidebar pages //update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({ const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) => 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]) => { allSideBarMatches.forEach(([key, d]) => {
@@ -330,7 +484,9 @@ export function invalidateOnDeletePage(pageId: string) {
...old, ...old,
pages: old.pages.map((page) => ({ pages: old.pages.map((page) => ({
...page, ...page,
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId), items: page.items.filter(
(sidebarPage: IPage) => sidebarPage.id !== pageId,
),
})), })),
}; };
}); });
@@ -8,6 +8,7 @@ import {
IPageInput, IPageInput,
SidebarPagesParams, SidebarPagesParams,
} from '@/features/page/types/page.types'; } from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts"; import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query"; import { InfiniteData } from "@tanstack/react-query";
@@ -30,8 +31,21 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
return req.data; return req.data;
} }
export async function deletePage(pageId: string): Promise<void> { export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
await api.post("/pages/delete", { pageId }); await api.post("/pages/delete", { pageId, permanentlyDelete });
}
export async function getDeletedPages(
spaceId: string,
params?: QueryParams,
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/trash", { spaceId, ...params });
return req.data;
}
export async function restorePage(pageId: string): Promise<IPage> {
const response = await api.post<IPage>("/pages/restore", { pageId });
return response.data;
} }
export async function movePage(data: IMovePage): Promise<void> { export async function movePage(data: IMovePage): Promise<void> {
@@ -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 (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Preview")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body p={0}>
<ScrollArea h="650" w="100%" scrollbarSize={5}>
<ReadonlyPageEditor title={title} content={pageContent} />
</ScrollArea>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -633,7 +633,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) }); openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}} }}
> >
{t("Delete")} {t("Move to trash")}
</Menu.Item> </Menu.Item>
</> </>
)} )}
@@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
useCreatePageMutation, useCreatePageMutation,
useDeletePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
@@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]); const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const deletePageMutation = useDeletePageMutation(); const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
@@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => { const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
try { try {
await deletePageMutation.mutateAsync(args.ids[0]); await removePageMutation.mutateAsync(args.ids[0]);
const node = tree.find(args.ids[0]); const node = tree.find(args.ids[0]);
if (!node) { if (!node) {
@@ -20,6 +20,7 @@ export interface IPage {
hasChildren: boolean; hasChildren: boolean;
creator: ICreator; creator: ICreator;
lastUpdatedBy: ILastUpdatedBy; lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>; space: Partial<ISpace>;
} }
@@ -34,6 +35,12 @@ interface ILastUpdatedBy {
avatarUrl: string; avatarUrl: string;
} }
interface IDeletedBy {
id: string;
name: string;
avatarUrl: string;
}
export interface IMovePage { export interface IMovePage {
pageId: string; pageId: string;
position?: string; position?: string;
@@ -14,6 +14,7 @@ import {
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import classes from "./space-sidebar.module.css"; import classes from "./space-sidebar.module.css";
import React from "react"; import React from "react";
@@ -206,6 +207,7 @@ interface SpaceMenuProps {
} }
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) { function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] = const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false); useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
@@ -253,6 +255,14 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
> >
{t("Space settings")} {t("Space settings")}
</Menu.Item> </Menu.Item>
<Menu.Item
component={Link}
to={`/s/${spaceSlug}/trash`}
leftSection={<IconTrash size={16} />}
>
{t("Trash")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
+227
View File
@@ -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: (
<Text size="sm">
{t(
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
{ title: pageTitle || "Untitled" },
)}
</Text>
),
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: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: pageTitle || "Untitled",
})}
</Text>
),
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 (
<Container size="lg" py="lg">
<Stack gap="md">
<Group justify="space-between" mb="md">
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after 30 days.")}
</Text>
</Alert>
{isLoading || !deletedPages ? (
<></>
) : hasPages ? (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted by")}
</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted at")}
</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{deletedPages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<Group
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => handlePageClick(page)}
>
{page.icon || (
<ActionIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<div>
<Text fw={500} size="sm" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<UserInfo user={page.deletedBy} size="sm" />
</Table.Td>
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.deletedAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={20} stroke={1.5} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
}
>
{t("Restore")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => openDeleteModal(page.id, page.title)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text ta="center" py="xl" c="dimmed">
{t("No pages in trash")}
</Text>
)}
{deletedPages && deletedPages.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={deletedPages.meta.hasPrevPage}
hasNextPage={deletedPages.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</Stack>
{selectedPage && (
<TrashPageContentModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
pageTitle={selectedPage.title}
pageContent={selectedPage.content}
/>
)}
</Container>
);
}
@@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
super(); super();
} }
async process(job: Job<Space, void>): Promise<void> { async process(job: Job<any, void>): Promise<void> {
try { try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) { if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id); 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) { if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id); await this.attachmentService.handleDeleteUserAvatars(job.data.id);
} }
if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
await this.attachmentService.handleDeletePageAttachments(
job.data.pageId,
);
}
} catch (err) { } catch (err) {
throw err; throw err;
} }
@@ -321,4 +321,50 @@ export class AttachmentService {
throw err; 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;
}
}
} }
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class DeletedPageDto {
@IsOptional()
@IsString()
spaceId: string;
}
@@ -31,3 +31,9 @@ export class PageInfoDto extends PageIdDto {
@IsBoolean() @IsBoolean()
includeContent: boolean; includeContent: boolean;
} }
export class DeletePageDto extends PageIdDto {
@IsOptional()
@IsBoolean()
permanentlyDelete?: boolean;
}
+68 -8
View File
@@ -13,7 +13,12 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto'; import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto'; import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-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 { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.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 { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -100,7 +106,35 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('delete') @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); const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) { if (!page) {
@@ -111,13 +145,14 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
await this.pageService.forceDelete(pageIdDto.pageId);
}
@HttpCode(HttpStatus.OK) await this.pageRepo.restorePage(pageIdDto.pageId);
@Post('restore')
async restore(@Body() pageIdDto: PageIdDto) { // Return the restored page data with hasChildren info
// await this.pageService.restore(deletePageDto.id); const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
return restoredPage;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -146,6 +181,31 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination); 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) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
+2 -1
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PageService } from './services/page.service'; import { PageService } from './services/page.service';
import { PageController } from './page.controller'; import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
@Module({ @Module({
controllers: [PageController], controllers: [PageController],
providers: [PageService, PageHistoryService], providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule] imports: [StorageModule]
}) })
@@ -17,8 +17,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto'; import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers'; import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@@ -37,6 +35,9 @@ import {
} from '../dto/duplicate-page.dto'; } from '../dto/duplicate-page.dto';
import { Node as PMNode } from '@tiptap/pm/model'; import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service'; import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable() @Injectable()
export class PageService { export class PageService {
@@ -47,6 +48,7 @@ export class PageService {
private attachmentRepo: AttachmentRepo, private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {} ) {}
async findById( async findById(
@@ -169,23 +171,6 @@ export class PageService {
}); });
} }
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
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( async getSidebarPages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -202,9 +187,11 @@ export class PageService {
'parentPageId', 'parentPageId',
'spaceId', 'spaceId',
'creatorId', 'creatorId',
'deletedAt',
]) ])
.select((eb) => this.withHasChildren(eb)) .select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', 'asc') .orderBy('position', 'asc')
.where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId); .where('spaceId', '=', spaceId);
if (pageId) { if (pageId) {
@@ -527,9 +514,11 @@ export class PageService {
'position', 'position',
'parentPageId', 'parentPageId',
'spaceId', 'spaceId',
'deletedAt',
]) ])
.select((eb) => this.withHasChildren(eb)) .select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId) .where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
@@ -541,6 +530,7 @@ export class PageService {
'p.position', 'p.position',
'p.parentPageId', 'p.parentPageId',
'p.spaceId', 'p.spaceId',
'p.deletedAt',
]) ])
.select( .select(
exp exp
@@ -555,11 +545,13 @@ export class PageService {
.as('count'), .as('count'),
) )
.whereRef('child.parentPageId', '=', 'id') .whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1) .limit(1)
.as('hasChildren'), .as('hasChildren'),
) )
//.select((eb) => this.withHasChildren(eb)) //.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') .selectFrom('page_ancestors')
@@ -583,98 +575,58 @@ export class PageService {
return await this.pageRepo.getRecentPages(userId, pagination); return await this.pageRepo.getRecentPages(userId, pagination);
} }
async getDeletedSpacePages(
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
}
async forceDelete(pageId: string): Promise<void> { async forceDelete(pageId: string): Promise<void> {
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);
// TODO: page deletion and restoration
async delete(pageId: string): Promise<void> {
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) { // Queue attachment deletion for all pages with unique job IDs to prevent duplicates
throw new NotFoundException(`Page not found`); for (const id of pageIds) {
} await this.attachmentQueue.add(
await this.softDeleteChildrenRecursive(page.id, manager); QueueJob.DELETE_PAGE_ATTACHMENTS,
await this.pageOrderingService.removePageFromHierarchy(page, manager); {
pageId: id,
await manager.softDelete(Page, pageId); },
}); {
} jobId: `delete-page-attachments-${id}`,
attempts: 3,
private async softDeleteChildrenRecursive( backoff: {
parentId: string, type: 'exponential',
manager: EntityManager, delay: 5000,
): Promise<void> { },
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<void> {
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( if (pageIds.length > 0) {
parentId: string, await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
manager: EntityManager, }
): Promise<void> { }
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) { async remove(pageId: string, userId: string): Promise<void> {
await this.restoreChildrenRecursive(child.id, manager); await this.pageRepo.removePage(pageId, userId);
await manager.recover(Page, { id: child.id });
} }
} }
*/
@@ -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'}`,
);
}
}
}
@@ -22,6 +22,24 @@ export class PageRepo {
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
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<keyof Page> = [ private baseFields: Array<keyof Page> = [
'id', 'id',
'slugId', 'slugId',
@@ -50,6 +68,7 @@ export class PageRepo {
includeCreator?: boolean; includeCreator?: boolean;
includeLastUpdatedBy?: boolean; includeLastUpdatedBy?: boolean;
includeContributors?: boolean; includeContributors?: boolean;
includeHasChildren?: boolean;
withLock?: boolean; withLock?: boolean;
trx?: KyselyTransaction; trx?: KyselyTransaction;
}, },
@@ -60,7 +79,10 @@ export class PageRepo {
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content')) .$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) { if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb)); query = query.select((eb) => this.withCreator(eb));
@@ -139,12 +161,107 @@ export class PageRepo {
await query.execute(); await query.execute();
} }
async removePage(pageId: string, deletedById: string): Promise<void> {
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<void> {
// 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) { async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withSpace(eb)) .select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc'); .orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, { const result = executeWithPagination(query, {
@@ -163,6 +280,7 @@ export class PageRepo {
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withSpace(eb)) .select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds) .where('spaceId', 'in', userSpaceIds)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc'); .orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0; const hasEmptyIds = userSpaceIds.length === 0;
@@ -175,6 +293,41 @@ export class PageRepo {
return result; 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<DB, 'pages'>) { withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom( return jsonObjectFrom(
eb eb
@@ -202,6 +355,15 @@ export class PageRepo {
).as('lastUpdatedBy'); ).as('lastUpdatedBy');
} }
withDeletedBy(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'pages.deletedById'),
).as('deletedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pages'>) { withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonArrayFrom( return jsonArrayFrom(
eb eb