Răsfoiți Sursa

Merge pull request #5902 from weseek/feat/admin-only-page-convert-by-path

feat: Admin only page convert by path
Yuki Takei 3 ani în urmă
părinte
comite
2ffbd27178

+ 2 - 0
packages/app/resource/locales/en_US/translation.json

@@ -664,6 +664,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
       "title": "Convert to new v5 compatible format",
+      "alert": "This operation cannot be undone, and pages that the user cannot view are also subject to processing.",
+      "checkbox_label": "Understood",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "button_label": "Convert",
       "button_label": "Convert",
       "success": "Successfully requested conversion.",
       "success": "Successfully requested conversion.",

+ 2 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -664,6 +664,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
       "title": "新しい v5 互換形式への変換",
+      "alert": "この操作は取り消すことができず、ユーザーが閲覧できないページも処理の対象になります。",
+      "checkbox_label": "理解しました",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "button_label": "変換",
       "button_label": "変換",
       "success": "正常に変換を開始しました",
       "success": "正常に変換を開始しました",

+ 2 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -951,6 +951,8 @@
     },
     },
     "by_path_modal": {
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
       "title": "转换为新的v5兼容格式",
+      "alert": "这一操作不能被撤销,用户不能查看的页面也要进行处理。",
+      "checkbox_label": "明白了",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "button_label": "转换",
       "button_label": "转换",
       "success": "成功地请求转换。",
       "success": "成功地请求转换。",

+ 44 - 8
packages/app/src/components/PrivateLegacyPages.tsx

@@ -15,6 +15,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import { useCurrentUser } from '~/stores/context';
 import {
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
@@ -139,6 +140,11 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [currentInput, setInput] = useState<string>('');
   const [currentInput, setInput] = useState<string>('');
+  const [checked, setChecked] = useState<boolean>(false);
+
+  useEffect(() => {
+    setChecked(false);
+  }, [props.isOpen]);
 
 
   return (
   return (
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
@@ -148,9 +154,26 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
       <ModalBody>
       <ModalBody>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+        <div className="alert alert-danger mt-3" role="alert">
+          { t('private_legacy_pages.by_path_modal.alert') }
+        </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+        <div className="form-check">
+          <input
+            className="form-check-input"
+            type="checkbox"
+            id="understoodCheckbox"
+            onChange={e => setChecked(e.target.checked)}
+          />
+          <label className="form-check-label" htmlFor="understoodCheckbox">{ t('private_legacy_pages.by_path_modal.checkbox_label') }</label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={!checked}
+          onClick={() => props.onSubmit?.(currentInput)}
+        >
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           { t('private_legacy_pages.by_path_modal.button_label') }
           { t('private_legacy_pages.by_path_modal.button_label') }
         </button>
         </button>
@@ -159,7 +182,6 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
   );
 });
 });
 
 
-
 /**
 /**
  * LegacyPage
  * LegacyPage
  */
  */
@@ -170,6 +192,9 @@ type Props = {
 
 
 const PrivateLegacyPages = (props: Props): JSX.Element => {
 const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
 
 
   const {
   const {
     appContainer,
     appContainer,
@@ -310,8 +335,23 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
     mutate();
     mutate();
   }, [limit, mutate]);
   }, [limit, mutate]);
 
 
