Преглед изворни кода

Merge branch 'master' into fix/req-params

jam411 пре 3 година
родитељ
комит
766a47012a
43 измењених фајлова са 1411 додато и 551 уклоњено
  1. 15 1
      packages/app/public/static/locales/en_US/commons.json
  2. 0 15
      packages/app/public/static/locales/en_US/translation.json
  3. 15 1
      packages/app/public/static/locales/ja_JP/commons.json
  4. 0 15
      packages/app/public/static/locales/ja_JP/translation.json
  5. 15 1
      packages/app/public/static/locales/zh_CN/commons.json
  6. 0 15
      packages/app/public/static/locales/zh_CN/translation.json
  7. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  8. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  9. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  10. 1 1
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  11. 1 2
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  12. 34 13
      packages/app/src/components/PageEditor.tsx
  13. 24 18
      packages/app/src/components/PageEditorByHackmd.tsx
  14. 6 11
      packages/app/src/components/SavePageControls.tsx
  15. 2 2
      packages/app/src/components/UnsavedAlertDialog.tsx
  16. 2 0
      packages/app/src/interfaces/page-operation.ts
  17. 2 2
      packages/app/src/pages/me/[[...path]].page.tsx
  18. 18 1
      packages/app/src/server/models/interfaces/page-operation.ts
  19. 14 117
      packages/app/src/server/models/obsolete-page.js
  20. 7 0
      packages/app/src/server/models/page-operation.ts
  21. 5 165
      packages/app/src/server/models/page.ts
  22. 9 0
      packages/app/src/server/models/user-group-relation.js
  23. 1 0
      packages/app/src/server/models/user-group.ts
  24. 1 1
      packages/app/src/server/routes/apiv3/page.js
  25. 1 6
      packages/app/src/server/routes/apiv3/pages.js
  26. 3 13
      packages/app/src/server/routes/page.js
  27. 1 1
      packages/app/src/server/routes/tag.js
  28. 1 1
      packages/app/src/server/service/installer.ts
  29. 187 16
      packages/app/src/server/service/page-grant.ts
  30. 2 1
      packages/app/src/server/service/page-operation.ts
  31. 372 14
      packages/app/src/server/service/page.ts
  32. 2 2
      packages/app/src/stores/context.tsx
  33. 2 2
      packages/app/src/stores/editor.tsx
  34. 12 3
      packages/app/src/stores/page.tsx
  35. 1 2
      packages/app/src/stores/ui.tsx
  36. 16 3
      packages/app/src/stores/use-context-swr.tsx
  37. 11 10
      packages/app/src/stores/use-static-swr.tsx
  38. 1 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  39. 554 68
      packages/app/test/integration/models/v5.page.test.js
  40. 24 19
      packages/app/test/integration/service/page-grant.test.js
  41. 16 2
      packages/app/test/integration/service/v5.non-public-page.test.ts
  42. 17 3
      packages/app/test/integration/service/v5.public-page.test.ts
  43. 13 1
      packages/core/src/interfaces/page.ts

+ 15 - 1
packages/app/public/static/locales/en_US/commons.json

@@ -3,6 +3,7 @@
   "Hide": "Hide",
   "Hide": "Hide",
   "Add": "Add",
   "Add": "Add",
   "Reset": "Reset",
   "Reset": "Reset",
+  "Sign out": "Logout",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
@@ -42,9 +43,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "In-App Notification List",
+    "see_all": "See All",
+    "no_notification": "You don't have any notificatios.",
+    "all": "All",
+    "unopend": "Unread",
+    "mark_all_as_read": "Mark all as read"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
-    "settings": "Settings"
+    "settings": "Settings",
+    "color_mode": "Color mode",
+    "sidebar_mode": "Sidebar mode",
+    "sidebar_mode_editor": "Sidebar mode on editor",
+    "use_os_settings": "Use OS settings"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/en_US/translation.json

@@ -138,7 +138,6 @@
   "edited this page": "edited this page.",
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
-  "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "add_bookmark": "Add to Bookmarks",
@@ -155,12 +154,6 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
   "Successfully requested": "Successfully requested.",
