feat(tree): add Expand all / Collapse all to the space page tree

The space sidebar tree loaded children one level at a time, so there was
no way to expand the whole tree at once - doing it client-side would mean
recursively paging /pages/sidebar-pages hundreds of times. Add a server
endpoint that returns the whole space tree in one permission-filtered
request, and two menu items in the Space menu that drive it.

Server:
- POST /pages/tree (reuses SidebarPageDto, CASL Read gate) returns
  { items: [...] } - a flat list in the same shape as /pages/sidebar-pages
  (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren,
  canEdit), sorted by position (collate 'C') then id.
- pageRepo.getSpaceDescendants(spaceId, opts): recursive CTE seeded from
  space roots (parentPageId IS NULL). getSpaceDescendantsExcludingRestricted
  is also added (space-rooted variant of getPageAnd*ExcludingRestricted)
  but not used by the read endpoint - see below.
- pageService.getSidebarPagesTree: fetches the whole space tree WITHOUT
  SQL restricted-pruning and lets filterAccessibleTreePages drop
  inaccessible pages + their subtrees. This mirrors the stepwise
  /pages/sidebar-pages behavior, so a restricted page the user HAS
  access to stays visible in expand-all (an earlier draft pruned in SQL
  and hid them - a behavioural regression). canEdit is per-page in
  restricted spaces (filterAccessiblePageIdsWithPermissions) and
  space-wide in open spaces. filterAccessibleTreePages is now public
  and accepts rootPageId=null to treat every top-level page as a root.
- hasChildren is derived in JS (a page hasChildren iff some returned row
  has it as parentPageId) - O(n), no N subqueries.

Client:
- SpaceTree is now a forwardRef exposing { expandAll, collapseAll,
  isExpanding } via useImperativeHandle. expandAll fires one
  getSpaceTree request, merges the full nested tree into treeDataAtom
  (replace-and-merge so the authoritative server tree wins over stale
  partially-loaded roots), opens every branch id of the current space,
  and guards against space switches via spaceIdRef. Errors are logged
  AND surfaced in a notification with the real reason - never a generic
  string. collapseAll clears ONLY current-space ids from the shared
  open-map (does not disturb other spaces).
- isExpanding is lifted to reactive state in SpaceSidebar and passed as
  a prop to SpaceMenu so the Expand item's disabled state actually
  updates (a ref mutation alone would not re-render).
- Two Menu.Items inside SpaceMenu (Expand all / Collapse all), gated
  only by read access (not canManage) - these are view operations.
This commit is contained in:
glm5.2 agent 180
2026-06-20 14:44:25 +03:00
parent c8af637654
commit 0c7d67fe2a
8 changed files with 482 additions and 10 deletions

View File

@@ -29,9 +29,11 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.", "Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Collapse all": "Collapse all",
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown", "Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Create": "Create", "Create": "Create",
"Create group": "Create group", "Create group": "Create group",
"Create page": "Create page", "Create page": "Create page",
@@ -68,6 +70,7 @@
"Enter your password": "Enter your password", "Enter your password": "Enter your password",
"Error fetching page data.": "Error fetching page data.", "Error fetching page data.": "Error fetching page data.",
"Error loading page history.": "Error loading page history.", "Error loading page history.": "Error loading page history.",
"Expand all": "Expand all",
"Export": "Export", "Export": "Export",
"Failed to create page": "Failed to create page", "Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page", "Failed to delete page": "Failed to delete page",

View File