+  const openConvertModalHandler = useCallback(() => {
+    if (!isAdmin) { return }
+    setOpenConvertModal(true);
+  }, [isAdmin]);
+
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
+  const renderOpenModalButton = useCallback(() => {
+    return (
+      <div className="d-flex pl-md-2">
+        <button type="button" className="btn btn-light" onClick={() => openConvertModalHandler()}>
+          {t('private_legacy_pages.input_path_to_convert')}
+        </button>
+      </div>
+    );
+  }, [t, openConvertModalHandler]);
+
   const searchControlAllAction = useMemo(() => {
   const searchControlAllAction = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
     const isCheckboxDisabled = hitsCount === 0;
 
 
@@ -342,11 +382,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
             </UncontrolledButtonDropdown>
             </UncontrolledButtonDropdown>
           </OperateAllControl>
           </OperateAllControl>
         </div>
         </div>
-        <div className="d-flex pl-md-2">
-          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
-            {t('private_legacy_pages.input_path_to_convert')}
-          </button>
-        </div>
+        {isAdmin && renderOpenModalButton()}
       </div>
       </div>
     );
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
@@ -418,7 +454,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         close={() => setOpenConvertModal(false)}
         close={() => setOpenConvertModal(false)}
         onSubmit={async(convertPath: string) => {
         onSubmit={async(convertPath: string) => {
           try {
           try {
-            await apiv3Post<void>('/pages/legacy-pages-migration', {
+            await apiv3Post<void>('/pages/convert-pages-by-path', {
               convertPath,
               convertPath,
             });
             });
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));

+ 1 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -2,6 +2,7 @@ export const V5ConversionErrCode = {
   GRANT_INVALID: 'GrantInvalid',
   GRANT_INVALID: 'GrantInvalid',
   PAGE_NOT_FOUND: 'PageNotFound',
   PAGE_NOT_FOUND: 'PageNotFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+  FORBIDDEN: 'Forbidden',
 } as const;
 } as const;
 
 
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 1 - 1
packages/app/src/server/events/user.js

@@ -21,7 +21,7 @@ UserEvent.prototype.onActivated = async function(user) {
 
 
     // create user page
     // create user page
     try {
     try {
-      await Page.create(userPagePath, body, user, {});
+      await this.crowi.pageService.create(userPagePath, body, user, {});
 
 
       // page created
       // page created
       debug('User page created', page);
       debug('User page created', page);

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

@@ -248,14 +248,14 @@ export const getPageSchema = (crowi) => {
   };
   };
 
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
-    // reset
+    // Reset
     this.grantedUsers = [];
     this.grantedUsers = [];
     this.grantedGroup = null;
     this.grantedGroup = null;
 
 
     this.grant = grant || GRANT_PUBLIC;
     this.grant = grant || GRANT_PUBLIC;
 
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
-      this.grantedUsers.push(user._id);
+    if (grant === GRANT_OWNER) {
+      this.grantedUsers.push(user?._id ?? user);
     }
     }
 
 
     if (grant === GRANT_USER_GROUP) {
     if (grant === GRANT_USER_GROUP) {

+ 77 - 251
packages/app/src/server/models/page.ts

@@ -10,15 +10,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
+import { IPage, IPageHasId, PageGrant } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
-import { IPage, IPageHasId, PageGrant } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { PageRedirectModel } from './page-redirect';
 
 
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
@@ -36,7 +35,9 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 const STATUS_DELETED = 'deleted';
 
 
-export interface PageDocument extends IPage, Document { }
+export interface PageDocument extends IPage, Document {
+  [x:string]: any // for obsolete methods
+}
 
 
 
 
 type TargetAndAncestorsResult = {
 type TargetAndAncestorsResult = {
@@ -51,11 +52,9 @@ type PaginatedPages = {
   offset: number
   offset: number
 }
 }
 
 
-export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
-  [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
-  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
+  [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -392,7 +391,7 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionAsMigrated() {
+  addConditionAsOnTree() {
     this.query = this.query
     this.query = this.query
       .and(
       .and(
         {
         {
@@ -481,58 +480,6 @@ export class PageQueryBuilder {
 
 
 }
 }
 
 
-/**
- * Create empty pages if the page in paths didn't exist
- * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
- * an empty page will not be created at that page's path.
- */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true, filter?): Promise<void> {
-  const aggregationPipeline: any[] = [];
-  // 1. Filter by paths
-  aggregationPipeline.push({ $match: { path: { $in: paths } } });
-  // 2. Normalized condition
-  if (onlyMigratedAsExistingPages) {
-    aggregationPipeline.push({
-      $match: {
-        $or: [
-          { grant: GRANT_PUBLIC },
-          { parent: { $ne: null } },
-          { path: '/' },
-        ],
-      },
-    });
-  }
-  // 3. Add custom pipeline
-  if (filter != null) {
-    aggregationPipeline.push({ $match: filter });
-  }
-  // 4. Add grant conditions
-  let userGroups = null;
-  if (user != null) {
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-  const grantCondition = this.generateGrantCondition(user, userGroups);
-  aggregationPipeline.push({ $match: grantCondition });
-
-  // Run aggregation
-  const existingPages = await this.aggregate(aggregationPipeline);
-
-
-  const existingPagePaths = existingPages.map(page => page.path);
-  // paths to create empty pages
-  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
-
-  // insertMany empty pages
-  try {
-    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
-  }
-  catch (err) {
-    logger.error('Failed to insert empty pages.', err);
-    throw err;
-  }
-};
-
 schema.statics.createEmptyPage = async function(
 schema.statics.createEmptyPage = async function(
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
 ): Promise<PageDocument & { _id: any }> {
 ): Promise<PageDocument & { _id: any }> {
@@ -600,73 +547,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
   return this.findById(newTarget._id);
 };
 };
 
 
-/**
- * Find parent or create parent if not exists.
- * It also updates parent of ancestors
- * @param path string
- * @returns Promise<PageDocument>
- */
-schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
-  const parentPath = nodePath.dirname(path);
-
-  const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
-  const pagesCanBeParent = await builder1
-    .addConditionAsMigrated()
-    .query
-    .exec();
-
-  if (pagesCanBeParent.length >= 1) {
-    return pagesCanBeParent[0]; // the earliest page will be the result
-  }
-
-  /*
-   * Fill parents if parent is null
-   */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
-
-  // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths, user);
-
-  // find ancestors
-  const builder2 = new PageQueryBuilder(this.find(), true);
-
-  // avoid including not normalized pages
-  builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
-
-  const ancestors = await builder2
-    .addConditionToListByPathsArray(ancestorPaths)
-    .addConditionToSortPagesByDescPath()
-    .query
-    .exec();
-
-  const ancestorsMap = new Map(); // Map<path, page>
-  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
-
-  // bulkWrite to update ancestors
-  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
-  const operations = nonRootAncestors.map((page) => {
-    const parentPath = nodePath.dirname(page.path);
-    return {
-      updateOne: {
-        filter: {
-          _id: page._id,
-        },
-        update: {
-          parent: ancestorsMap.get(parentPath)._id,
-        },
-      },
-    };
-  });
-  await this.bulkWrite(operations);
-
-  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
-  const createdParent = await this.findOne({ _id: parentId });
-  if (createdParent == null) {
-    throw Error('updated parent not Found');
-  }
-  return createdParent;
-};
-
 // Utility function to add viewer condition to PageQueryBuilder instance
 // Utility function to add viewer condition to PageQueryBuilder instance
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
   let relatedUserGroups = userGroups;
   let relatedUserGroups = userGroups;
@@ -766,7 +646,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
 
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
   const _targetAndAncestors: PageDocument[] = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByDescPath()
     .addConditionToSortPagesByDescPath()
@@ -814,7 +694,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
   const _pages = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByAscPath()
     .addConditionToSortPagesByAscPath()
     .query
     .query
@@ -845,14 +725,49 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
   return pathToChildren;
 };
 };
 
 
+/**
+ * Create empty pages at paths at which no pages exist
+ * @param paths Page paths
+ * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
+ */
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPipelineForExistingPages: any[]): Promise<void> {
+  const existingPages = await this.aggregate(aggrPipelineForExistingPages);
+
+  const existingPagePaths = existingPages.map(page => page.path);
+  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+  await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+};
+
+/**
+ * Find a parent page by path
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
+  const parentPath = nodePath.dirname(path);
+
+  const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
+  const pagesCanBeParent = await builder
+    .addConditionAsOnTree()
+    .query
+    .exec();
+
+  if (pagesCanBeParent.length >= 1) {
+    return pagesCanBeParent[0]; // the earliest page will be the result
+  }
+
+  return null;
+};
+
 /*
 /*
  * Utils from obsolete-page.js
  * Utils from obsolete-page.js
  */
  */
-async function pushRevision(pageData, newRevision, user) {
+export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
   await newRevision.save();
 
 
   pageData.revision = newRevision;
   pageData.revision = newRevision;
-  pageData.lastUpdateUser = user;
+  pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
   pageData.updatedAt = Date.now();
 
 
   return pageData.save();
   return pageData.save();
@@ -999,6 +914,36 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
   });
 };
 };
 
 
+/**
+ * Find a not empty parent recursively.
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
+  const parent = await this.findParentByPath(path);
+  if (parent == null) {
+    return null;
+  }
+
+  const recursive = async(page: PageDocument): Promise<PageDocument> => {
+    if (!page.isEmpty) {
+      return page;
+    }
+
+    const next = await this.findById(page.parent);
+
+    if (next == null || isTopPage(next.path)) {
+      return page;
+    }
+
+    return recursive(next);
+  };
+
+  const notEmptyParent = await recursive(parent);
+
+  return notEmptyParent;
+};
+
 schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
 schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
   return this.findOne({ _id: pageId });
   return this.findOne({ _id: pageId });
 };
 };
@@ -1063,125 +1008,6 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
     pageEvent = crowi.event('page');
   }
   }
 
 
-  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
-      throw Error('Crowi is not setup');
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    // v4 compatible process
-    if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
-    }
-
-    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
-    if (!canOperate) {
-      throw Error(`Cannot operate create to path "${path}" right now.`);
-    }
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', grantUserGroupId,
-    } = options;
-    let grant = options.grant;
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-    // throw if exists
-    const isExist = (await this.count({ path, isEmpty: false })) > 0; // not validate empty page
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    // find an existing empty page
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-
-    /*
-     * UserGroup & Owner validation
-     */
-    if (grant !== GRANT_RESTRICTED) {
-      let isGrantNormalized = false;
-      try {
-        // It must check descendants as well if emptyTarget is not null
-        const shouldCheckDescendants = emptyPage != null;
-        const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
-
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-    }
-
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
-      page = emptyPage;
-      const descendantCount = await this.recountDescendantCount(page._id);
-
-      page.descendantCount = descendantCount;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-
-    // set parent to null when GRANT_RESTRICTED
-    const isGrantRestricted = grant === GRANT_RESTRICTED;
-    if (isTopPage(path) || isGrantRestricted) {
-      page.parent = null;
-    }
-    else {
-      const parent = await Page.getParentAndFillAncestors(path, user);
-      page.parent = parent._id;
-    }
-
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-
-    /*
-     * After save
-     */
-    // Delete PageRedirect if exists
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-    try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
-    }
-    catch (err) {
-      // no throw
-      logger.error('Failed to delete PageRedirect');
-    }
-
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    // update descendantCount asynchronously
-    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
-    return savedPage;
-  };
-
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
     return !isRestricted && (!isV5Compatible || !isOnTree);
@@ -1253,7 +1079,7 @@ export default (crowi: Crowi): any => {
       }
       }
 
 
       if (!wasOnTree) {
       if (!wasOnTree) {
-        const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
+        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
         newPageData.parent = newParent._id;
         newPageData.parent = newParent._id;
       }
       }
     }
     }

+ 23 - 16
packages/app/src/server/routes/apiv3/pages.js

@@ -204,12 +204,15 @@ module.exports = (crowi) => {
         .custom(v => v === 'true' || v === true || v == null)
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     ],
+    convertPagesByPath: [
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+    ],
   };
   };
 
 
   async function createPageAction({
   async function createPageAction({
     path, body, user, options,
     path, body, user, options,
   }) {
   }) {
-    const createdPage = await Page.create(path, body, user, options);
+    const createdPage = await crowi.pageService.create(path, body, user, options);
     return createdPage;
     return createdPage;
   }
   }
 
 
@@ -823,29 +826,33 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
   });
 
 