-  "personal_dropdown": {
-    "color_mode": "Color mode",
-    "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
@@ -243,14 +236,6 @@
   "API Token Settings": "API token settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
   "Update API Token": "Update API token",
-  "in_app_notification": {
-    "notification_list": "In-App Notification List",
-    "see_all": "See All",
-    "no_notification": "You don't have any notificatios.",
-    "all": "All",
-    "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 15 - 1
packages/app/public/static/locales/ja_JP/commons.json

@@ -3,6 +3,7 @@
   "Hide": "非公開",
   "Hide": "非公開",
   "Add": "追加",
   "Add": "追加",
   "Reset": "リセット",
   "Reset": "リセット",
+  "Sign out": "ログアウト",
 
 
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
@@ -42,9 +43,22 @@
     "description": "概要"
     "description": "概要"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "アプリ内通知一覧",
+    "see_all": "通知一覧を見る",
+    "no_notification": "通知はありません",
+    "all": "全て",
+    "unopend": "未読",
+    "mark_all_as_read": "全て既読にする"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
-    "settings": "設定"
+    "settings": "設定",
+    "color_mode": "カラーモード",
+    "sidebar_mode": "サイドバーモード",
+    "sidebar_mode_editor": "サイドバーモード(編集時)",
+    "use_os_settings": "OS設定を利用する"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/ja_JP/translation.json

@@ -133,7 +133,6 @@
   "edited this page": "さんがこのページを編集しました。",
   "edited this page": "さんがこのページを編集しました。",
   "List Drafts": "下書き一覧",
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
-  "Sign out": "ログアウト",
   "Disassociate": "連携解除",
   "Disassociate": "連携解除",
   "Color mode": "カラーモード",
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
@@ -153,12 +152,6 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
   "Successfully requested": "正常に処理を受け付けました",
-  "personal_dropdown": {
-    "color_mode": "カラーモード",
-    "sidebar_mode": "サイドバーモード",
-    "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
@@ -241,14 +234,6 @@
   "API Token Settings": "API Token設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
   "Update API Token": "API Tokenを更新",
-  "in_app_notification": {
-    "notification_list": "アプリ内通知一覧",
-    "see_all": "通知一覧を見る",
-    "no_notification": "通知はありません",
-    "all": "全て",
-    "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 15 - 1
packages/app/public/static/locales/zh_CN/commons.json

@@ -3,6 +3,7 @@
 	"Hide": "隐藏",
 	"Hide": "隐藏",
   "Add": "添加",
   "Add": "添加",
   "Reset": "重启",
   "Reset": "重启",
+	"Sign out": "退出",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
@@ -42,9 +43,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "应用内通知列表",
+    "see_all": "查看通知列表",
+    "no_notification": "您没有任何通知",
+    "all": "全部",
+    "unopend": "未读",
+    "mark_all_as_read" : "标记为已读"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "家",
     "home": "家",
-    "settings": "设置"
+    "settings": "设置",
+		"color_mode": "颜色模式",
+		"sidebar_mode": "边栏模式",
+		"sidebar_mode_editor": "编辑器上的边栏模式",
+		"use_os_settings": "使用操作系统设置"
   },
   },
 
 
 	"copy_to_clipboard": {
 	"copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/zh_CN/translation.json

@@ -145,7 +145,6 @@
 	"edited this page": "edited this page.",
 	"edited this page": "edited this page.",
 	"List Drafts": "草稿",
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Deleted Pages": "已删除页",
-	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "add_bookmark": "添加到书签",
@@ -228,14 +227,6 @@
 	"API Token Settings": "API token 设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
 	"Update API Token": "更新 API token",
-  "in_app_notification": {
-    "notification_list": "应用内通知列表",
-    "see_all": "查看通知列表",
-    "no_notification": "您没有任何通知",
-    "all": "全部",
-    "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
@@ -566,12 +557,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value",
     "Invalid_Number_of_Date" : "You entered invalid value",
     "link_sharing_is_disabled": "链接共享已被禁用"
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
   },
-	"personal_dropdown": {
-		"color_mode": "颜色模式",
-		"sidebar_mode": "边栏模式",
-		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
-	},
 	"search_result": {
 	"search_result": {
 		"result_meta": "搜索结果:",
 		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"deletion_mode_btn_lavel": "选择并删除页面",

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationDropdown');
 
 
 
 
 export const InAppNotificationDropdown = (): JSX.Element => {
 export const InAppNotificationDropdown = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const limit = 6;
   const limit = 6;

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -97,7 +97,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       break;
       break;
     case 'PAGE_UPDATE':
     case 'PAGE_UPDATE':
       actionMsg = 'updated on';
       actionMsg = 'updated on';
-      actionIcon = 'ti-agenda';
+      actionIcon = 'ti ti-agenda';
       break;
       break;
     case 'PAGE_RENAME':
     case 'PAGE_RENAME':
       actionMsg = 'renamed';
       actionMsg = 'renamed';

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationPage');
 
 
 
 
 export const InAppNotificationPage: FC = () => {
 export const InAppNotificationPage: FC = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { mutate } = useSWRxInAppNotificationStatus();
   const { mutate } = useSWRxInAppNotificationStatus();
 
 
   const { data: showPageLimitationXL } = useShowPageLimitationXL();
   const { data: showPageLimitationXL } = useShowPageLimitationXL();

+ 1 - 1
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -20,7 +20,7 @@ type AppearanceModeDropdownProps = {
 }
 }
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const { isAuthenticated } = props;
   const { isAuthenticated } = props;
 
 

+ 1 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,8 +15,7 @@ import {
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentPageId, useCurrentPathname,
-  useIsNotFound,
+  useCurrentPageId, useCurrentPathname, useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';

+ 34 - 13
packages/app/src/components/PageEditor.tsx

@@ -4,9 +4,10 @@ import React, {
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import { envUtils, PageGrant } from '@growi/core';
+import { envUtils, IPageHasId, PageGrant } from '@growi/core';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { saveOrUpdate } from '~/client/services/page-operation';
@@ -16,7 +17,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
 import {
   useCurrentPathname, useCurrentPageId,
   useCurrentPathname, useCurrentPageId,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,7 +52,10 @@ let isOriginOfScrollSyncPreview = false;
 const PageEditor = React.memo((): JSX.Element => {
 const PageEditor = React.memo((): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pageId } = useCurrentPageId();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -66,7 +70,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
@@ -112,7 +116,7 @@ const PageEditor = React.memo((): JSX.Element => {
   }, [setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
 
   // return true if the save succeeds, otherwise false.
   // return true if the save succeeds, otherwise false.
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<boolean> => {
+  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
@@ -127,10 +131,13 @@ const PageEditor = React.memo((): JSX.Element => {
     );
     );
 
 
     try {
     try {
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId }, markdownToSave.current);
-      await mutateCurrentPage();
-      mutateIsEnabledUnsavedWarning(false);
-      return true;
+      const { page } = await saveOrUpdate(
+        optionsToSave,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        markdownToSave.current,
+      );
+
+      return page;
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
@@ -143,20 +150,34 @@ const PageEditor = React.memo((): JSX.Element => {
         //   lastUpdateUser: error.data.user,
         //   lastUpdateUser: error.data.user,
         // });
         // });
       }
       }
-      return false;
+      return null;
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId, mutateCurrentPage, mutateIsEnabledUnsavedWarning]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
       return;
       return;
     }
     }
 
 
-    await save(opts);
+    const page = await save(opts);
+    if (page == null) {
+      return;
+    }
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+    if (isNotFound) {
+      await router.push(`/${page._id}`);
+    }
+    else {
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
+    }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateEditorMode]);
+  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {

+ 24 - 18
packages/app/src/components/PageEditorByHackmd.tsx

@@ -13,7 +13,7 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
 import {
-  useCurrentPageId, useCurrentPathname, useHackmdUri,
+  useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -29,6 +29,7 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+import { useRouter } from 'next/router';
 
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 
@@ -41,12 +42,15 @@ type HackEditorRef = {
 export const PageEditorByHackmd = (): JSX.Element => {
 export const PageEditorByHackmd = (): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId } = useCurrentPageId();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
@@ -100,30 +104,32 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
 
       const markdown = await hackmdEditorRef.current.getValue();
       const markdown = await hackmdEditorRef.current.getValue();
 
 
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
       await mutatePageData();
       await mutatePageData();
       await mutateTagsInfo();
       await mutateTagsInfo();
+
+      if (page == null) {
+        return;
+      }
+      // The updateFn should be a promise or asynchronous function to handle the remote mutation
+      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+      // Moreover, `async() => false` does not work since it's too fast to be calculated.
+      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+      if (isNotFound) {
+        await router.push(`/${page._id}`);
+      }
+      else {
+        await mutateCurrentPageId(page._id);
+        await mutatePageData();
+      }
       mutateEditorMode(EditorMode.View);
       mutateEditorMode(EditorMode.View);
-      mutateIsEnabledUnsavedWarning(false);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [editorMode,
-      isSlackEnabled,
-      currentPathname,
-      slackChannels,
-      grant,
-      revision,
-      pageTags,
-      pageId,
-      currentPagePath,
-      mutatePageData,
-      mutateEditorMode,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
+      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {

+ 6 - 11
packages/app/src/components/SavePageControls.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import EventEmitter from 'events';
+
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
@@ -7,30 +9,23 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-// import PageContainer from '~/client/services/PageContainer';
-import { CustomWindow } from '~/interfaces/global';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useCurrentPageId, useIsAclEnabled,
   useIsEditable, useCurrentPageId, useIsAclEnabled,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
-// import { withUnstatedContainers } from './UnstatedUtils';
+declare const globalEmitter: EventEmitter;
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
-type Props = {
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-}
-
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
-export const SavePageControls = (props: Props): JSX.Element | null => {
+export const SavePageControls = (): JSX.Element | null => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
@@ -45,12 +40,12 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
+    globalEmitter.emit('saveAndReturnToView');
   }, []);
   }, []);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
   }, []);
   }, []);
 
 
 
 

+ 2 - 2
packages/app/src/components/UnsavedAlertDialog.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect, memo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -56,4 +56,4 @@ const UnsavedAlertDialog = (): JSX.Element => {
   return <></>;
   return <></>;
 };
 };
 
 
-export default UnsavedAlertDialog;
+export default memo(UnsavedAlertDialog);

+ 2 - 0
packages/app/src/interfaces/page-operation.ts

@@ -1,4 +1,6 @@
 export const PageActionType = {
 export const PageActionType = {
+  Create: 'Create',
+  Update: 'Update',
   Rename: 'Rename',
   Rename: 'Rename',
   Duplicate: 'Duplicate',
   Duplicate: 'Duplicate',
   Delete: 'Delete',
   Delete: 'Delete',

+ 2 - 2
packages/app/src/pages/me/[[...path]].page.tsx

@@ -53,7 +53,7 @@ const InAppNotificationPage = dynamic(
 
 
 const MePage: NextPage<Props> = (props: Props) => {
 const MePage: NextPage<Props> = (props: Props) => {
   const router = useRouter();
   const router = useRouter();
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
   const { path } = router.query;
   const { path } = router.query;
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
 
 
@@ -68,7 +68,7 @@ const MePage: NextPage<Props> = (props: Props) => {
       //   component: <MyDraftList />,
       //   component: <MyDraftList />,
       // },
       // },
       'all-in-app-notifications': {
       'all-in-app-notifications': {
-        title: t('in_app_notification.notification_list'),
+        title: t('commons:in_app_notification.notification_list'),
         component: <InAppNotificationPage />,
         component: <InAppNotificationPage />,
       },
       },
     };
     };

+ 18 - 1
packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import { PageGrant } from '~/interfaces/page';
+
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 
 export type IPageForResuming = {
 export type IPageForResuming = {
@@ -19,8 +21,23 @@ export type IUserForResuming = {
   _id: ObjectIdLike,
   _id: ObjectIdLike,
 };
 };
 
 
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  grantUserGroupId?: ObjectIdLike,
+  isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  format?: string,
+  grantUserGroupId?: ObjectIdLike,
+  grant?: PageGrant,
+  overwriteScopesOfDescendants?: boolean,
+  isSynchronously?: boolean,
+};
+
 export type IOptionsForResuming = {
 export type IOptionsForResuming = {
   updateMetadata?: boolean,
   updateMetadata?: boolean,
   createRedirectPage?: boolean,
   createRedirectPage?: boolean,
   prevDescendantCount?: number,
   prevDescendantCount?: number,
-};
+} & IOptionsForUpdate & IOptionsForCreate;

+ 14 - 117
packages/app/src/server/models/obsolete-page.js

@@ -1,5 +1,6 @@
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 
 
+import { PageGrant } from '~/interfaces/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -647,132 +648,28 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
     return { templateBody, templateTags };
   };
   };
 
 
-  async function pushRevision(pageData, newRevision, user) {
-    await newRevision.save();
-    debug('Successfully saved new revision', newRevision);
-
-    pageData.revision = newRevision;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
-
-    return pageData.save();
-  }
-
-  async function validateAppliedScope(user, grant, grantUserGroupId) {
-    if (grant === GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
-
-    if (grant === GRANT_USER_GROUP) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
-
-      if (count === 0) {
-        throw new Error('no relations were exist for group and user.');
-      }
-    }
-  }
-
-  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
-    /*
-     * v4 compatible process
-     */
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const grantUserGroupId = options.grantUserGroupId || null;
-    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-
-    let grant = options.grant;
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    const isExist = await this.count({ path });
-
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
+  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
+    const builder = new this.PageQueryBuilder(this.find());
+    builder.addConditionToListOnlyDescendants(parentPage.path);
 
 
-    const page = new Page();
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-    if (expandContentWidth != null) {
-      page.expandContentWidth = expandContentWidth;
+    if (isV4) {
+      builder.addConditionAsRootOrNotOnTree();
     }
     }
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
-    validateCrowi();
-
-    const Revision = crowi.model('Revision');
-    const grant = options.grant || pageData.grant; //                                  use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    pageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await pageData.save();
-
-    // Update revision
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
+    else {
+      builder.addConditionAsOnTree();
     }
     }
 
 
-
-    pageEvent.emit('update', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListWithDescendants(parentPage.path);
-
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
     await addConditionToFilteringByViewerToEdit(builder, user);
 
 
-    // get all pages that the specified user can update
-    const pages = await builder.query.exec();
+    const grant = parentPage.grant;
 
 
-    for (const page of pages) {
-      // skip parentPage
-      if (page.id === parentPage.id) {
-        continue;
-      }
+    await builder.query.updateMany({}, {
+      grant,
+      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
+    });
 
 
-      page.applyScope(user, parentPage.grant, parentPage.grantedGroup);
-      page.save();
-    }
   };
   };
 
 
   pageSchema.statics.removeByPath = function(path) {
   pageSchema.statics.removeByPath = function(path) {

+ 7 - 0
packages/app/src/server/models/page-operation.ts

@@ -33,6 +33,7 @@ export interface IPageOperation {
   options?: IOptionsForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
   incForUpdatingDescendantCount?: number,
   unprocessableExpiryDate: Date,
   unprocessableExpiryDate: Date,
+  exPage?: IPageForResuming,
 
 
   isProcessable(): boolean
   isProcessable(): boolean
 }
 }
@@ -71,6 +72,11 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   createRedirectPage: { type: Boolean },
   createRedirectPage: { type: Boolean },
   updateMetadata: { type: Boolean },
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
   prevDescendantCount: { type: Number },
+  grant: { type: Number },
+  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  format: { type: String },
+  isSyncRevisionToHackmd: { type: Boolean },
+  overwriteScopesOfDescendants: { type: Boolean },
 }, { _id: false });
 }, { _id: false });
 
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
@@ -89,6 +95,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   fromPath: { type: String, required: true, index: true },
   fromPath: { type: String, required: true, index: true },
   toPath: { type: String, index: true },
   toPath: { type: String, index: true },
   page: { type: pageSchemaForResuming, required: true },
   page: { type: pageSchemaForResuming, required: true },
+  exPage: { type: pageSchemaForResuming, required: false },
   user: { type: userSchemaForResuming, required: true },
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
   incForUpdatingDescendantCount: { type: Number },

+ 5 - 165
packages/app/src/server/models/page.ts

@@ -340,7 +340,7 @@ export class PageQueryBuilder {
           { grant: { $ne: GRANT_SPECIFIED } },
           { grant: { $ne: GRANT_SPECIFIED } },
         ],
         ],
       });
       });