@@ -69,6 +69,17 @@ export async function getSidebarPages(
return req.data; return req.data;
} }
// Fetch the whole space tree (or a single page's subtree) in one shot. Used by
// the "Expand all" command. Returns a flat list that buildTreeWithChildren turns
// into the nested tree. Permission-filtered server-side.
export async function getSpaceTree(params: {
spaceId: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
return req.data.items;
}
export async function getAllSidebarPages( export async function getAllSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> { ): Promise<InfiniteData<IPagination<IPage>, unknown>> {

View File

@@ -1,8 +1,17 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { import {
fetchAllAncestorChildren, fetchAllAncestorChildren,
useGetRootSidebarPagesQuery, useGetRootSidebarPagesQuery,
@@ -15,11 +24,16 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
import { import {
buildTree, buildTree,
buildTreeWithChildren, buildTreeWithChildren,
collectAllIds,
collectBranchIds,
mergeRootTrees, mergeRootTrees,
} from "@/features/page/tree/utils/utils.ts"; } from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts"; import {
getPageBreadcrumbs,
getSpaceTree,
} from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { DocTree } from "./doc-tree"; import { DocTree } from "./doc-tree";
@@ -28,9 +42,24 @@ import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps { interface SpaceTreeProps {
spaceId: string; spaceId: string;
readOnly: boolean; readOnly: boolean;
// Lifted-state notifier: invoked with true at the start of expandAll and
// false in its finally. Lets the parent (SpaceSidebar) re-render and feed
// a reactive `isExpanding` prop to SpaceMenu, so the Expand-all item's
// disabled state updates while the menu is open. treeRef.current?.isExpanding
// alone does NOT trigger a re-render of the menu.
onExpandingChange?: (v: boolean) => void;
} }
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
{ spaceId, readOnly, onExpandingChange },
ref,
) {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
@@ -43,6 +72,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
} = useGetRootSidebarPagesQuery({ spaceId }); } = useGetRootSidebarPagesQuery({ spaceId });
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
const [isDataLoaded, setIsDataLoaded] = useState(false); const [isDataLoaded, setIsDataLoaded] = useState(false);
const [isExpanding, setIsExpanding] = useState(false);
const spaceIdRef = useRef(spaceId); const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId; spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({ const { data: currentPage } = usePageQuery({
@@ -186,6 +216,66 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
[data, spaceId], [data, spaceId],
); );
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
onExpandingChange?.(true);
try {
// One request: the entire space tree, permission-filtered server-side.
const items: IPage[] = await getSpaceTree({ spaceId: startSpaceId });
// Space switched mid-request — drop the result, don't mutate another space.
if (spaceIdRef.current !== startSpaceId) return;
// IPage[] -> flat SpaceTreeNode[] -> nested tree.
const fullTree = buildTreeWithChildren(buildTree(items));
setData((prev) => {
// fullTree is the server-authoritative complete tree; pass it first so
// its fully-nested children win, and any current root absent from the
// response is preserved defensively. Other spaces are untouched.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
const current = prev.filter((n) => n?.spaceId === startSpaceId);
return [...others, ...mergeRootTrees(fullTree, current)];
});
// Open every branch node of the returned tree (leaves need no entry).
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err: any) {
// Never swallow: log the full error AND surface the real reason.
console.error("[tree] expandAll failed", err);
notifications.show({
color: "red",
message: t("Couldn't expand the tree: {{reason}}", {
reason: err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
setIsExpanding(false);
onExpandingChange?.(false);
}
}, [setData, setOpenTreeNodes, t, onExpandingChange]);
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
ref,
() => ({ expandAll, collapseAll, isExpanding }),
[expandAll, collapseAll, isExpanding],
);
// Stable callbacks for DocTree. Without these, every parent render recreates // Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription, // the props and tears down every row's draggable/dropTarget subscription,
// defeating memo(DocTreeRow). // defeating memo(DocTreeRow).
@@ -228,4 +318,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
)} )}
</div> </div>
); );
} });
export default SpaceTree;

View File

@@ -216,3 +216,33 @@ export function mergeRootTrees(
return sortPositionKeys(merged); return sortPositionKeys(merged);
} }
// Collect every node id in the tree (roots, branches, leaves). Used by
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
ids.push(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return ids;
}
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
// open every branch in the open-state map; leaves need no entry.
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
if (n.children?.length) {
ids.push(n.id);
walk(n.children);
}
}
};
walk(nodes);
return ids;
}

View File