+
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+    const { convertPath } = req.body;
 
 
     // Convert by path
     // Convert by path
-    if (convertPath != null) {
-      const normalizedPath = pathUtils.normalizePath(convertPath);
-      try {
-        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-      }
-      catch (err) {
-        logger.error(err);
-
-        if (isV5ConversionError(err)) {
-          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
-        }
+    const normalizedPath = pathUtils.normalizePath(convertPath);
+    try {
+      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+    }
+    catch (err) {
+      logger.error(err);
 
 
-        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      if (isV5ConversionError(err)) {
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
       }
       }
 
 
-      return res.apiv3({});
+      return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
     }
     }
 
 
+    return res.apiv3({});
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds: _pageIds, isRecursively } = req.body;
+
     // Convert by pageIds
     // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
     const pageIds = _pageIds == null ? [] : _pageIds;
 
 

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

@@ -437,7 +437,7 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
       logger.debug('Create page before file upload');
 
 
-      page = await Page.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
+      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
       pageCreated = true;
       pageCreated = true;
       pageId = page._id;
       pageId = page._id;
     }
     }

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

@@ -792,7 +792,7 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
     }
     }
 
 
-    const createdPage = await Page.create(pagePath, body, req.user, options);
+    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
 
 
     let savedTags;
     let savedTags;
     if (pageTags != null) {
     if (pageTags != null) {

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

@@ -43,14 +43,9 @@ export class InstallerService {
   }
   }
 
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
-
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const Page = mongoose.model('Page') as any;
-
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);

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