-    this.addConditionAsNotMigrated();
+    this.addConditionAsRootOrNotOnTree();
     this.addConditionAsNonRootPage();
     this.addConditionAsNonRootPage();
     this.addConditionToExcludeTrashed();
     this.addConditionToExcludeTrashed();
     await this.addConditionForParentNormalization(user);
     await this.addConditionForParentNormalization(user);
@@ -384,7 +384,7 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionAsNotMigrated(): PageQueryBuilder {
+  addConditionAsRootOrNotOnTree(): PageQueryBuilder {
     this.query = this.query
     this.query = this.query
       .and({ parent: null });
       .and({ parent: null });
 
 
@@ -956,177 +956,17 @@ export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike
   grant?: number
   grant?: number
+  overwriteScopesOfDescendants?: boolean
 }
 }
 
 
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */
-// remove type for crowi to prevent 'import/no-cycle'
-// eslint-disable-next-line import/no-anonymous-default-export
-export default (crowi): any => {
-  let pageEvent;
-  if (crowi != null) {
-    pageEvent = crowi.event('page');
-  }
-
-  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
-    const isRestricted = grant === GRANT_RESTRICTED;
-    return !isRestricted && (!isV5Compatible || !isOnTree);
-  };
-
-  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
-    pageEvent.emit('update', page, user);
-  };
-
-  /**
-   * A wrapper method of schema.statics.updatePage for updating grant only.
-   * @param {PageDocument} page
-   * @param {UserDocument} user
-   * @param options
-   */
-  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
-    const { grant, grantedGroup } = grantData;
-
-    const options = {
-      grant,
-      grantUserGroupId: grantedGroup,
-      isSyncRevisionToHackmd: false,
-    };
-
-    return this.updatePage(page, null, null, user, options);
-  };
-
-  schema.statics.updatePage = async function(
-      pageData,
-      body: string | null,
-      previousBody: string | null,
-      user,
-      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
-  ) {
-    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
-      throw Error('Crowi is not set up');
-    }
-
-    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
-    const exParent = pageData.parent;
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
-    if (shouldUseV4Process) {
-      // v4 compatible process
-      return this.updatePageV4(pageData, body, previousBody, user, options);
-    }
-
-    const grant = options.grant ?? pageData.grant; // use the previous data if absence
-    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-
-    const grantedUserIds = pageData.grantedUserIds || [user._id];
-    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
-    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
-
-    const newPageData = pageData;
-
-    if (shouldBeOnTree) {
-      let isGrantNormalized = false;
-      try {
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, pageData.path, grant, grantedUserIds, grantUserGroupId, true);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-
-      if (!wasOnTree) {
-        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
-        newPageData.parent = newParent._id;
-      }
-    }
-    else {
-      if (wasOnTree && isChildrenExist) {
-        // Update children's parent with new parent
-        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
-        await this.updateMany(
-          { parent: pageData._id },
-          { parent: newParentForChildren._id },
-        );
-      }
-
-      newPageData.parent = null;
-      newPageData.descendantCount = 0;
-    }
-
-    newPageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await newPageData.save();
-
-    // Update body
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
-    }
-
-
-    this.emitPageEventUpdate(savedPage, user);
-
-    // Update ex children's parent
-    if (!wasOnTree && shouldBeOnTree) {
-      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
-
-      if (isChildrenExist) {
-        if (emptyPageAtSamePath != null) {
-          // Update children's parent with new parent
-          await this.updateMany(
-            { parent: emptyPageAtSamePath._id },
-            { parent: savedPage._id },
-          );
-        }
-      }
-
-      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
-    }
-
-    // Sub operation
-    // 1. Update descendantCount
-    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
-    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
-    if (shouldPlusDescCount) {
-      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
-      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
-      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
-    }
-    else if (shouldMinusDescCount) {
-      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
-      if (newPageData.grant === GRANT_RESTRICTED) {
-        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
-      }
-    }
-
-    // 2. Delete unnecessary empty pages
-    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
-    if (shouldRemoveLeafEmpPages) {
-      await this.removeLeafEmptyPagesRecursively(exParent);
-    }
-
-    return savedPage;
-  };
-
+export default function PageModel(crowi): any {
   // add old page schema methods
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
-};
+}

+ 9 - 0
packages/app/src/server/models/user-group-relation.js

@@ -93,6 +93,15 @@ class UserGroupRelation {
       .exec();
       .exec();
   }
   }
 
 
