mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29869478e6 |
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
|
||||
"MCP Server URL": "MCP-Server-URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
|
||||
"Enforce OAuth": "OAuth erzwingen",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.",
|
||||
"Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Unterstützte Tools",
|
||||
"MCP server URL:": "MCP-Server-URL:",
|
||||
"Learn more": "Mehr erfahren",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
|
||||
"MCP Server URL": "URL del servidor MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
|
||||
"Enforce OAuth": "Exigir OAuth",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.",
|
||||
"Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Herramientas compatibles",
|
||||
"MCP server URL:": "URL del servidor MCP:",
|
||||
"Learn more": "Más información",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
|
||||
"MCP Server URL": "URL du serveur MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
|
||||
"Enforce OAuth": "Imposer OAuth",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.",
|
||||
"Toggle enforce OAuth for MCP": "Activer ou désactiver l’imposition d’OAuth pour MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Outils pris en charge",
|
||||
"MCP server URL:": "URL du serveur MCP :",
|
||||
"Learn more": "En savoir plus",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
|
||||
"MCP Server URL": "URL del server MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
|
||||
"Enforce OAuth": "Rendi obbligatorio OAuth",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.",
|
||||
"Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Strumenti supportati",
|
||||
"MCP server URL:": "URL del server MCP:",
|
||||
"Learn more": "Scopri di più",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
|
||||
"MCP Server URL": "MCP サーバーの URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
|
||||
"Enforce OAuth": "OAuth を必須化",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。",
|
||||
"Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "サポートされているツール",
|
||||
"MCP server URL:": "MCP サーバーの URL:",
|
||||
"Learn more": "詳細を見る",
|
||||
@@ -1191,7 +1191,7 @@
|
||||
"Default value": "デフォルト値",
|
||||
"Delete property": "プロパティを削除",
|
||||
"Delete view": "ビューを削除",
|
||||
"Delete {{count}} rows?_one": "1行を削除しますか?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "降順",
|
||||
"Discard": "破棄",
|
||||
@@ -1291,9 +1291,9 @@
|
||||
"Value": "値",
|
||||
"View updated for everyone": "ビューが全員向けに更新されました",
|
||||
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
|
||||
"{{count}} rows deleted_one": "1行を削除しました",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1件を選択中",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected",
|
||||
"Compare": "比較",
|
||||
"Compare versions": "バージョンを比較",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
|
||||
"MCP Server URL": "MCP 서버 URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
|
||||
"Enforce OAuth": "OAuth 강제",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.",
|
||||
"Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "지원되는 도구",
|
||||
"MCP server URL:": "MCP 서버 URL:",
|
||||
"Learn more": "자세히 알아보기",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
|
||||
"MCP Server URL": "MCP-server-URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
|
||||
"Enforce OAuth": "OAuth afdwingen",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.",
|
||||
"Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Ondersteunde tools",
|
||||
"MCP server URL:": "MCP-server-URL:",
|
||||
"Learn more": "Meer informatie",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
|
||||
"MCP Server URL": "URL do servidor MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.",
|
||||
"Enforce OAuth": "Exigir OAuth",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.",
|
||||
"Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Ferramentas compatíveis",
|
||||
"MCP server URL:": "URL do servidor MCP:",
|
||||
"Learn more": "Saiba mais",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
|
||||
"MCP Server URL": "URL сервера MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
|
||||
"Enforce OAuth": "Сделать OAuth обязательным",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.",
|
||||
"Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Поддерживаемые инструменты",
|
||||
"MCP server URL:": "URL сервера MCP:",
|
||||
"Learn more": "Подробнее",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
|
||||
"MCP Server URL": "URL сервера MCP",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
|
||||
"Enforce OAuth": "Зробити OAuth обов’язковим",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.",
|
||||
"Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "Підтримувані інструменти",
|
||||
"MCP server URL:": "URL сервера MCP:",
|
||||
"Learn more": "Дізнатися більше",
|
||||
|
||||
@@ -708,9 +708,9 @@
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
|
||||
"MCP Server URL": "MCP 服务器 URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
|
||||
"Enforce OAuth": "强制使用 OAuth",
|
||||
"Enforce OAuth": "Enforce OAuth",
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。",
|
||||
"Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Supported tools": "支持的工具",
|
||||
"MCP server URL:": "MCP 服务器 URL:",
|
||||
"Learn more": "了解更多",
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function LicenseDetails() {
|
||||
<Table.Td>
|
||||
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
|
||||
{license.trial && <Badge color="green">Trial</Badge>}
|
||||
{license.lifetime && <Badge color="blue">Lifetime</Badge>}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -59,7 +60,14 @@ export default function LicenseDetails() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Expires at</Table.Th>
|
||||
<Table.Td>
|
||||
{formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)}
|
||||
{license.lifetime
|
||||
? "Never"
|
||||
: formatLocalized(
|
||||
license.expiresAt,
|
||||
"dd MMMM, yyyy",
|
||||
"PPP",
|
||||
locale,
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { differenceInDays, isAfter } from "date-fns";
|
||||
export const GRACE_PERIOD_DAYS = 10;
|
||||
|
||||
export function isLicenseExpired(license: ILicenseInfo): boolean {
|
||||
if (license.lifetime) return false;
|
||||
return isAfter(new Date(), license.expiresAt);
|
||||
}
|
||||
|
||||
export function daysToExpire(license: ILicenseInfo): number {
|
||||
if (license.lifetime) return Infinity;
|
||||
const days = differenceInDays(license.expiresAt, new Date());
|
||||
return days > 0 ? days : 0;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ILicenseInfo {
|
||||
seatCount: number;
|
||||
licenseType: LicenseType;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
expiresAt: Date | null;
|
||||
trial: boolean;
|
||||
lifetime: boolean;
|
||||
}
|
||||
@@ -12,19 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
|
||||
export default function GroupMembersList() {
|
||||
const { t } = useTranslation();
|
||||
const { groupId } = useParams();
|
||||
const { search, cursor, goNext, goPrev, handleSearch } =
|
||||
usePaginateAndSearch();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId, {
|
||||
cursor,
|
||||
query: search,
|
||||
});
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
@@ -54,7 +48,6 @@ export default function GroupMembersList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
@@ -66,62 +59,55 @@ export default function GroupMembersList() {
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={3} />
|
||||
)}
|
||||
{data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:integration": "jest --config test/jest-integration.json --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
|
||||
@@ -55,10 +55,6 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -133,27 +129,24 @@ export class PageService {
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.insertPage(
|
||||
{
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
createPageDto.spaceId,
|
||||
parentPageId,
|
||||
),
|
||||
icon: createPageDto.icon,
|
||||
parentPageId: parentPageId,
|
||||
spaceId: createPageDto.spaceId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
const page = await this.pageRepo.insertPage({
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
createPageDto.spaceId,
|
||||
parentPageId,
|
||||
),
|
||||
icon: createPageDto.icon,
|
||||
parentPageId: parentPageId,
|
||||
spaceId: createPageDto.spaceId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
}, trx);
|
||||
|
||||
if (trx) {
|
||||
// Add the watcher inside the caller's transaction so the async worker
|
||||
@@ -874,9 +867,6 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
sql<number>`0`.as('traversalDepth'),
|
||||
])
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
@@ -893,28 +883,13 @@ export class PageService {
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
sql<number>`pa.traversal_depth + 1`.as('traversalDepth'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pa.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'isBase',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb
|
||||
.exists(
|
||||
@@ -926,12 +901,9 @@ export class PageService {
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.orderBy('traversalDepth', 'desc')
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, childPageId);
|
||||
|
||||
return ancestors.map(stripPageTraversalMetadata);
|
||||
return ancestors.reverse();
|
||||
}
|
||||
|
||||
async getRecentSpacePages(
|
||||
@@ -1037,29 +1009,19 @@ export class PageService {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
|
||||
|
||||
@@ -5,11 +5,6 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
PageHierarchyCycleError,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@@ -47,35 +42,17 @@ export class TrashCleanupService {
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
.orderBy('id')
|
||||
.execute();
|
||||
|
||||
for (const page of oldDeletedPages) {
|
||||
let pageIds: string[];
|
||||
|
||||
try {
|
||||
const ancestors = await this.getPageAncestors(page.id);
|
||||
assertAcyclicPageTraversal(ancestors, page.id);
|
||||
|
||||
const descendants = await this.getPageDescendants(page.id);
|
||||
assertAcyclicPageTraversal(descendants, page.id);
|
||||
pageIds = descendants.map((descendant) => descendant.id);
|
||||
await this.cleanupPage(page.id);
|
||||
totalCleaned++;
|
||||
} catch (error) {
|
||||
if (error instanceof PageHierarchyCycleError) {
|
||||
this.logCleanupError(page.id, error);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
totalCleaned += await this.cleanupPage(page.id, pageIds);
|
||||
} catch (error) {
|
||||
this.logCleanupError(page.id, error);
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,66 +70,27 @@ export class TrashCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPageAncestors(pageId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'parentPageId',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.parentPageId',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('pa.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async getPageDescendants(pageId: string) {
|
||||
private async cleanupPage(pageId: string) {
|
||||
// Get all descendants using recursive CTE (including the page itself)
|
||||
return this.db
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async cleanupPage(pageId: string, pageIds: string[]) {
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
this.logger.debug(
|
||||
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
|
||||
);
|
||||
@@ -176,24 +114,14 @@ export class TrashCleanupService {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.db
|
||||
.deleteFrom('pages')
|
||||
.where('id', 'in', pageIds)
|
||||
.executeTakeFirst();
|
||||
return Number(result.numDeletedRows);
|
||||
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'}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private logCleanupError(pageId: string, error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${pageId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { validate as isValidUUID } from 'uuid';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../page/transclusion/transclusion.service';
|
||||
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
|
||||
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -145,7 +144,7 @@ export class ShareService {
|
||||
|
||||
async getShareForPage(pageId: string, workspaceId: string) {
|
||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||
const traversal = await this.db
|
||||
const share = await this.db
|
||||
.withRecursive('page_hierarchy', (cte) =>
|
||||
cte
|
||||
.selectFrom('pages')
|
||||
@@ -165,67 +164,41 @@ export class ShareService {
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
'shares.createdAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where('ph.isCycle', '=', false),
|
||||
.unionAll(
|
||||
(union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where(sql`ph.level`, '<', sql`25`), // prevent loop
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'parentPageId',
|
||||
'level',
|
||||
'shareId',
|
||||
'shareKey',
|
||||
'includeSubPages',
|
||||
'searchIndexing',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'isCycle',
|
||||
])
|
||||
.execute();
|
||||
|
||||
if (traversal.some((row) => row.isCycle)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedShare = traversal.find((row) => row.shareId !== null);
|
||||
const share = matchedShare
|
||||
? stripPageTraversalMetadata(matchedShare)
|
||||
: undefined;
|
||||
.selectAll()
|
||||
.where('shareId', 'is not', null)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
return undefined;
|
||||
@@ -255,6 +228,67 @@ export class ShareService {
|
||||
};
|
||||
}
|
||||
|
||||
async getShareAncestorPage(
|
||||
ancestorPageId: string,
|
||||
childPageId: string,
|
||||
): Promise<any> {
|
||||
let ancestor = null;
|
||||
try {
|
||||
ancestor = await this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('p.id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
// Continue recursing only when the target ancestor hasn't been found on that branch.
|
||||
.where('pa.found', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll()
|
||||
.where('found', '=', true)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
} catch (err) {
|
||||
// empty
|
||||
}
|
||||
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transclusion content for a public share viewer. Each requested
|
||||
* source page must itself be reachable via the share graph (its own share
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
PageHierarchyCycleError,
|
||||
stripPageTraversalMetadata,
|
||||
} from './page-hierarchy-cycle';
|
||||
|
||||
describe('page hierarchy cycle contract', () => {
|
||||
it('does nothing when no row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[
|
||||
{ id: 'page-1', title: 'Root', isCycle: false },
|
||||
{ id: 'page-2', title: 'Child', isCycle: false },
|
||||
],
|
||||
'page-1',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws PageHierarchyCycleError when a row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'page-1',
|
||||
),
|
||||
).toThrow(PageHierarchyCycleError);
|
||||
});
|
||||
|
||||
it('keeps the root page id on the error for safe logging', () => {
|
||||
try {
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'root-page',
|
||||
);
|
||||
throw new Error('expected a cycle error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PageHierarchyCycleError);
|
||||
expect((error as PageHierarchyCycleError).rootPageId).toBe('root-page');
|
||||
expect((error as PageHierarchyCycleError).code).toBe(
|
||||
'PAGE_HIERARCHY_CYCLE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes traversal metadata without changing the public row fields', () => {
|
||||
const row = { id: 'page-1', title: 'Root', isCycle: false };
|
||||
|
||||
expect(stripPageTraversalMetadata(row)).toEqual({
|
||||
id: 'page-1',
|
||||
title: 'Root',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
export type CycleTrackedRow = {
|
||||
isCycle: boolean;
|
||||
};
|
||||
|
||||
export class PageHierarchyCycleError extends Error {
|
||||
readonly code = 'PAGE_HIERARCHY_CYCLE';
|
||||
|
||||
constructor(readonly rootPageId: string) {
|
||||
super('Cyclic page hierarchy detected');
|
||||
this.name = 'PageHierarchyCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAcyclicPageTraversal<T extends CycleTrackedRow>(
|
||||
rows: readonly T[],
|
||||
rootPageId: string,
|
||||
): void {
|
||||
if (rows.some((row) => row.isCycle)) {
|
||||
throw new PageHierarchyCycleError(rootPageId);
|
||||
}
|
||||
}
|
||||
|
||||
export function stripPageTraversalMetadata<T extends CycleTrackedRow>(
|
||||
row: T,
|
||||
): Omit<T, 'isCycle'> {
|
||||
const { isCycle: _isCycle, ...publicRow } = row;
|
||||
return publicRow;
|
||||
}
|
||||
@@ -60,10 +60,6 @@ export class GroupUserRepo {
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
).or(
|
||||
sql`users.email`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
import { assertAcyclicPageTraversal } from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -333,7 +332,7 @@ export class PagePermissionRepo {
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const ancestors = await this.db
|
||||
return this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
@@ -341,8 +340,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -353,41 +350,19 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
'pageAccess.id as pageAccessId',
|
||||
'pageAccess.pageId',
|
||||
'pageAccess.accessLevel',
|
||||
'ancestors.depth',
|
||||
'ancestors.isCycle',
|
||||
])
|
||||
.orderBy('ancestors.depth', 'asc')
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, pageId);
|
||||
|
||||
const restrictedAncestor = ancestors.find(
|
||||
(ancestor) => ancestor.pageAccessId !== null,
|
||||
);
|
||||
if (!restrictedAncestor) return undefined;
|
||||
|
||||
return {
|
||||
pageAccessId: restrictedAncestor.pageAccessId,
|
||||
pageId: restrictedAncestor.pageId,
|
||||
accessLevel: restrictedAncestor.accessLevel,
|
||||
depth: restrictedAncestor.depth,
|
||||
};
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,30 +396,17 @@ export class PagePermissionRepo {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
hasHierarchyCycle: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
0 AS depth,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.depth + 1,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT
|
||||
(SELECT bool_or(is_cycle) FROM ancestors) AS "hasHierarchyCycle",
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
@@ -460,13 +422,6 @@ export class PagePermissionRepo {
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (row?.hasHierarchyCycle) {
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: false,
|
||||
};
|
||||
}
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
@@ -506,8 +461,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -518,14 +471,7 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -565,14 +511,6 @@ export class PagePermissionRepo {
|
||||
.else(false)
|
||||
.end()
|
||||
.as('hasInheritedRestriction'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('ancestors')
|
||||
.select('ancestors.ancestorId')
|
||||
.where('ancestors.isCycle', '=', true),
|
||||
)
|
||||
.as('hasHierarchyCycle'),
|
||||
// canAccess: no restricted ancestor without ANY permission
|
||||
eb
|
||||
.case()
|
||||
@@ -700,15 +638,13 @@ export class PagePermissionRepo {
|
||||
|
||||
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
|
||||
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
|
||||
const hasHierarchyCycle = Boolean(result?.hasHierarchyCycle);
|
||||
|
||||
return {
|
||||
hasDirectRestriction,
|
||||
hasInheritedRestriction,
|
||||
hasAnyRestriction:
|
||||
hasDirectRestriction || hasInheritedRestriction || hasHierarchyCycle,
|
||||
canAccess: !hasHierarchyCycle && Boolean(result?.canAccess),
|
||||
canEdit: !hasHierarchyCycle && Boolean(result?.canEdit),
|
||||
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
|
||||
canAccess: Boolean(result?.canAccess),
|
||||
canEdit: Boolean(result?.canEdit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -728,48 +664,7 @@ export class PagePermissionRepo {
|
||||
if (spaceId) {
|
||||
const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
|
||||
if (!hasRestrictions) {
|
||||
const cyclicPages = await this.db
|
||||
.withRecursive('allAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'allAncestors',
|
||||
'allAncestors.parentPageId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('allAncestors')
|
||||
.select('allAncestors.pageId')
|
||||
.distinct()
|
||||
.where('allAncestors.isCycle', '=', true)
|
||||
.execute();
|
||||
const cyclicPageIds = new Set(cyclicPages.map((page) => page.pageId));
|
||||
|
||||
return pageIds.filter((pageId) => !cyclicPageIds.has(pageId));
|
||||
return pageIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,8 +676,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -797,29 +690,12 @@ export class PagePermissionRepo {
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
.select('pages.id')
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -869,8 +745,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -886,14 +760,7 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`all_ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -954,16 +821,6 @@ export class PagePermissionRepo {
|
||||
.as('canEdit'),
|
||||
)
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
// view filter: no restricted ancestor without any permission
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
@@ -1008,39 +865,21 @@ export class PagePermissionRepo {
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
sql<boolean>`bool_or(ancestors.is_cycle)`.as('hasHierarchyCycle'),
|
||||
sql<boolean>`bool_or(page_access.id IS NOT NULL)`.as('hasPageAccess'),
|
||||
])
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select('pageAccess.id')
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(result?.hasHierarchyCycle || result?.hasPageAccess);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1082,8 +921,6 @@ export class PagePermissionRepo {
|
||||
'child.id as childId',
|
||||
'child.id as ancestorId',
|
||||
'child.parentPageId as ancestorParentId',
|
||||
sql<string[]>`ARRAY[child.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
@@ -1099,14 +936,7 @@ export class PagePermissionRepo {
|
||||
'childAncestors.childId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
sql<string[]>`child_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(child_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('childAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages as child')
|
||||
@@ -1114,16 +944,6 @@ export class PagePermissionRepo {
|
||||
.distinct()
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('childAncestors')
|
||||
.select('childAncestors.ancestorId')
|
||||
.whereRef('childAncestors.childId', '=', 'child.id')
|
||||
.where('childAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -1158,6 +978,67 @@ export class PagePermissionRepo {
|
||||
return results.map((r) => r.parentPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
|
||||
* Used to filter pages from public shares - if a page is restricted, it and all its
|
||||
* children should be hidden.
|
||||
*/
|
||||
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.withRecursive('descendants', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', rootPageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendants',
|
||||
'descendants.descendantId',
|
||||
'pages.parentPageId',
|
||||
)
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.withRecursive('descendantAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('descendants')
|
||||
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
|
||||
.select([
|
||||
'descendants.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
])
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendantAncestors',
|
||||
'descendantAncestors.ancestorParentId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'descendantAncestors.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('descendantAncestors')
|
||||
.innerJoin(
|
||||
'pageAccess',
|
||||
'pageAccess.pageId',
|
||||
'descendantAncestors.ancestorId',
|
||||
)
|
||||
.select('descendantAncestors.descendantId')
|
||||
.distinct()
|
||||
.execute();
|
||||
|
||||
return results.map((r) => r.descendantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pageId and a set of candidate userIds, return the subset who can
|
||||
* access the page (have permission on ALL restricted ancestors).
|
||||
@@ -1171,27 +1052,17 @@ export class PagePermissionRepo {
|
||||
|
||||
const results = await sql<{ userId: string }>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
SELECT id AS ancestor_id, parent_page_id
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
SELECT p.id, p.parent_page_id
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT cu.user_id AS "userId"
|
||||
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ancestors WHERE is_cycle)
|
||||
AND NOT EXISTS (
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
|
||||
@@ -16,10 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageRepo {
|
||||
@@ -207,31 +203,21 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pd.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
@@ -286,29 +272,19 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, pageId);
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
|
||||
// Restore all pages, but only detach the root page if its parent is deleted
|
||||
@@ -378,12 +354,7 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
) {
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
@@ -394,11 +365,7 @@ export class PageRepo {
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
|
||||
);
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
@@ -524,7 +491,7 @@ export class PageRepo {
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
const pages = await this.db
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -539,8 +506,6 @@ export class PageRepo {
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('id', '=', parentPageId)
|
||||
@@ -559,34 +524,15 @@ export class PageRepo {
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('ph.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages.map((page) => stripPageTraversalMetadata(page));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -594,82 +540,69 @@ export class PageRepo {
|
||||
* More efficient than getPageAndDescendants + filtering because:
|
||||
* 1. Single DB query (no separate restricted IDs query)
|
||||
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
|
||||
* 3. Filters the bounded traversal only after hierarchy validation
|
||||
* 3. No in-memory filtering needed
|
||||
*/
|
||||
async getPageAndDescendantsExcludingRestricted(
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
const pages = await 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'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('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'),
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$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)
|
||||
.where('ph.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isRestricted',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages
|
||||
.filter((page) => !page.isRestricted)
|
||||
.map((page) => {
|
||||
const withoutCycleMetadata = stripPageTraversalMetadata(page);
|
||||
const { isRestricted: _isRestricted, ...publicPage } =
|
||||
withoutCycleMetadata;
|
||||
return publicPage;
|
||||
});
|
||||
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('pages.id', '=', parentPageId)
|
||||
.where('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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 844f2003cd...f6344cbb12
@@ -1,18 +0,0 @@
|
||||
name: docmost-cycle-test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
environment:
|
||||
POSTGRES_DB: docmost_cycle_test
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- '127.0.0.1:55432:5432'
|
||||
tmpfs:
|
||||
- /var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U docmost -d docmost_cycle_test']
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".*\\.integration-spec\\.ts$",
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"babel-jest",
|
||||
{
|
||||
"presets": [["@babel/preset-env", { "targets": { "node": "current" } }]]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/../src/$1",
|
||||
"^@docmost/base-formula/server$": "<rootDir>/../../../packages/base-formula/src/index.server.ts",
|
||||
"^@docmost/base-formula/client$": "<rootDir>/../../../packages/base-formula/src/index.client.ts"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
import { CamelCasePlugin, Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { DbInterface } from '../../src/database/types/db.interface';
|
||||
|
||||
const disposableDatabaseName = 'docmost_cycle_test';
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('TEST_DATABASE_URL must be set for integration tests');
|
||||
}
|
||||
|
||||
export function assertDisposableTestDatabaseUrl(value: string): void {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(value);
|
||||
} catch {
|
||||
throw new Error('TEST_DATABASE_URL must be a valid PostgreSQL URL');
|
||||
}
|
||||
|
||||
const isPostgresUrl = ['postgres:', 'postgresql:'].includes(
|
||||
parsedUrl.protocol,
|
||||
);
|
||||
const isLoopback = ['127.0.0.1', 'localhost', '[::1]'].includes(
|
||||
parsedUrl.hostname,
|
||||
);
|
||||
const databaseName = decodeURIComponent(parsedUrl.pathname.slice(1));
|
||||
|
||||
if (!isPostgresUrl || !isLoopback || databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Integration tests require the loopback database ${disposableDatabaseName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertDisposableTestDatabaseUrl(databaseUrl);
|
||||
|
||||
const postgresPool = postgres(databaseUrl, { max: 1, onnotice: () => {} });
|
||||
|
||||
export const db = new Kysely<DbInterface>({
|
||||
dialect: new PostgresJSDialect({ postgres: postgresPool }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
|
||||
let databaseSafetyVerified = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await sql<{ databaseName: string }>`
|
||||
SELECT current_database() AS "databaseName"
|
||||
`.execute(db);
|
||||
|
||||
if (result.rows[0]?.databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Connected database must be the disposable ${disposableDatabaseName} database`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseSafetyVerified = true;
|
||||
});
|
||||
|
||||
export async function withStatementTimeout<T>(
|
||||
callback: (connection: Kysely<DbInterface>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return db.connection().execute(async (connection) => {
|
||||
await sql`SET statement_timeout = '500ms'`.execute(connection);
|
||||
|
||||
try {
|
||||
return await callback(connection);
|
||||
} finally {
|
||||
await sql`SET statement_timeout = DEFAULT`.execute(connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function truncateFixtureTables(): Promise<void> {
|
||||
if (!databaseSafetyVerified) {
|
||||
throw new Error('Refusing to truncate an unverified integration database');
|
||||
}
|
||||
|
||||
await sql`TRUNCATE TABLE pages, spaces, workspaces CASCADE`.execute(db);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await truncateFixtureTables();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await postgresPool.end();
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sql } from 'kysely';
|
||||
import { db } from './database';
|
||||
|
||||
export type PageFixture = {
|
||||
id: string;
|
||||
parentPageId: string | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type HierarchyContext = {
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
async function seedWorkspaceAndSpace(): Promise<HierarchyContext> {
|
||||
const workspace = await db
|
||||
.insertInto('workspaces')
|
||||
.values({
|
||||
hostname: `cycle-test-${randomUUID()}`,
|
||||
name: 'Page hierarchy integration workspace',
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const space = await db
|
||||
.insertInto('spaces')
|
||||
.values({
|
||||
name: 'Page hierarchy integration space',
|
||||
slug: `cycle-test-${randomUUID()}`,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { spaceId: space.id, workspaceId: workspace.id };
|
||||
}
|
||||
|
||||
async function insertPage(
|
||||
context: HierarchyContext,
|
||||
title: string,
|
||||
parentPageId: string | null = null,
|
||||
): Promise<PageFixture> {
|
||||
const page = await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
parentPageId,
|
||||
slugId: randomUUID(),
|
||||
spaceId: context.spaceId,
|
||||
title,
|
||||
workspaceId: context.workspaceId,
|
||||
})
|
||||
.returning(['id', 'parentPageId', 'title'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { id: page.id, parentPageId: page.parentPageId, title };
|
||||
}
|
||||
|
||||
export async function seedAcyclicPageChain(): Promise<{
|
||||
root: PageFixture;
|
||||
child: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const child = await insertPage(context, 'Child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', child.id);
|
||||
|
||||
return { root, child, grandchild };
|
||||
}
|
||||
|
||||
export async function seedSelfCycle(): Promise<{ self: PageFixture }> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const self = await insertPage(context, 'Self');
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${self.id} WHERE id = ${self.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return { self: { ...self, parentPageId: self.id } };
|
||||
}
|
||||
|
||||
export async function seedTwoPageCycle(): Promise<{
|
||||
a: PageFixture;
|
||||
b: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const a = await insertPage(context, 'A');
|
||||
const b = await insertPage(context, 'B', a.id);
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${b.id} WHERE id = ${a.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return {
|
||||
a: { ...a, parentPageId: b.id },
|
||||
b,
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedBranchingDescendantTree(): Promise<{
|
||||
root: PageFixture;
|
||||
firstChild: PageFixture;
|
||||
secondChild: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const firstChild = await insertPage(context, 'First child', root.id);
|
||||
const secondChild = await insertPage(context, 'Second child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', firstChild.id);
|
||||
|
||||
return { root, firstChild, secondChild, grandchild };
|
||||
}
|
||||
Reference in New Issue
Block a user