@@ -6,6 +6,8 @@ import {
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconArrowsMaximize,
IconArrowsMinimize,
IconArrowDown, IconArrowDown,
IconDots, IconDots,
IconEye, IconEye,
@@ -23,14 +25,16 @@ import {
useUnwatchSpaceMutation, useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts"; } from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css"; import classes from "./space-sidebar.module.css";
import React from "react"; import React, { useRef, useState } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import clsx from "clsx"; import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import SpaceTree, {
SpaceTreeApi,
} from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
@@ -57,6 +61,11 @@ export function SpaceSidebar() {
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules); const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? ""); const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
// Lifted mirror of SpaceTree's internal isExpanding, so SpaceSidebar (and
// SpaceMenu) re-render when expand-all starts/finishes. treeRef.current
// mutations do not trigger re-renders; this state does.
const [isTreeExpanding, setIsTreeExpanding] = useState(false);
if (!space) { if (!space) {
return <></>; return <></>;
@@ -95,6 +104,8 @@ export function SpaceSidebar() {
<Group gap="xs"> <Group gap="xs">
<SpaceMenu <SpaceMenu
spaceId={space.id} spaceId={space.id}
treeRef={treeRef}
isExpanding={isTreeExpanding}
canManagePages={spaceAbility.can( canManagePages={spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
@@ -122,7 +133,9 @@ export function SpaceSidebar() {
<div className={classes.pages}> <div className={classes.pages}>
<SpaceTree <SpaceTree
ref={treeRef}
spaceId={space.id} spaceId={space.id}
onExpandingChange={setIsTreeExpanding}
readOnly={spaceAbility.cannot( readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
@@ -143,11 +156,18 @@ export function SpaceSidebar() {
interface SpaceMenuProps { interface SpaceMenuProps {
spaceId: string; spaceId: string;
treeRef: React.RefObject<SpaceTreeApi | null>;
// Reactive (lifted state), NOT treeRef.current?.isExpanding. The latter would
// never update the disabled state while the menu is open because ref mutations
// do not trigger re-renders.
isExpanding: boolean;
canManagePages: boolean; canManagePages: boolean;
onSpaceSettings: () => void; onSpaceSettings: () => void;
} }
function SpaceMenu({ function SpaceMenu({
spaceId, spaceId,
treeRef,
isExpanding,
canManagePages, canManagePages,
onSpaceSettings, onSpaceSettings,
}: SpaceMenuProps) { }: SpaceMenuProps) {
@@ -226,6 +246,21 @@ function SpaceMenu({
{isWatching ? t("Stop watching space") : t("Watch space")} {isWatching ? t("Stop watching space") : t("Watch space")}
</Menu.Item> </Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={() => treeRef.current?.expandAll()}
leftSection={<IconArrowsMaximize size={16} />}
disabled={isExpanding}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={() => treeRef.current?.collapseAll()}
leftSection={<IconArrowsMinimize size={16} />}
>
{t("Collapse all")}
</Menu.Item>
{canManagePages && ( {canManagePages && (
<> <>
<Menu.Divider /> <Menu.Divider />

View File

@@ -578,6 +578,52 @@ export class PageController {
); );
} }
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getTree(
@Body() dto: SidebarPageDto,
@AuthUser() user: User,
) {
if (!dto.spaceId && !dto.pageId) {
throw new BadRequestException(
'Either spaceId or pageId must be provided',
);
}
let spaceId = dto.spaceId;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new ForbiddenException();
}
spaceId = page.spaceId;
}
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
// Returns the whole space tree (or a single page's subtree when pageId is
// given) as a flat list of sidebar items, permission-filtered. Used by the
// client's "Expand all" command so it can build the full tree in one request
// instead of recursively paging one level at a time. Wrapped in { items }
// for forward-compatible extensions.
const items = await this.pageService.getSidebarPagesTree(
spaceId,
user.id,
spaceCanEdit,
dto.pageId,
);
return { items };
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('move-to-space') @Post('move-to-space')
async movePageToSpace( async movePageToSpace(

View File

@@ -1132,12 +1132,16 @@ export class PageService {
* 1. The user has access to it * 1. The user has access to it
* 2. Its parent is also included (or it's the root page) * 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded. * This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*
* Used by both movePageToSpace (single-root subtree) and the expand-all tree
* endpoint (whole space). When rootPageId is null, every top-level page
* (parentPageId IS NULL) is treated as a root.
*/ */
private async filterAccessibleTreePages< async filterAccessibleTreePages<
T extends { id: string; parentPageId: string | null }, T extends { id: string; parentPageId: string | null },
>( >(
pages: T[], pages: T[],
rootPageId: string, rootPageId: string | null,
userId: string, userId: string,
spaceId?: string, spaceId?: string,
): Promise<T[]> { ): Promise<T[]> {
@@ -1165,8 +1169,12 @@ export class PageService {
if (includedIds.has(page.id)) continue; if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue; if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible // Root page: include if accessible. For the whole-space tree case
if (page.id === rootPageId) { // (rootPageId === null), any top-level page counts as a root.
if (
page.id === rootPageId ||
(rootPageId === null && page.parentPageId === null)
) {
includedIds.add(page.id); includedIds.add(page.id);
changed = true; changed = true;
continue; continue;
@@ -1182,4 +1190,119 @@ export class PageService {
return pages.filter((p) => includedIds.has(p.id)); return pages.filter((p) => includedIds.has(p.id));
} }
/**
* Return the whole space tree as a flat list of sidebar items,
* permission-filtered, mirroring the two-branch logic of getSidebarPages.
* Used by the expand-all endpoint so the client can build the full tree in
* one request instead of paging one level at a time.
*
* Always fetches the whole space tree WITHOUT SQL restricted-pruning: the
* regular /pages/sidebar-pages endpoint does not prune restricted pages in
* SQL either, it filters them per-user afterwards. Pruning in SQL (as
* getSpaceDescendantsExcludingRestricted does) would hide restricted pages
* the user HAS access to — a behavioural regression vs the normal sidebar.
* filterAccessibleTreePages below drops inaccessible pages AND their
* subtrees, preserving tree integrity.
*
* pageId is accepted for API symmetry with the single-level endpoint but is
* NOT used in v1: the whole space is always returned. Subtree mode is a
* future enhancement (TODO).
*
* hasChildren is derived from the returned set (a page has children iff
* another returned page lists it as parent), so leaves simply have no
* children in the response — no per-row subquery needed.
*/
async getSidebarPagesTree(
spaceId: string,
userId: string,
spaceCanEdit: boolean,
_pageId?: string,
): Promise<
Array<{
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
hasChildren: boolean;
canEdit: boolean;
}>
> {
// Always fetch the whole space tree without SQL restricted-pruning (see
// the JSDoc above for why). filterAccessibleTreePages handles per-user
// access in the restricted branch below.
let pages = await this.pageRepo.getSpaceDescendants(spaceId, {
includeContent: false,
});
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (hasRestrictions) {
// Per-user access filter + tree-integrity pruning: a middle page being
// inaccessible excludes its whole subtree. rootPageId=null treats every
// top-level page (parentPageId IS NULL) as a root.
pages = await this.filterAccessibleTreePages(pages, null, userId, spaceId);
// Per-page canEdit on the accessible set.
const pageIds = pages.map((p) => p.id);
const accessibleWithPerms =
pageIds.length > 0
? await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
)
: [];
const permMap = new Map(
accessibleWithPerms.map((p) => [p.id, p.canEdit]),
);
pages = pages.map((p) => ({
...p,
canEdit: (permMap.get(p.id) ?? false) && spaceCanEdit,
}));
} else {
// Open space: every page is visible to space members, canEdit is the
// space-level edit ability.
pages = pages.map((p) => ({ ...p, canEdit: spaceCanEdit }));
}
if (pages.length === 0) return [];
// Derive hasChildren in JS from the returned set (O(n)): a page has
// children iff some returned page lists it as its parent. Avoids N
// withHasChildren subqueries the single-level endpoint uses.
const parentIds = new Set(
pages
.map((p) => p.parentPageId)
.filter((id): id is string => Boolean(id)),
);
const items = pages.map((p: any) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: p.canEdit,
}));
// Sort by position collate "C" asc, then id asc — matches the sidebar's
// cursor-pagination order. Raw string comparison (< / >) matches PG
// collation "C" byte order; localeCompare would NOT (it is locale-aware).
items.sort((a, b) => {
if (a.position < b.position) return -1;
if (a.position > b.position) return 1;
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
});
return items;
}
} }

View File

@@ -672,4 +672,136 @@ export class PageRepo {
.execute() .execute()
); );
} }
// Space-rooted variant of getPageAndDescendants: seeds the recursive CTE from
// the space's root pages (parentPageId IS NULL) instead of a single page, so a
// single request returns the whole space tree. Used by the expand-all endpoint.
async getSpaceDescendants(
spaceId: string,
opts: { includeContent: boolean },
) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('parentPageId', 'is', null)
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.createdAt',
'p.updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.execute();
}
// Space-rooted variant of getPageAndDescendantsExcludingRestricted: prunes
// restricted subtrees in SQL (stops traversing at restricted pages). Seeds from
// the space's root pages by default, or from a single page when pageId is given
// (single-subtree mode). Used by the expand-all endpoint for spaces that have
// page-level restrictions.
async getSpaceDescendantsExcludingRestricted(
spaceId: string,
pageId: string | null,
opts: { includeContent: boolean },
) {
return (
this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where((eb) =>
pageId
? eb.and([
eb('pages.id', '=', pageId),
eb('pages.deletedAt', 'is', null),
])
: eb.and([
eb('pages.parentPageId', 'is', null),
eb('pages.spaceId', '=', spaceId),
eb('pages.deletedAt', 'is', null),
]),
)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
.where('isRestricted', '=', false)
.execute()
);
}
} }