+  static async findAllUserIdsForUserGroup(userGroup) {
+    const relations = await this
+      .find({ relatedGroup: userGroup })
+      .select('relatedUser')
+      .exec();
+
+    return relations.map(r => r.relatedUser);
+  }
+
   /**
   /**
    * find all user and group relation of UserGroups
    * find all user and group relation of UserGroups
    *
    *

+ 1 - 0
packages/app/src/server/models/user-group.ts

@@ -109,6 +109,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
 };
 };
 
 
 /**
 /**
+ * TODO: use $graphLookup
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * @param groups UserGroupDocument[] including at least one UserGroup
  * @param groups UserGroupDocument[] including at least one UserGroup

+ 1 - 1
packages/app/src/server/routes/apiv3/page.js

@@ -559,7 +559,7 @@ module.exports = (crowi) => {
     try {
     try {
       const shouldUseV4Process = false;
       const shouldUseV4Process = false;
       const grantData = { grant, grantedGroup };
       const grantData = { grant, grantedGroup };
-      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      data = await this.crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occurred while processing calcApplicableGrantData.', err);
       logger.error('Error occurred while processing calcApplicableGrantData.', err);

+ 1 - 6
packages/app/src/server/routes/apiv3/pages.js

@@ -298,7 +298,7 @@ module.exports = (crowi) => {
     // check whether path starts slash
     // check whether path starts slash
     path = pathUtils.addHeadingSlash(path);
     path = pathUtils.addHeadingSlash(path);
 
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -323,11 +323,6 @@ module.exports = (crowi) => {
       revision: serializeRevisionSecurely(createdPage.revision),
       revision: serializeRevisionSecurely(createdPage.revision),
     };
     };
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     const parameters = {
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: createdPage,
       target: createdPage,

+ 3 - 13
packages/app/src/server/routes/page.js

@@ -870,7 +870,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
     }
 
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -891,11 +891,6 @@ module.exports = function(crowi, app) {
     };
     };
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     // global notification
     // global notification
     try {
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
@@ -1014,7 +1009,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
     }
 
 
-    const options = { isSyncRevisionToHackmd };
+    const options = { isSyncRevisionToHackmd, overwriteScopesOfDescendants };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
@@ -1022,7 +1017,7 @@ module.exports = function(crowi, app) {
 
 
     const previousRevision = await Revision.findById(revisionId);
     const previousRevision = await Revision.findById(revisionId);
     try {
     try {
-      page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
+      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('error on _api/pages.update', err);
       logger.error('error on _api/pages.update', err);
@@ -1044,11 +1039,6 @@ module.exports = function(crowi, app) {
     };
     };
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(page, req.user);
-    }
-
     // global notification
     // global notification
     try {
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);

+ 1 - 1
packages/app/src/server/routes/tag.js

@@ -159,7 +159,7 @@ module.exports = function(crowi, app) {
       }
       }
 
 
       const previousRevision = await Revision.findById(revisionId);
       const previousRevision = await Revision.findById(revisionId);
-      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
+      result.savedPage = await crowi.pageService.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
 

+ 1 - 1
packages/app/src/server/service/installer.ts

@@ -52,7 +52,7 @@ export class InstallerService {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);

+ 187 - 16
packages/app/src/server/service/page-grant.ts

@@ -1,4 +1,7 @@
-import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
+import {
+  pagePathUtils, pathUtils, pageUtils,
+  PageGrant, PageGrantCanBeOnTree,
+} from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
@@ -35,6 +38,39 @@ type ComparableDescendants = {
   grantedGroupIds: ObjectIdLike[],
   grantedGroupIds: ObjectIdLike[],
 };
 };
 
 
+/**
+ * @param grantedUserGroupInfo This parameter has info to calculate whether the update operation is allowed.
+ *   - See the `calcCanOverwriteDescendants` private method for detail.
+ */
+type UpdateGrantInfo = {
+  grant: typeof PageGrant.GRANT_PUBLIC,
+} | {
+  grant: typeof PageGrant.GRANT_OWNER,
+  grantedUserId: ObjectIdLike,
+} | {
+  grant: typeof PageGrant.GRANT_USER_GROUP,
+  grantedUserGroupInfo: {
+    groupId: ObjectIdLike,
+    userIds: Set<ObjectIdLike>,
+    childrenOrItselfGroupIds: Set<ObjectIdLike>,
+  },
+};
+
+type DescendantPagesGrantInfo = {
+  grantSet: Set<number>,
+  grantedUserIds: Set<ObjectIdLike>, // all only me users of descendant pages
+  grantedUserGroupIds: Set<ObjectIdLike>, // all user groups of descendant pages
+};
+
+/**
+ * @param {ObjectIdLike} userId The _id of the operator.
+ * @param {Set<ObjectIdLike>} userGroupIds The Set of the _id of the user groups that the operator belongs.
+ */
+type OperatorGrantInfo = {
+  userId: ObjectIdLike,
+  userGroupIds: Set<ObjectIdLike>,
+};
+
 class PageGrantService {
 class PageGrantService {
 
 
   crowi!: any;
   crowi!: any;
@@ -260,7 +296,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    * @returns ComparableDescendants
    */
    */
-  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
@@ -412,22 +448,22 @@ class PageGrantService {
     const isOnlyPublicApplicable = isTopPage(page.path);
     const isOnlyPublicApplicable = isTopPage(page.path);
     if (isOnlyPublicApplicable) {
     if (isOnlyPublicApplicable) {
       return {
       return {
-        [Page.GRANT_PUBLIC]: null,
+        [PageGrant.GRANT_PUBLIC]: null,
       };
       };
     }
     }
 
 
     // Increment an object (type IRecordApplicableGrant)
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
     const data: IRecordApplicableGrant = {
-      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+      [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
     };
 
 
     // -- Any grant is allowed if parent is null
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
     if (isAnyGrantApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       return data;
       return data;
     }
     }
 
 
@@ -440,21 +476,21 @@ class PageGrantService {
       grant, grantedUsers, grantedGroup,
       grant, grantedUsers, grantedGroup,
     } = parent;
     } = parent;
 
 
-    if (grant === Page.GRANT_PUBLIC) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    if (grant === PageGrant.GRANT_PUBLIC) {
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
-    else if (grant === Page.GRANT_OWNER) {
+    else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
       const grantedUser = grantedUsers[0];
 
 
       const isUserApplicable = grantedUser.toString() === user._id.toString();
       const isUserApplicable = grantedUser.toString() === user._id.toString();
 
 
       if (isUserApplicable) {
       if (isUserApplicable) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
       }
     }
     }
-    else if (grant === Page.GRANT_USER_GROUP) {
+    else if (grant === PageGrant.GRANT_USER_GROUP) {
       const group = await UserGroup.findById(grantedGroup);
       const group = await UserGroup.findById(grantedGroup);
       if (group == null) {
       if (group == null) {
         throw Error('Group not found to calculate grant data.');
         throw Error('Group not found to calculate grant data.');
@@ -465,14 +501,149 @@ class PageGrantService {
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
 
 
       if (isUserExistInGroup) {
       if (isUserExistInGroup) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
       }
-      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups };
     }
     }
 
 
     return data;
     return data;
   }
   }
 
 
+  /**
+   * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
+   * @param {string} targetPath
+   * @param operator
+   * @param {UpdateGrantInfo} updateGrantInfo
+   * @returns {Promise<boolean>}
+   */
+  async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
+    const UserGroupRelationModel = mongoose.model('UserGroupRelation') as any; // TODO: TypeScriptize model
+
+    const relatedGroupIds = await UserGroupRelationModel.findAllUserGroupIdsRelatedToUser(operator);
+    const operatorGrantInfo = {
+      userId: operator._id,
+      userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),
+    };
+
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, operator);
+
+    const grantSet = new Set<PageGrant>();
+    if (comparableDescendants.isPublicExist) {
+      grantSet.add(PageGrant.GRANT_PUBLIC);
+    }
+    if (comparableDescendants.grantedUserIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_OWNER);
+    }
+    if (comparableDescendants.grantedGroupIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_USER_GROUP);
+    }
+    const descendantPagesGrantInfo = {
+      grantSet,
+      grantedUserIds: new Set(comparableDescendants.grantedUserIds), // all only me users of descendant pages
+      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds), // all user groups of descendant pages
+    };
+
+    return this.calcCanOverwriteDescendants(operatorGrantInfo, updateGrantInfo, descendantPagesGrantInfo);
+  }
+
+  async generateUpdateGrantInfoToOverwriteDescendants(operator, updateGrant: PageGrantCanBeOnTree, grantUserGroupId?: ObjectIdLike): Promise<UpdateGrantInfo> {
+    let updateGrantInfo: UpdateGrantInfo | null = null;
+
+    if (updateGrant === PageGrant.GRANT_PUBLIC) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_PUBLIC,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_OWNER) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_OWNER,
+        grantedUserId: operator._id,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_USER_GROUP) {
+      if (grantUserGroupId == null) {
+        throw Error('The parameter `grantUserGroupId` is required.');
+      }
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      const userIds = await UserGroupRelation.findAllUserIdsForUserGroup(grantUserGroupId);
+      const childrenOrItselfGroups = await UserGroup.findGroupsWithDescendantsById(grantUserGroupId);
+      const childrenOrItselfGroupIds = childrenOrItselfGroups.map(d => d._id);
+
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserGroupInfo: {
+          groupId: grantUserGroupId,
+          userIds: new Set<ObjectIdLike>(userIds),
+          childrenOrItselfGroupIds: new Set<ObjectIdLike>(childrenOrItselfGroupIds),
+        },
+      };
+    }
+
+    if (updateGrantInfo == null) {
+      throw Error('The parameter `updateGrant` must be 1, 4, or 5');
+    }
+
+    return updateGrantInfo;
+  }
+
+  private calcIsAllDescendantsGrantedByOperator(operatorGrantInfo: OperatorGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo): boolean {
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_OWNER)) {
+      const isNonApplicableOwnerExist = descendantPagesGrantInfo.grantedUserIds.size >= 2
+        || !isIncludesObjectId([...descendantPagesGrantInfo.grantedUserIds], operatorGrantInfo.userId);
+      if (isNonApplicableOwnerExist) {
+        return false;
+      }
+    }
+
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_USER_GROUP)) {
+      const isNonApplicableGroupExist = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...operatorGrantInfo.userGroupIds],
+      ).length > 0;
+
+      if (isNonApplicableGroupExist) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private calcCanOverwriteDescendants(
+      operatorGrantInfo: OperatorGrantInfo, updateGrantInfo: UpdateGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo,
+  ): boolean {
+    // 1. check is tree GRANTED and it returns true when GRANTED
+    //   - GRANTED is the tree with all pages granted by the operator
+    const isAllDescendantsGranted = this.calcIsAllDescendantsGrantedByOperator(operatorGrantInfo, descendantPagesGrantInfo);
+    if (isAllDescendantsGranted) {
+      return true;
+    }
+
+    // 2. if not 1. then,
+    //   - when update grant is PUBLIC, return true
+    if (updateGrantInfo.grant === PageGrant.GRANT_PUBLIC) {
+      return true;
+    }
+    //   - when update grant is ONLYME, return false
+    if (updateGrantInfo.grant === PageGrant.GRANT_OWNER) {
+      return false;
+    }
+    //   - when update grant is USER_GROUP, return true if meets 2 conditions below
+    //      a. if all descendants user groups are children or itself of update user group
+    //      b. if all descendants grantedUsers belong to update user group
+    if (updateGrantInfo.grant === PageGrant.GRANT_USER_GROUP) {
+      const isAllDescendantGroupsChildrenOrItselfOfUpdateGroup = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...updateGrantInfo.grantedUserGroupInfo.childrenOrItselfGroupIds],
+      ).length === 0; // a.
+      const isUpdateGroupUsersIncludeAllDescendantsOwners = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserIds], [...updateGrantInfo.grantedUserGroupInfo.userIds],
+      ).length === 0; // b.
+
+      return isAllDescendantGroupsChildrenOrItselfOfUpdateGroup && isUpdateGroupUsersIncludeAllDescendantsOwners;
+    }
+
+    return false;
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 2 - 1
packages/app/src/server/service/page-operation.ts

