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

View File

@ -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"
}

View File

@ -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() {
<Route path={"/home"} element={<Home />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={

View File

@ -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>
);
}

View File

@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}

View File

@ -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: (
<Text size="sm">
{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.")}
</Text>
),
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;
}
}

View File

@ -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<IPageInput>,
@ -70,10 +77,7 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["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<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({
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<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>) {
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
@ -202,34 +334,40 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
};
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<InfiniteData<IPagination<Partial<IPage>>>>(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<InfiniteData<IPagination<Partial<IPage>>>>(
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<IPage>) {
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<IPage>) {
//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<IPage>) {
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<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;
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<InfiniteData<IPagination<IPage>>>(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<InfiniteData<IPagination<IPage>>>(
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"],
});
}
}

View File

@ -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<IPageInput>): Promise<IPage> {
return req.data;
}
export async function deletePage(pageId: string): Promise<void> {
await api.post("/pages/delete", { pageId });
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
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> {

View File

@ -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>
);
}

View File

@ -633,7 +633,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}

View File

@ -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<T>(spaceId: string) {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(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<T>(spaceId: string) {
const onDelete: DeleteHandler<T> = 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) {

View File

@ -20,6 +20,7 @@ export interface IPage {
hasChildren: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>;
}
@ -34,6 +35,12 @@ interface ILastUpdatedBy {
avatarUrl: string;
}
interface IDeletedBy {
id: string;
name: string;
avatarUrl: string;
}
export interface IMovePage {
pageId: string;
position?: string;

View File

@ -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")}
</Menu.Item>
<Menu.Item
component={Link}
to={`/s/${spaceSlug}/trash`}
leftSection={<IconTrash size={16} />}
>
{t("Trash")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

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>
);
}

View File

@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
super();
}
async process(job: Job<Space, void>): Promise<void> {
async process(job: Job<any, void>): Promise<void> {
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;
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class DeletedPageDto {
@IsOptional()
@IsString()
spaceId: string;
}

View File

@ -31,3 +31,9 @@ export class PageInfoDto extends PageIdDto {
@IsBoolean()
includeContent: boolean;
}
export class DeletePageDto extends PageIdDto {
@IsOptional()
@IsBoolean()
permanentlyDelete?: boolean;
}

View File

@ -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(

View File

@ -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]
})

View File

@ -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<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(
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<PaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
}
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);
// 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<void> {
await this.pageRepo.removePage(pageId, userId);
}
}
/*
// 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) {
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<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(
parentId: string,
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) {
await this.restoreChildrenRecursive(child.id, manager);
await manager.recover(Page, { id: child.id });
}
}
*/

View File

@ -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'}`,
);
}
}
}

View File

@ -22,6 +22,24 @@ export class PageRepo {
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> = [
'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<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) {
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<DB, 'pages'>) {
return jsonObjectFrom(
eb
@ -202,6 +355,15 @@ export class PageRepo {
).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'>) {
return jsonArrayFrom(
eb