@@ -227,7 +227,7 @@ class PageGrantService {
      */
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     if (!includeNotMigratedPages) {
     if (!includeNotMigratedPages) {
-      builderForAncestors.addConditionAsMigrated();
+      builderForAncestors.addConditionAsOnTree();
     }
     }
     const ancestors = await builderForAncestors
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToListOnlyAncestors(targetPath)

+ 468 - 22
packages/app/src/server/service/page.ts

@@ -20,7 +20,7 @@ import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -525,7 +525,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     }
     else {
     else {
-      newParent = await Page.getParentAndFillAncestors(newPagePath, user);
+      newParent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
     }
     }
 
 
     // 3. Put back target page to tree (also update the other attrs)
     // 3. Put back target page to tree (also update the other attrs)
@@ -979,12 +979,12 @@ class PageService {
     };
     };
     let duplicatedTarget;
     let duplicatedTarget;
     if (page.isEmpty) {
     if (page.isEmpty) {
-      const parent = await Page.getParentAndFillAncestors(newPagePath, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     }
     else {
     else {
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
-      duplicatedTarget = await (Page.create as CreateMethod)(
+      duplicatedTarget = await (this.create as CreateMethod)(
         newPagePath, page.revision.body, user, options,
         newPagePath, page.revision.body, user, options,
       );
       );
     }
     }
@@ -1067,7 +1067,6 @@ class PageService {
   }
   }
 
 
   async duplicateV4(page, newPagePath, user, isRecursively) {
   async duplicateV4(page, newPagePath, user, isRecursively) {
-    const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1080,7 +1079,7 @@ class PageService {
 
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
-    const createdPage = await Page.create(
+    const createdPage = await this.crowi.pageService.create(
       newPagePath, page.revision.body, user, options,
       newPagePath, page.revision.body, user, options,
     );
     );
     this.pageEvent.emit('duplicate', page, user);
     this.pageEvent.emit('duplicate', page, user);
@@ -1910,7 +1909,7 @@ class PageService {
     }
     }
 
 
     // 2. Revert target
     // 2. Revert target
-    const parent = await Page.getParentAndFillAncestors(newPath, user);
+    const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
       $set: {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
@@ -2255,13 +2254,28 @@ class PageService {
 
 
   async normalizeParentByPath(path: string, user): Promise<void> {
   async normalizeParentByPath(path: string, user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // This validation is not 100% correct since it ignores user to count
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionAsNotMigrated();
+    builder.addConditionToListWithDescendants(path);
+    const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
+    if (nEstimatedNormalizationTarget === 0) {
+      throw Error('No page is available for conversion');
+    }
 
 
     const pages = await Page.findByPathAndViewer(path, user, null, false);
     const pages = await Page.findByPathAndViewer(path, user, null, false);
     if (pages == null || !Array.isArray(pages)) {
     if (pages == null || !Array.isArray(pages)) {
       throw Error('Something went wrong while converting pages.');
       throw Error('Something went wrong while converting pages.');
     }
     }
+
+
     if (pages.length === 0) {
     if (pages.length === 0) {
-      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+      const isForbidden = await Page.count({ path, isEmpty: false }) > 0;
+      if (isForbidden) {
+        throw new V5ConversionError('It is not allowed to convert this page.', V5ConversionErrCode.FORBIDDEN);
+      }
     }
     }
     if (pages.length > 1) {
     if (pages.length > 1) {
       throw new V5ConversionError(
       throw new V5ConversionError(
@@ -2270,10 +2284,33 @@ class PageService {
       );
       );
     }
     }
 
 
-    const page = pages[0];
-    const {
-      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
-    } = page;
+    let page;
+    let systematicallyCreatedPage;
+
+    const shouldCreateNewPage = pages[0] == null;
+    if (shouldCreateNewPage) {
+      const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
+
+      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
+        grant: notEmptyParent.grant,
+        grantUserGroupId: notEmptyParent.grantedGroup,
+        grantedUsers: notEmptyParent.grantedUsers,
+      };
+
+      systematicallyCreatedPage = await this.createBySystem(
+        path,
+        '',
+        options,
+      );
+      page = systematicallyCreatedPage;
+    }
+    else {
+      page = pages[0];
+    }
+
+    const grant = page.grant;
+    const grantedUserIds = page.grantedUsers;
+    const grantedGroupId = page.grantedGroup;
 
 
     /*
     /*
      * UserGroup & Owner validation
      * UserGroup & Owner validation
@@ -2311,7 +2348,6 @@ class PageService {
       throw err;
       throw err;
     }
     }
 
 
-    // no await
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
   }
   }
 
 
@@ -2412,7 +2448,7 @@ class PageService {
       normalizedPage = await Page.findById(page._id);
       normalizedPage = await Page.findById(page._id);
     }
     }
     else {
     else {
-      const parent = await Page.getParentAndFillAncestors(page.path, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, page.path);
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
     }
 
 
@@ -2466,7 +2502,7 @@ class PageService {
       const Page = mongoose.model('Page') as unknown as PageModel;
       const Page = mongoose.model('Page') as unknown as PageModel;
       const { PageQueryBuilder } = Page;
       const { PageQueryBuilder } = Page;
       const builder = new PageQueryBuilder(Page.findOne());
       const builder = new PageQueryBuilder(Page.findOne());
-      builder.addConditionAsMigrated();
+      builder.addConditionAsOnTree();
       builder.addConditionToListByPathsArray([page.path]);
       builder.addConditionToListByPathsArray([page.path]);
       const existingPage = await builder.query.exec();
       const existingPage = await builder.query.exec();
 
 
@@ -2509,18 +2545,19 @@ class PageService {
     }
     }
   }
   }
 
 
-  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
+  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<number> {
     // Save prevDescendantCount for sub-operation
     // Save prevDescendantCount for sub-operation
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.findOne(), true);
     const builder = new PageQueryBuilder(Page.findOne(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListByPathsArray([page.path]);
     builder.addConditionToListByPathsArray([page.path]);
     const exPage = await builder.query.exec();
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
 
+    let count: number;
     try {
     try {
-      await this.normalizeParentRecursively([page.path], user);
+      count = await this.normalizeParentRecursively([page.path], user);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
@@ -2536,6 +2573,8 @@ class PageService {
     }
     }
 
 
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
+
+    return count;
   }
   }
 
 
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
@@ -2674,7 +2713,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    * @returns Promise<void>
    */
    */
-  async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
+  async normalizeParentRecursively(paths: string[], user: any | null): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2740,7 +2779,7 @@ class PageService {
 
 
   private async _normalizeParentRecursively(
   private async _normalizeParentRecursively(
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
+  ): Promise<number> {
     const BATCH_SIZE = 100;
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const PAGES_LIMIT = 1000;
 
 
@@ -2779,6 +2818,9 @@ class PageService {
     let nextCount = count;
     let nextCount = count;
     let nextSkiped = skiped;
     let nextSkiped = skiped;
 
 
+    // eslint-disable-next-line max-len
+    const buildPipelineToCreateEmptyPagesByUser = this.buildPipelineToCreateEmptyPagesByUser.bind(this);
+
     const migratePagesStream = new Writable({
     const migratePagesStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(pages, encoding, callback) {
       async write(pages, encoding, callback) {
@@ -2817,7 +2859,8 @@ class PageService {
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
         ];
         ];
         const filterForApplicableAncestors = { $or: orFilters };
         const filterForApplicableAncestors = { $or: orFilters };
-        await Page.createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
+        const aggregationPipeline = await buildPipelineToCreateEmptyPagesByUser(user, parentPaths, false, filterForApplicableAncestors);
+        await Page.createEmptyPagesByPaths(parentPaths, aggregationPipeline);
 
 
         // 3. Find parents
         // 3. Find parents
         const addGrantCondition = (builder) => {
         const addGrantCondition = (builder) => {
@@ -2910,6 +2953,8 @@ class PageService {
 
 
     // End
     // End
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
+
+    return nextCount;
   }
   }
 
 
   private async _v5NormalizeIndex() {
   private async _v5NormalizeIndex() {
@@ -2963,7 +3008,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     const builder = new PageQueryBuilder(Page.find(), true);
     const builder = new PageQueryBuilder(Page.find(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToSortPagesByDescPath();
     builder.addConditionToSortPagesByDescPath();
 
 
@@ -3008,6 +3053,407 @@ class PageService {
     socket.emit(SocketEventName.UpdateDescCount, data);
     socket.emit(SocketEventName.UpdateDescCount, data);
   }
   }
 
 
+  /**
+   * Build the base aggregation pipeline for fillAncestors--- methods
+   * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+   * an empty page will not be created at that page's path.
+   */
+  private buildBasePipelineToCreateEmptyPages(paths: string[], onlyMigratedAsExistingPages = true, andFilter?): any[] {
+    const aggregationPipeline: any[] = [];
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // -- Filter by paths
+    aggregationPipeline.push({ $match: { path: { $in: paths } } });
+    // -- Normalized condition
+    if (onlyMigratedAsExistingPages) {
+      aggregationPipeline.push({
+        $match: {
+          $or: [
+            { grant: Page.GRANT_PUBLIC },
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      });
+    }
+    // -- Add custom pipeline
+    if (andFilter != null) {
+      aggregationPipeline.push({ $match: andFilter });
+    }
+
+    return aggregationPipeline;
+  }
+
+  private async buildPipelineToCreateEmptyPagesByUser(user, paths: string[], onlyMigratedAsExistingPages = true, andFilter?): Promise<any[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const pipeline = this.buildBasePipelineToCreateEmptyPages(paths, onlyMigratedAsExistingPages, andFilter);
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    const grantCondition = Page.generateGrantCondition(user, userGroups);
+    pipeline.push({ $match: grantCondition });
+
+    return pipeline;
+  }
+
+  private buildPipelineToCreateEmptyPagesBySystem(paths: string[]): any[] {
+    return this.buildBasePipelineToCreateEmptyPages(paths);
+  }
+
+  private async connectPageTree(path: string): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Find ancestors
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToFilterByApplicableAncestors(ancestorPaths); // avoid including not normalized pages
+    const ancestors = await builder
+      .addConditionToListByPathsArray(ancestorPaths)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+
+    // Update parent attrs
+    const ancestorsMap = new Map(); // Map<path, page>
+    ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
+
+    const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+    const operations = nonRootAncestors.map((page) => {
+      const parentPath = pathlib.dirname(page.path);
+      return {
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update: {
+            parent: ancestorsMap.get(parentPath)._id,
+          },
+        },
+      };
+    });
+    await Page.bulkWrite(operations);
+  }
+
+  /**
+   * Find parent or create parent if not exists.
+   * It also updates parent of ancestors
+   * @param path string
+   * @returns Promise<PageDocument>
+   */
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Fill ancestors
+    const aggregationPipeline: any[] = await this.buildPipelineToCreateEmptyPagesByUser(user, ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+    return createdParent;
+  }
+
+  async getParentAndFillAncestorsBySystem(path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    // Fill ancestors
+    const ancestorPaths = collectAncestorPaths(path);
+    const aggregationPipeline: any[] = this.buildPipelineToCreateEmptyPagesBySystem(ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+
+    return createdParent;
+  }
+
+  // --------- Create ---------
+
+  private async preparePageDocumentToCreate(path: string, shouldNew: boolean): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    // Use empty page if exists, if not, create a new page
+    let page;
+    if (shouldNew) {
+      page = new Page();
+    }
+    else if (emptyPage != null) {
+      page = emptyPage;
+      const descendantCount = await Page.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    return page;
+  }
+
+  private setFieldExceptForGrantRevisionParent(
+      pageDocument: PageDocument,
+      path: string,
+      user?,
+  ): void {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    pageDocument.path = path;
+    pageDocument.creator = user;
+    pageDocument.lastUpdateUser = user;
+    pageDocument.status = Page.STATUS_PUBLISHED;
+  }
+
+  private async canProcessCreate(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+      shouldValidateGrant: boolean,
+      user?,
+  ): Promise<boolean> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Operatability validation
+    const canOperate = await this.crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      logger.error(`Cannot operate create to path "${path}" right now.`);
+      return false;
+    }
+
+    // Existance validation
+    const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      logger.error('Cannot create new page to existed path');
+      return false;
+    }
+
+    // UserGroup & Owner validation
+    const { grant, grantedUserIds, grantUserGroupId } = grantData;
+    if (shouldValidateGrant) {
+      if (user == null) {
+        throw Error('user is required to validate grant');
+      }
+
+      let isGrantNormalized = false;
+      try {
+        // It must check descendants as well if emptyTarget is not null
+        const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    return true;
+  }
+
+  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Switch method
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      return Page.createV4(path, body, user, options);
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+    const {
+      format = 'markdown', grantUserGroupId,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+    const grantData = {
+      grant,
+      grantedUserIds: grant === Page.GRANT_OWNER ? [user._id] : undefined,
+      grantUserGroupId,
+    };
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+
+    // Validate
+    const shouldValidateGrant = !isGrantRestricted;
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    if (!canProcessCreate) {
+      throw Error('Cannnot process create');
+    }
+
+    // Prepare a page document
+    const shouldNew = isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path, user);
+
+    // Apply scope
+    page.applyScope(user, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsByUser(user, path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, user);
+
+    // Delete PageRedirect if exists
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    try {
+      await PageRedirect.deleteOne({ fromPath: path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+    }
+    catch (err) {
+      // no throw
+      logger.error('Failed to delete PageRedirect');
+    }
+
+    return savedPage;
+  }
+
+  private async canProcessCreateBySystem(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+  ): Promise<boolean> {
+    return this.canProcessCreate(path, grantData, false);
+  }
+
+  async createBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      throw Error('This method is available only when v5 compatible');
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+
+    const {
+      format = 'markdown', grantUserGroupId, grantedUsers,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+    const isGrantOwner = grant === Page.GRANT_OWNER;
+
+    const grantData = {
+      grant,
+      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserGroupId,
+    };
+
+    // Validate
+    if (isGrantOwner && grantedUsers?.length !== 1) {
+      throw Error('grantedUser must exist when grant is GRANT_OWNER');
+    }
+    const canProcessCreateBySystem = await this.canProcessCreateBySystem(path, grantData);
+    if (!canProcessCreateBySystem) {
+      throw Error('Cannnot process createBySystem');
+    }
+
+    // Prepare a page document
+    const shouldNew = !isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path);
+
+    // Apply scope
+    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsBySystem(path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const dummyUser = { _id: new mongoose.Types.ObjectId() };
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    savedPage = await pushRevision(savedPage, newRevision, dummyUser);
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, dummyUser);
+
+    return savedPage;
+  }
+
 }
 }
 
 
 export default PageService;
 export default PageService;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -21,7 +21,7 @@ class CreatePageService {
 
 
     // generate a dummy id because Operation to create a page needs ObjectId
     // generate a dummy id because Operation to create a page needs ObjectId
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
-    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();
     const growiUri = this.crowi.appService.getSiteUrl();

+ 1 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -27,7 +27,7 @@ module.exports = (crowi) => {
 
 
       if (isCreatableName && !isPageNameTaken) {
       if (isCreatableName && !isPageNameTaken) {
         try {
         try {
-          const promise = Page.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
+          const promise = crowi.pageService.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
           promises.push(promise);
           promises.push(promise);
         }
         }
         catch (err) {
         catch (err) {

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

@@ -511,13 +511,13 @@ describe('Page', () => {
   describe('create', () => {
   describe('create', () => {
 
 
     test('Should create single page', async() => {
     test('Should create single page', async() => {
-      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
       expect(page.parent).toStrictEqual(rootPage._id);
     });
     });
 
 
     test('Should create empty-child and non-empty grandchild', async() => {
     test('Should create empty-child and non-empty grandchild', async() => {
-      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await crowi.pageService.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);
@@ -531,7 +531,7 @@ describe('Page', () => {
       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 Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await crowi.pageService.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();
@@ -557,7 +557,7 @@ describe('Page', () => {
         expect(page3).toBeNull();
         expect(page3).toBeNull();
 
 
         // use existing path
         // use existing path
-        await Page.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await crowi.pageService.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 });
@@ -582,7 +582,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
         expect(page2).toBeNull();
 
 
-        await Page.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await crowi.pageService.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 });
@@ -814,7 +814,7 @@ describe('Page', () => {
   describe('getParentAndFillAncestors', () => {
   describe('getParentAndFillAncestors', () => {
     test('return parent if exist', async() => {
     test('return parent if exist', async() => {
       const page1 = await Page.findOne({ path: '/PAF1' });
       const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
       expect(parent).toBeTruthy();
       expect(parent).toBeTruthy();
       expect(page1.parent).toStrictEqual(parent._id);
       expect(page1.parent).toStrictEqual(parent._id);
     });
     });
@@ -829,7 +829,7 @@ describe('Page', () => {
       expect(_page2).toBeNull();
       expect(_page2).toBeNull();
       expect(_page3).toBeNull();
       expect(_page3).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
       const page1 = await Page.findOne({ path: path1 });
       const page1 = await Page.findOne({ path: path1 });
       const page2 = await Page.findOne({ path: path2 });
       const page2 = await Page.findOne({ path: path2 });
       const page3 = await Page.findOne({ path: path3 });
       const page3 = await Page.findOne({ path: path3 });
@@ -854,7 +854,7 @@ describe('Page', () => {
       expect(_page1).toBeTruthy();
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
       expect(_page2).toBeTruthy();
 
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
       const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
       const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
       const page2 = await Page.findOne({ path: path2, isEmpty: false });
       const page2 = await Page.findOne({ path: path2, isEmpty: false });
 
 
@@ -877,7 +877,7 @@ describe('Page', () => {
       expect(_page3).toBeTruthy();
       expect(_page3).toBeTruthy();
       expect(_page3.parent).toBeNull();
       expect(_page3.parent).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
       const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
       const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
       const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
       const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
       const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
       const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
@@ -920,7 +920,7 @@ describe('Page', () => {
       expect(_emptyA).toBeNull();
       expect(_emptyA).toBeNull();
       expect(_emptyAB).toBeNull();
       expect(_emptyAB).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
 
 
       const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
@@ -966,7 +966,7 @@ describe('Page', () => {
       expect(_emptyC).toBeNull();
       expect(_emptyC).toBeNull();
       expect(_emptyCD).toBeNull();
       expect(_emptyCD).toBeNull();
 
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
 
 
       const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
       const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
@@ -985,7 +985,7 @@ describe('Page', () => {
       expect(pageCD.parent).toStrictEqual(pageC._id);
       expect(pageCD.parent).toStrictEqual(pageC._id);
 
 
       // -- Check the found parent
       // -- Check the found parent
-      expect(parent).toStrictEqual(pageCD);
+      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
     });
     });
   });
   });
 });
 });