@@ -16,6 +16,7 @@ const {
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 
 const {
 const {
+  Create, Update,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 } = PageActionType;
 
 
@@ -29,7 +30,7 @@ class PageOperationService {
 
 
   async init(): Promise<void> {
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
   }

+ 372 - 14
packages/app/src/server/service/page.ts

@@ -3,8 +3,8 @@ import { Readable, Writable } from 'stream';
 
 
 import {
 import {
   pagePathUtils, pathUtils, Ref, HasObjectId,
   pagePathUtils, pathUtils, Ref, HasObjectId,
-  IUserHasId,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
+  IUserHasId, PageStatus,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, PageGrant,
 } from '@growi/core';
 } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
@@ -28,6 +28,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
+import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
@@ -340,7 +341,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     const builder = new PageQueryBuilder(Page.find(), true)
     const builder = new PageQueryBuilder(Page.find(), true)
-      .addConditionAsNotMigrated() // to avoid affecting v5 pages
+      .addConditionAsRootOrNotOnTree() // to avoid affecting v5 pages
       .addConditionToListOnlyDescendants(targetPagePath);
       .addConditionToListOnlyDescendants(targetPagePath);
 
 
     await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
     await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
@@ -2376,7 +2377,7 @@ class PageService {
 
 
     // This validation is not 100% correct since it ignores user to count
     // This validation is not 100% correct since it ignores user to count
     const builder = new PageQueryBuilder(Page.find());
     const builder = new PageQueryBuilder(Page.find());
-    builder.addConditionAsNotMigrated();
+    builder.addConditionAsRootOrNotOnTree();
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToListWithDescendants(path);
     const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
     const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
     if (nEstimatedNormalizationTarget === 0) {
     if (nEstimatedNormalizationTarget === 0) {
@@ -3406,6 +3407,21 @@ class PageService {
     pageDocument.status = Page.STATUS_PUBLISHED;
     pageDocument.status = Page.STATUS_PUBLISHED;
   }
   }
 
 
+  private async validateAppliedScope(user, grant, grantUserGroupId) {
+    if (grant === PageGrant.GRANT_USER_GROUP && grantUserGroupId == null) {
+      throw new Error('grant userGroupId is not specified');
+    }
+
+    if (grant === PageGrant.GRANT_USER_GROUP) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
+
+      if (count === 0) {
+        throw new Error('no relations were exist for group and user.');
+      }
+    }
+  }
+
   private async canProcessCreate(
   private async canProcessCreate(
       path: string,
       path: string,
       grantData: {
       grantData: {
@@ -3415,6 +3431,7 @@ class PageService {
       },
       },
       shouldValidateGrant: boolean,
       shouldValidateGrant: boolean,
       user?,
       user?,
+      options?: Partial<PageCreateOptions>,
   ): Promise<boolean> {
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
@@ -3443,7 +3460,7 @@ class PageService {
       try {
       try {
         // It must check descendants as well if emptyTarget is not null
         // It must check descendants as well if emptyTarget is not null
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
-        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
 
         isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
         isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
       }
       }
@@ -3454,18 +3471,31 @@ class PageService {
       if (!isGrantNormalized) {
       if (!isGrantNormalized) {
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
       }
       }
+
+      if (options?.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await this.crowi.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
     }
     }
 
 
     return true;
     return true;
   }
   }
 
 
-  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+  /**
+   * Create a page
+   * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
+   */
+  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     // Switch method
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
     if (!isV5Compatible) {
-      return Page.createV4(path, body, user, options);
+      return this.createV4(path, body, user, options);
     }
     }
 
 
     // Values
     // Values
@@ -3485,7 +3515,7 @@ class PageService {
 
 
     // Validate
     // Validate
     const shouldValidateGrant = !isGrantRestricted;
     const shouldValidateGrant = !isGrantRestricted;
-    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user, options);
     if (!canProcessCreate) {
     if (!canProcessCreate) {
       throw Error('Cannnot process create');
       throw Error('Cannnot process create');
     }
     }
@@ -3517,23 +3547,113 @@ class PageService {
     savedPage = await pushRevision(savedPage, newRevision, user);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
-    // Update descendantCount
-    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
     // Emit create event
     // Emit create event
     this.pageEvent.emit('create', savedPage, user);
     this.pageEvent.emit('create', savedPage, user);
 
 
-    // Delete PageRedirect if exists
+    // Directly run sub operation for now since it might be complex to handle main operation for creating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Create,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        user,
+        fromPath: path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    if (options.isSynchronously) {
+      await this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+    else {
+      this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+
+    return savedPage;
+  }
+
+  /**
+   * Used to run sub operation in create method
+   */
+  async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(page._id, 1, false);
+
+    // Delete PageRedirect if exists
     try {
     try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+      await PageRedirect.deleteOne({ fromPath: page.path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${page.path}".`);
     }
     }
     catch (err) {
     catch (err) {
       // no throw
       // no throw
       logger.error('Failed to delete PageRedirect');
       logger.error('Failed to delete PageRedirect');
     }
     }
 
 
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(page, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  /**
+   * V4 compatible create method
+   */
+  private async createV4(path, body, user, options: any = {}) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const format = options.format || 'markdown';
+    const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+    // sanitize path
+    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
+
+    let grant = options.grant;
+    // force public
+    if (isTopPage(path)) {
+      grant = PageGrant.GRANT_PUBLIC;
+    }
+
+    const isExist = await Page.count({ path });
+
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+
+    const page = new Page();
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.status = PageStatus.STATUS_PUBLISHED;
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    this.pageEvent.emit('create', savedPage, user);
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
     return savedPage;
     return savedPage;
   }
   }
 
 
@@ -3628,6 +3748,244 @@ class PageService {
     return savedPage;
     return savedPage;
   }
   }
 
 
+  private shouldUseUpdatePageV4(grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean {
+    const isRestricted = grant === PageGrant.GRANT_RESTRICTED;
+    return !isRestricted && (!isV5Compatible || !isOnTree);
+  }
+
+  /**
+   * A wrapper method of updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}): Promise<PageDocument> {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  }
+
+  async updatePageSubOperation(page, user, exPage, options: IOptionsForUpdate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const currentPage = page;
+
+    const exParent = exPage.parent;
+    const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
+    const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(currentPage.path))}`), parent: { $ne: null } });
+
+    // 1. Update descendantCount
+    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
+    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
+    if (shouldPlusDescCount) {
+      await this.updateDescendantCountOfAncestors(currentPage._id, 1, false);
+      const newDescendantCount = await Page.recountDescendantCount(currentPage._id);
+      await Page.updateOne({ _id: currentPage._id }, { descendantCount: newDescendantCount });
+    }
+    else if (shouldMinusDescCount) {
+      // Update from parent. Parent is null if currentPage.grant is RESTRECTED.
+      if (currentPage.grant === PageGrant.GRANT_RESTRICTED) {
+        await this.updateDescendantCountOfAncestors(exParent, -1, true);
+      }
+    }
+
+    // 2. Delete unnecessary empty pages
+    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
+    if (shouldRemoveLeafEmpPages) {
+      await Page.removeLeafEmptyPagesRecursively(exParent);
+    }
+
+    // 3. Update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(currentPage, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  async updatePage(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: IOptionsForUpdate = {},
+  ): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    const shouldUseV4Process = this.shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
+    if (shouldUseV4Process) {
+      // v4 compatible process
+      return this.updatePageV4(pageData, body, previousBody, user, options);
+    }
+
+    // Clone page document
+    const clonedPageData = Page.hydrate(pageData.toObject());
+    const newPageData = pageData;
+
+    const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
+    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? clonedPageData.grantedGroup?._id.toString();
+
+    const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
+    const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`), parent: { $ne: null } });
+
+    const { pageService, pageGrantService } = this.crowi;
+
+    if (shouldBeOnTree) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
+        // eslint-disable-next-line max-len
+        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+
+      if (options.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
+
+      if (!wasOnTree) {
+        const newParent = await pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
+        newPageData.parent = newParent._id;
+      }
+    }
+    else {
+      if (wasOnTree && isChildrenExist) {
+        // Update children's parent with new parent
+        const newParentForChildren = await Page.createEmptyPage(clonedPageData.path, clonedPageData.parent, clonedPageData.descendantCount);
+        await Page.updateMany(
+          { parent: clonedPageData._id },
+          { parent: newParentForChildren._id },
+        );
+      }
+
+      newPageData.parent = null;
+      newPageData.descendantCount = 0;
+    }
+
+    newPageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await newPageData.save();
+
+    // Update body
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    // Update ex children's parent
+    if (!wasOnTree && shouldBeOnTree) {
+      const emptyPageAtSamePath = await Page.findOne({ path: clonedPageData.path, isEmpty: true }); // this page is necessary to find children
+
+      if (isChildrenExist) {
+        if (emptyPageAtSamePath != null) {
+          // Update children's parent with new parent
+          await Page.updateMany(
+            { parent: emptyPageAtSamePath._id },
+            { parent: savedPage._id },
+          );
+        }
+      }
+
+      await Page.findOneAndDelete({ path: clonedPageData.path, isEmpty: true }); // delete here
+    }
+
+    // Directly run sub operation for now since it might be complex to handle main operation for updating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Update,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        exPage: clonedPageData,
+        user,
+        fromPath: clonedPageData.path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    this.updatePageSubOperation(savedPage, user, clonedPageData, options, pageOp._id);
+
+    return savedPage;
+  }
+
+
+  async updatePageV4(pageData, body, previousBody, user, options: any = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    pageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await pageData.save();
+
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    return savedPage;
+  }
+
   /*
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    * Find all children by parent's path or id. Using id should be prioritized
    */
    */

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -1,6 +1,6 @@
 import { IUser, pagePathUtils } from '@growi/core';
 import { IUser, pagePathUtils } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import { Key, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 
 
@@ -57,7 +57,7 @@ export const useCurrentPathname = (initialData?: string): SWRResponse<string, Er
 };
 };
 
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useContextSWR<Nullable<string>, Error>('currentPageId', initialData);
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 };
 
 
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {

+ 2 - 2
packages/app/src/stores/editor.tsx

@@ -1,5 +1,5 @@
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import useSWR, { MutatorOptions, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
@@ -115,5 +115,5 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 };
 };
 
 
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning', undefined, { fallbackData: false });
+  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
 };
 };

+ 12 - 3
packages/app/src/stores/page.tsx

@@ -2,6 +2,7 @@ import type {
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
 } from '@growi/core';
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import { useEffect } from 'react';
 import useSWR, { Key, SWRResponse } from 'swr';
 import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -16,7 +17,7 @@ import { IRevisionsForPagination } from '~/interfaces/revision';
 import { IPageTagsInfo } from '../interfaces/tag';
 import { IPageTagsInfo } from '../interfaces/tag';
 
 
 import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
 import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
-import { ITermNumberManagerUtil, useStaticSWR, useTermNumberManager } from './use-static-swr';
+import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 
 const { isPermalink: _isPermalink } = pagePathUtils;
 const { isPermalink: _isPermalink } = pagePathUtils;
 
 
@@ -27,7 +28,7 @@ export const useSWRxPage = (
     revisionId?: string,
     revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  return useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)
       .then(result => result.data.page)
@@ -40,8 +41,16 @@ export const useSWRxPage = (
         }
         }
         throw Error('failed to get page');
         throw Error('failed to get page');
       }),
       }),
-    { fallbackData: initialData },
   );
   );
+
+  useEffect(() => {
+    if (initialData !== undefined) {
+      swrResponse.mutate(initialData);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [initialData]); // Only depends on `initialData`
+
+  return swrResponse;
 };
 };
 
 
 export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToShowRevision, Error> => {
 export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToShowRevision, Error> => {

+ 1 - 2
packages/app/src/stores/ui.tsx

@@ -23,7 +23,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   useCurrentPageId, useIsEditable, useIsGuestUser,
   useCurrentPageId, useIsEditable, useIsGuestUser,
-  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
+  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId, useIsNotFound,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useCurrentPagePath, useIsTrashPage } from './page';
 import { useCurrentPagePath, useIsTrashPage } from './page';
@@ -396,7 +396,6 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
   };
   };
 };
 };
 
 
-
 /** **********************************************************
 /** **********************************************************
  *                          SWR Hooks
  *                          SWR Hooks
  *                Determined value by context
  *                Determined value by context

+ 16 - 3
packages/app/src/stores/use-context-swr.tsx

@@ -1,8 +1,10 @@
+import assert from 'assert';
+
 import {
 import {
-  Key, SWRConfiguration, SWRResponse,
+  Key, SWRConfiguration, SWRResponse, useSWRConfig,
 } from 'swr';
 } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 
-import { useStaticSWR } from './use-static-swr';
 
 
 export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
@@ -16,7 +18,18 @@ export function useContextSWR<Data, Error>(
 ): SWRResponse<Data, Error> {
 ): SWRResponse<Data, Error> {
   const [key, data, configuration] = args;
   const [key, data, configuration] = args;
 
 
-  const swrResponse = useStaticSWR<Data, Error>(key, data, configuration);
+  assert.notStrictEqual(configuration?.fetcher, null, 'useContextSWR does not support \'configuration.fetcher\'');
+
+  const { cache } = useSWRConfig();
+  const swrResponse = useSWRImmutable(key, null, {
+    ...configuration,
+    fallbackData: configuration?.fallbackData ?? cache.get(key),
+  });
+
+  // write data to cache directly
+  if (data !== undefined) {
+    cache.set(key, data);
+  }
 
 
   const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
   const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
 
 

+ 11 - 10
packages/app/src/stores/use-static-swr.tsx

@@ -1,7 +1,9 @@
+import { useEffect } from 'react';
+
 import assert from 'assert';
 import assert from 'assert';
 
 
 import {
 import {
-  Key, SWRConfiguration, SWRResponse, useSWRConfig,
+  Key, SWRConfiguration, SWRResponse,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -20,16 +22,15 @@ export function useStaticSWR<Data, Error>(
 
 
   assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
   assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
 
 
-  const { cache } = useSWRConfig();
-  const swrResponse = useSWRImmutable(key, null, {
-    ...configuration,
-    fallbackData: configuration?.fallbackData ?? cache.get(key),
-  });
+  const swrResponse = useSWRImmutable(key, null, configuration);
 
 
-  // write data to cache directly
-  if (data !== undefined) {
-    cache.set(key, data);
-  }
+  // Do mutate with `data` from args
+  useEffect(() => {
+    if (data !== undefined) {
+      swrResponse.mutate(data);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data]); // Only depends on `data`
 
 
   return swrResponse;
   return swrResponse;
 }
 }

+ 1 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -11,6 +11,7 @@ context('Access to page by guest', () => {
 
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
     cy.visit('/Sandbox#Headers');
+    cy.getByTestid('grw-pagetree-item-container').should('be.visible');
     cy.collapseSidebar(true, true);
     cy.collapseSidebar(true, true);
 
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting

+ 554 - 68
packages/app/test/integration/models/v5.page.test.js

@@ -1,10 +1,14 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { PageGrant } from '~/interfaces/page';
 
 
 import { getInstance } from '../setup-crowi';
 import { getInstance } from '../setup-crowi';
 
 
 describe('Page', () => {
 describe('Page', () => {
   let crowi;
   let crowi;
+  let pageGrantService;
+  let pageService;
+
   let Page;
   let Page;
   let Revision;
   let Revision;
   let User;
   let User;
@@ -22,13 +26,313 @@ describe('Page', () => {
   let pModelUser1;
   let pModelUser1;
   let pModelUser2;
   let pModelUser2;
   let pModelUser3;
   let pModelUser3;
-  let groupIdIsolate;
-  let groupIdA;
-  let groupIdB;
-  let groupIdC;
+  let userGroupIdPModelIsolate;
+  let userGroupIdPModelA;
+  let userGroupIdPModelB;
+  let userGroupIdPModelC;
+
+  // To test updatePage overwriting descendants (prefix `upod`)
+  let upodUserA;
+  let upodUserB;
+  let upodUserC;
+  let upodGroupAB;
+  let upodGroupA;
+  let upodGroupAIsolated;
+  let upodGroupB;
+  let upodGroupC;
+  const upodUserGroupIdA = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAIsolated = new mongoose.Types.ObjectId();
+  const upodUserGroupIdB = new mongoose.Types.ObjectId();
+  const upodUserGroupIdC = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAB = new mongoose.Types.ObjectId();
+  const upodPageIdgAB1 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic2 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic3 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic4 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic5 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic6 = new mongoose.Types.ObjectId();
+
+  const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
+    const mockedUpdatePageSubOperation = jest.spyOn(pageService, 'updatePageSubOperation').mockReturnValue(null);
+
+    const savedPage = await pageService.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
+
+    const argsForUpdatePageSubOperation = mockedUpdatePageSubOperation.mock.calls[0];
+
+    mockedUpdatePageSubOperation.mockRestore();
+
+    await pageService.updatePageSubOperation(...argsForUpdatePageSubOperation);
+
+    return savedPage;
+  };
+
+  const createDocumentsToTestUpdatePageOverwritingDescendants = async() => {
+    // Users
+    await User.insertMany([
+      { name: 'upodUserA', username: 'upodUserA', email: 'upoduserA@example.com' },
+      { name: 'upodUserB', username: 'upodUserB', email: 'upoduserB@example.com' },
+      { name: 'upodUserC', username: 'upodUserC', email: 'upodUserC@example.com' },
+    ]);
+
+    upodUserA = await User.findOne({ username: 'upodUserA' });
+    upodUserB = await User.findOne({ username: 'upodUserB' });
+    upodUserC = await User.findOne({ username: 'upodUserC' });
+
+    await UserGroup.insertMany([
+      {
+        _id: upodUserGroupIdAB,
+        name: 'upodGroupAB',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdA,
+        name: 'upodGroupA',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdAIsolated,
+        name: 'upodGroupAIsolated',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdB,
+        name: 'upodGroupB',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdC,
+        name: 'upodGroupC',
+        parent: null,
+      },
+    ]);
+
+    upodGroupAB = await UserGroup.findOne({ name: 'upodGroupAB' });
+    upodGroupA = await UserGroup.findOne({ name: 'upodGroupA' });
+    upodGroupAIsolated = await UserGroup.findOne({ name: 'upodGroupAIsolated' });
+    upodGroupB = await UserGroup.findOne({ name: 'upodGroupB' });
+    upodGroupC = await UserGroup.findOne({ name: 'upodGroupC' });
+
+    // UserGroupRelations
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdA,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAIsolated,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdC,
+        relatedUser: upodUserC._id,
+      },
+    ]);
+
+    // Pages
+    await Page.insertMany([
+      // case 1
+      {
+        _id: upodPageIdgAB1,
+        path: '/gAB_upod_1', // to GRANT_PUBLIC
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: rootPage._id,
+      },
+      {
+        path: '/gAB_upod_1/gB_upod_1',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdgAB1,
+      },
+      {
+        path: '/gAB_upod_1/onlyB_upod_1',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdgAB1,
+      },
+      // case 2
+      {
+        _id: upodPageIdPublic2,
+        path: '/public_upod_2', // to Anything
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_2/gA_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/gAIsolated_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAIsolated,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/onlyA_upod_2',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: [upodUserA._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic2,
+      },
+      // case 3
+      {
+        _id: upodPageIdPublic3,
+        path: '/public_upod_3', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_3/gAB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/gB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/onlyB_upod_3',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic3,
+      },
+      // case 4
+      {
+        _id: upodPageIdPublic4,
+        path: '/public_upod_4', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_4/gA_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic4,
+      },
+      {
+        path: '/public_upod_4/gC_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdC,
+        parent: upodPageIdPublic4,
+      },
+      // case 5
+      {
+        _id: upodPageIdPublic5,
+        path: '/public_upod_5', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_5/gA_upod_5',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic5,
+      },
+      {
+        path: '/public_upod_5/onlyC_upod_5',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic5,
+      },
+      // case 6
+      {
+        _id: upodPageIdPublic6,
+        path: '/public_upod_6', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_6/onlyC_upod_6',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic6,
+      },
+    ]);
+  };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
+    pageGrantService = crowi.pageGrantService;
+    pageService = crowi.pageService;
+
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
 
     jest.restoreAllMocks();
     jest.restoreAllMocks();
@@ -75,69 +379,69 @@ describe('Page', () => {
     pModelUser3 = await User.findOne({ _id: pModelUserId3 });
     pModelUser3 = await User.findOne({ _id: pModelUserId3 });
 
 
 
 
-    groupIdIsolate = new mongoose.Types.ObjectId();
-    groupIdA = new mongoose.Types.ObjectId();
-    groupIdB = new mongoose.Types.ObjectId();
-    groupIdC = new mongoose.Types.ObjectId();
+    userGroupIdPModelIsolate = new mongoose.Types.ObjectId();
+    userGroupIdPModelA = new mongoose.Types.ObjectId();
+    userGroupIdPModelB = new mongoose.Types.ObjectId();
+    userGroupIdPModelC = new mongoose.Types.ObjectId();
     await UserGroup.insertMany([
     await UserGroup.insertMany([
       {
       {
-        _id: groupIdIsolate,
+        _id: userGroupIdPModelIsolate,
         name: 'pModel_groupIsolate',
         name: 'pModel_groupIsolate',
       },
       },
       {
       {
-        _id: groupIdA,
+        _id: userGroupIdPModelA,
         name: 'pModel_groupA',
         name: 'pModel_groupA',
       },
       },
       {
       {
-        _id: groupIdB,
+        _id: userGroupIdPModelB,
         name: 'pModel_groupB',
         name: 'pModel_groupB',
-        parent: groupIdA,
+        parent: userGroupIdPModelA,
       },
       },
       {
       {
-        _id: groupIdC,
+        _id: userGroupIdPModelC,
         name: 'pModel_groupC',
         name: 'pModel_groupC',
-        parent: groupIdB,
+        parent: userGroupIdPModelB,
       },
       },
     ]);
     ]);
 
 
     await UserGroupRelation.insertMany([
     await UserGroupRelation.insertMany([
       {
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId1,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId2,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId1,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId2,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId3,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId2,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId3,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
       {
       {
-        relatedGroup: groupIdC,
+        relatedGroup: userGroupIdPModelC,
         relatedUser: pModelUserId3,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
         createdAt: new Date(),
       },
       },
@@ -307,7 +611,7 @@ describe('Page', () => {
       {
       {
         path: '/mup20',
         path: '/mup20',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
         isEmpty: false,
@@ -335,7 +639,7 @@ describe('Page', () => {
       {
       {
         path: '/mup22/mup23',
         path: '/mup22/mup23',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
         isEmpty: false,
@@ -393,7 +697,7 @@ describe('Page', () => {
         _id: pageIdUpd16,
         _id: pageIdUpd16,
         path: '/mup29_A',
         path: '/mup29_A',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
         isEmpty: false,
@@ -414,7 +718,7 @@ describe('Page', () => {
         _id: pageIdUpd17,
         _id: pageIdUpd17,
         path: '/mup31_A',
         path: '/mup31_A',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
         isEmpty: false,
@@ -435,7 +739,7 @@ describe('Page', () => {
         _id: pageIdUpd18,
         _id: pageIdUpd18,
         path: '/mup33_C',
         path: '/mup33_C',
         grant: Page.GRANT_USER_GROUP,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroup: userGroupIdPModelC,
         creator: pModelUserId3,
         creator: pModelUserId3,
         lastUpdateUser: pModelUserId3,
         lastUpdateUser: pModelUserId3,
         isEmpty: false,
         isEmpty: false,
@@ -485,17 +789,10 @@ describe('Page', () => {
       },
       },
     ]);
     ]);
 
 
+    await createDocumentsToTestUpdatePageOverwritingDescendants();
   });
   });
 
 
   describe('update', () => {
   describe('update', () => {
-
-    const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
-      const mockedRenameSubOperation = jest.spyOn(Page, 'emitPageEventUpdate').mockReturnValue(null);
-      const savedPage = await Page.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
-      mockedRenameSubOperation.mockRestore();
-      return savedPage;
-    };
-
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
         const pathT = '/mup13_top';
@@ -509,7 +806,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
         expect(page2).toBeTruthy();
 
 
         const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
         const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1 });
         const _page1 = await Page.findOne({ path: path1 });
@@ -530,7 +827,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
         expect(page2).toBeTruthy();
 
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
 
         const _top = await Page.findOne({ path: pathT });
         const _top = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -557,7 +854,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(count).toBe(1);
         expect(count).toBe(1);
 
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -599,7 +896,7 @@ describe('Page', () => {
         expect(page1).toBeNull();
         expect(page1).toBeNull();
         expect(page2).toBeNull();
         expect(page2).toBeNull();
 
 
-        await Page.updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true });
@@ -626,7 +923,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
         expect(page2).toBeTruthy();
         expect(page3).toBeTruthy();
         expect(page3).toBeTruthy();
 
 
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
         const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
@@ -658,7 +955,7 @@ describe('Page', () => {
       });
       });
       test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
       test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
         const path = '/mup20';
         const path = '/mup20';
-        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page).toBeTruthy();
         expect(_page).toBeTruthy();
 
 
         await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
         await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
@@ -683,7 +980,7 @@ describe('Page', () => {
         const path1 = '/mup22';
         const path1 = '/mup22';
         const path2 = '/mup22/mup23';
         const path2 = '/mup22/mup23';
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page1).toBeTruthy();
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
         expect(_page2).toBeTruthy();
 
 
@@ -708,8 +1005,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
           const page2 = await Page.findById(_page2._id);
@@ -720,7 +1017,7 @@ describe('Page', () => {
 
 
           // check page2 grant and group
           // check page2 grant and group
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
         });
         });
 
 
         test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
         test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
@@ -730,8 +1027,8 @@ describe('Page', () => {
           const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
           const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
           expect(page1).toBeTruthy();
           expect(page1).toBeTruthy();
@@ -740,7 +1037,7 @@ describe('Page', () => {
 
 
           // updated page
           // updated page
           expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
           expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page1.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
 
 
           // parent's grant check
           // parent's grant check
           const parent = await Page.findById(page1.parent);
           const parent = await Page.findById(page1.parent);
@@ -760,8 +1057,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
           const page2 = await Page.findById(_page2._id);
@@ -772,7 +1069,7 @@ describe('Page', () => {
 
 
           // grant check
           // grant check
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
           expect(page2.grantedUsers.length).toBe(0);
           expect(page2.grantedUsers.length).toBe(0);
         });
         });
       });
       });
@@ -782,18 +1079,18 @@ describe('Page', () => {
           const _path1 = '/mup29_A';
           const _path1 = '/mup29_A';
           const _path2 = '/mup29_A/mup30_owner';
           const _path2 = '/mup29_A/mup30_owner';
           // page
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // out of update scope
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA }); // out of update scope
           const _page2 = await Page.findOne({ // update target
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
           });
           });
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdB };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelB };
 
 
           // First round
           // First round
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdB)
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
           const page2 = await Page.findById(_page2._id);
@@ -803,24 +1100,24 @@ describe('Page', () => {
           expect(updatedPage._id).toStrictEqual(page2._id);
           expect(updatedPage._id).toStrictEqual(page2._id);
 
 
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelB);
           expect(page2.grantedUsers.length).toBe(0);
           expect(page2.grantedUsers.length).toBe(0);
 
 
           // Second round
           // Second round
           // Update group to groupC which is a grandchild from pageA's point of view
           // Update group to groupC which is a grandchild from pageA's point of view
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdC }; // from GRANT_USER_GROUP(groupIdB) to GRANT_USER_GROUP(groupIdC)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelC }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
           const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
 
 
           expect(secondRoundUpdatedPage).toBeTruthy();
           expect(secondRoundUpdatedPage).toBeTruthy();
           expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
           expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(groupIdC);
+          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(userGroupIdPModelC);
         });
         });
         test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
         test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
           // path
           // path
           const _path1 = '/mup31_A';
           const _path1 = '/mup31_A';
           const _path2 = '/mup31_A/mup32_owner';
           const _path2 = '/mup31_A/mup32_owner';
           // page
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
           const _page2 = await Page.findOne({ // update target
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
           });
           });
@@ -828,13 +1125,13 @@ describe('Page', () => {
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
           // group
           // group
-          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          const _groupIsolated = await UserGroup.findById(userGroupIdPModelIsolate);
           expect(_groupIsolated).toBeTruthy();
           expect(_groupIsolated).toBeTruthy();
           // group parent check
           // group parent check
           expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
           expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdIsolate };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdIsolate)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelIsolate };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelIsolate)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
@@ -851,18 +1148,18 @@ describe('Page', () => {
           const _path1 = '/mup33_C';
           const _path1 = '/mup33_C';
           const _path2 = '/mup33_C/mup34_owner';
           const _path2 = '/mup33_C/mup34_owner';
           // page
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // groupC
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelC }); // groupC
           const _page2 = await Page.findOne({ // update target
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
           });
           });
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
 
 
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
           // this should fail because the groupC is a descendant of groupA
           // this should fail because the groupC is a descendant of groupA
-          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
 
           const page1 = await Page.findById(_page1._id);
           const page1 = await Page.findById(_page1._id);
@@ -888,8 +1185,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
 
           const page1 = await Page.findById(_page1.id);
           const page1 = await Page.findById(_page1.id);
@@ -905,4 +1202,193 @@ describe('Page', () => {
     });
     });
 
 
   });
   });
+
+
+  // see: https://dev.growi.org/635a314eac6bcd85cbf359fc about the specification
+  describe('updatePage with overwriteScopesOfDescendants true', () => {
+    test('(case 1) it should update all granted descendant pages when update grant is GRANT_PUBLIC', async() => {
+      const upodPagegAB = await Page.findOne({ path: '/gAB_upod_1' });
+      const upodPagegB = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
+      const upodPageonlyB = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).not.toBeNull();
+
+      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_PUBLIC,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagegAB, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegBUpdated = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_PUBLIC;
+      expect(updatedPage.grant).toBe(newGrant);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+    });
+    test('(case 2) it should update all granted descendant pages when all descendant pages are granted by the operator', async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyA = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPagegAIsolated).not.toBeNull();
+      expect(upodPageonlyA).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAIsolated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyA.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_OWNER,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegAUpdated = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolatedUpdated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyAUpdated = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_OWNER;
+      const newGrantedUsers = [upodUserA._id];
+      expect(updatedPage.grant).toBe(newGrant);
+      expect(updatedPage.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPagegAUpdated.grant).toBe(newGrant);
+      expect(upodPagegAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPagegAIsolatedUpdated.grant).toBe(newGrant);
+      expect(upodPagegAIsolatedUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPageonlyAUpdated.grant).toBe(newGrant);
+      expect(upodPageonlyAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+    });
+    test(`(case 3) it should update all granted descendant pages when update grant is GRANT_USER_GROUP
+    , all user groups of descendants are the children or itself of the update user group
+    , and all users of descendants belong to the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_3' });
+      const upodPagegAB = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegB = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyB = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegABUpdated = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegBUpdated = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_USER_GROUP;
+      const newGrantedGroup = upodUserGroupIdAB;
+      expect(updatedPage.grant).toBe(newGrant);
+      expect(updatedPage.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      expect(upodPagegABUpdated.grant).toBe(newGrant);
+      expect(upodPagegABUpdated.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup._id).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+    });
+    test(`(case 4) it should throw when some of descendants is not granted
+    , update grant is GRANT_USER_GROUP
+    , and some of user groups of descendants are not children or itself of the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_4' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_4/gA_upod_4' });
+      const upodPagegC = await Page.findOne({ path: '/public_upod_4/gC_upod_4' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPagegC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegC.grant).toBe(PageGrant.GRANT_USER_GROUP);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+    test(`(case 5) it should throw when some of descendants is not granted
+    , update grant is GRANT_USER_GROUP
+    , and some of users of descendants does NOT belong to the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_5' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_5/gA_upod_5' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_5/onlyC_upod_5' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPageonlyC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+    test('(case 6) it should throw when some of descendants is not granted and update grant is GRANT_OWNER', async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_6' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_6/onlyC_upod_6' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPageonlyC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+  });
 });
 });

+ 24 - 19
packages/app/test/integration/service/page-grant.test.js

@@ -30,6 +30,8 @@ describe('PageGrantService', () => {
   let groupParent;
   let groupParent;
   let groupChild;
   let groupChild;
 
 
+  const userGroupIdParent = new mongoose.Types.ObjectId();
+
   let rootPage;
   let rootPage;
   let rootPublicPage;
   let rootPublicPage;
   let rootOnlyMePage;
   let rootOnlyMePage;
@@ -72,18 +74,7 @@ describe('PageGrantService', () => {
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3User1Path = '/E3/User1';
   const pageE3User1Path = '/E3/User1';
 
 
-  /*
-   * prepare before all tests
-   */
-  beforeAll(async() => {
-    crowi = await getInstance();
-
-    pageGrantService = crowi.pageGrantService;
-
-    User = mongoose.model('User');
-    Page = mongoose.model('Page');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
-
+  const createDocumentsToTestIsGrantNormalized = async() => {
     // Users
     // Users
     await User.insertMany([
     await User.insertMany([
       { name: 'User1', username: 'User1', email: 'user1@example.com' },
       { name: 'User1', username: 'User1', email: 'user1@example.com' },
@@ -93,22 +84,19 @@ describe('PageGrantService', () => {
     user1 = await User.findOne({ username: 'User1' });
     user1 = await User.findOne({ username: 'User1' });
     user2 = await User.findOne({ username: 'User2' });
     user2 = await User.findOne({ username: 'User2' });
 
 
-    // Parent user groups
     await UserGroup.insertMany([
     await UserGroup.insertMany([
       {
       {
+        _id: userGroupIdParent,
         name: 'GroupParent',
         name: 'GroupParent',
         parent: null,
         parent: null,
       },
       },
-    ]);
-    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
-
-    // Child user groups
-    await UserGroup.insertMany([
       {
       {
         name: 'GroupChild',
         name: 'GroupChild',
-        parent: groupParent._id,
+        parent: userGroupIdParent,
       },
       },
     ]);
     ]);
+
+    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
 
 
     // UserGroupRelations
     // UserGroupRelations
@@ -319,6 +307,23 @@ describe('PageGrantService', () => {
     pageE3GroupParent = await Page.findOne({ path: pageE3GroupParentPath });
     pageE3GroupParent = await Page.findOne({ path: pageE3GroupParentPath });
     pageE3GroupChild = await Page.findOne({ path: pageE3GroupChildPath });
     pageE3GroupChild = await Page.findOne({ path: pageE3GroupChildPath });
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
+  };
+
+  /*
+   * prepare before all tests
+   */
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    pageGrantService = crowi.pageGrantService;
+
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+
+    rootPage = await Page.findOne({ path: '/' });
+
+    await createDocumentsToTestIsGrantNormalized();
 
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
   });

+ 16 - 2
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -83,6 +83,20 @@ describe('PageService page operations with non-public pages', () => {
   const tagIdRevert1 = new mongoose.Types.ObjectId();
   const tagIdRevert1 = new mongoose.Types.ObjectId();
   const tagIdRevert2 = new mongoose.Types.ObjectId();
   const tagIdRevert2 = new mongoose.Types.ObjectId();
 
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -746,7 +760,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page3).toBeNull();
         expect(page3).toBeNull();
 
 
         // use existing path
         // use existing path
-        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -774,7 +788,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
         expect(page2).toBeNull();
 
 
-        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });

+ 17 - 3
packages/app/test/integration/service/v5.public-page.test.ts

@@ -28,6 +28,20 @@ describe('PageService page operations with only public pages', () => {
   // page operation ids
   // page operation ids
   let pageOpId1;
   let pageOpId1;
 
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -1055,7 +1069,7 @@ describe('PageService page operations with only public pages', () => {
 
 
     test('Should create single page', async() => {
     test('Should create single page', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
       expect(page.parent).toStrictEqual(rootPage._id);
       // isGrantNormalized is called when GRANT PUBLIC
       // isGrantNormalized is called when GRANT PUBLIC
@@ -1064,7 +1078,7 @@ describe('PageService page operations with only public pages', () => {
 
 
     test('Should create empty-child and non-empty grandchild', async() => {
     test('Should create empty-child and non-empty grandchild', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
 
       expect(childPage.isEmpty).toBe(true);
       expect(childPage.isEmpty).toBe(true);
@@ -1081,7 +1095,7 @@ describe('PageService page operations with only public pages', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
       expect(beforeCreatePage.isEmpty).toBe(true);
 
 
-      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
 
       expect(childPage).toBeTruthy();
       expect(childPage).toBeTruthy();

+ 13 - 1
packages/core/src/interfaces/page.ts

@@ -54,7 +54,19 @@ export const PageGrant = {
   GRANT_OWNER: 4,
   GRANT_OWNER: 4,
   GRANT_USER_GROUP: 5,
   GRANT_USER_GROUP: 5,
 } as const;
 } as const;
-export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+type UnionPageGrantKeys = keyof typeof PageGrant;
+export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
+
+/**
+ * Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
+ */
+export type PageGrantCanBeOnTree = typeof PageGrant[Exclude<UnionPageGrantKeys, 'GRANT_RESTRICTED' | 'GRANT_SPECIFIED'>];
+
+export const PageStatus = {
+  STATUS_PUBLISHED: 'published',
+  STATUS_DELETED: 'deleted',
+} as const;
+export type PageStatus = typeof PageStatus[keyof typeof PageStatus];
 
 
 export type IPageHasId = IPage & HasObjectId;
 export type IPageHasId = IPage & HasObjectId;