Просмотр исходного кода

Refactored PageService getParentAndFillAncestors and create

Taichi Masuyama 3 лет назад
Родитель
Сommit
223f8f0976

+ 44 - 29
packages/app/src/server/models/page.ts

@@ -36,7 +36,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 = {
@@ -53,7 +55,7 @@ type PaginatedPages = {
 
 
 export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => 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
+  [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>
@@ -63,7 +65,6 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
-  createSystematically(path: string, mrkdwn: string, options: PageCreateOptions): Promise<PageDocument | null>
 
 
   PageQueryBuilder: typeof PageQueryBuilder
   PageQueryBuilder: typeof PageQueryBuilder
 
 
@@ -391,7 +392,7 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionAsMigrated() {
+  addConditionAsOnTree() {
     this.query = this.query
     this.query = this.query
       .and(
       .and(
         {
         {
@@ -646,7 +647,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()
@@ -694,7 +695,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
@@ -725,6 +726,41 @@ 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
  */
  */
@@ -879,7 +915,7 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
   });
 };
 };
 
 
-// TODO: implement this method
+// TODO 93939: implement this method
 // schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
 // schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
 // Find a page on the tree by path
 // Find a page on the tree by path
 // Find not empty parent
 // Find not empty parent
@@ -942,9 +978,7 @@ schema.statics.generateGrantCondition = generateGrantCondition;
 export type PageCreateOptions = {
 export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike
-  grantedUserIds?: ObjectIdLike[]
   grant?: number
   grant?: number
-  isSystematically?: boolean
 }
 }
 
 
 /*
 /*
@@ -956,25 +990,6 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
     pageEvent = crowi.event('page');
   }
   }
 
 
-  /**
-   * Use this method when the system needs to create a page. Only available when v5 compatible
-   */
-  schema.statics.createSystematically = async function(path: string, mrkdwn: string, options: PageCreateOptions) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
-      throw Error('Crowi is not setup');
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    if (!isV5Compatible) {
-      throw Error('This method is only available when v5 compatibale.');
-    }
-
-    const dummyUser = { _id: new mongoose.Types.ObjectId() };
-
-    options.isSystematically = true;
-    return (crowi.pageService.create as CreateMethod)(path, mrkdwn, dummyUser, options);
-  };
-
   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);
@@ -1023,7 +1038,7 @@ export default (crowi: Crowi): any => {
       }
       }
 
 
       if (!wasOnTree) {
       if (!wasOnTree) {
-        const newParent = await crowi.pageService.getParentAndFillAncestors(newPageData.path, user);
+        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
         newPageData.parent = newParent._id;
         newPageData.parent = newParent._id;
       }
       }
     }
     }

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

@@ -226,7 +226,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)

+ 314 - 165
packages/app/src/server/service/page.ts

@@ -525,7 +525,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     }
     else {
     else {
-      newParent = await this.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,7 +979,7 @@ class PageService {
     };
     };
     let duplicatedTarget;
     let duplicatedTarget;
     if (page.isEmpty) {
     if (page.isEmpty) {
-      const parent = await this.getParentAndFillAncestors(newPagePath, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     }
     else {
     else {
@@ -1740,7 +1740,7 @@ class PageService {
     return;
     return;
   }
   }
 
 
-  // TODO: implement this method
+  // TODO 93939: implement this method
   async deleteCompletelySystematically() {
   async deleteCompletelySystematically() {
     return;
     return;
   }
   }
@@ -1914,7 +1914,7 @@ class PageService {
     }
     }
 
 
     // 2. Revert target
     // 2. Revert target
-    const parent = await this.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,
@@ -2286,14 +2286,16 @@ class PageService {
     if (shouldCreateNewPage) {
     if (shouldCreateNewPage) {
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
 
 
-      systematicallyCreatedPage = await Page.createSystematically(
+      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
+        grant: notEmptyParent.grant,
+        grantUserGroupId: notEmptyParent.grantedGroup,
+        grantedUsers: notEmptyParent.grantedUsers,
+      };
+
+      systematicallyCreatedPage = await this.createBySystem(
         path,
         path,
-        'This page was created by GROWI.',
-        {
-          grant: notEmptyParent.grant,
-          grantedUserIds: notEmptyParent.grantedUsers,
-          grantUserGroupId: notEmptyParent.grantedGroup,
-        },
+        '',
+        options,
       );
       );
       page = systematicallyCreatedPage;
       page = systematicallyCreatedPage;
     }
     }
@@ -2315,7 +2317,7 @@ class PageService {
       isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
       isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
     }
     }
     catch (err) {
     catch (err) {
-      // TODO: delete systematicallyCreatedPage
+      // TODO 93939: delete systematicallyCreatedPage
       logger.error(`Failed to validate grant of page at "${path}"`, err);
       logger.error(`Failed to validate grant of page at "${path}"`, err);
       throw err;
       throw err;
     }
     }
@@ -2338,7 +2340,7 @@ class PageService {
       });
       });
     }
     }
     catch (err) {
     catch (err) {
-      // TODO: delete systematicallyCreatedPage
+      // TODO 93939: delete systematicallyCreatedPage
       logger.error('Failed to create PageOperation document.', err);
       logger.error('Failed to create PageOperation document.', err);
       throw err;
       throw err;
     }
     }
@@ -2348,7 +2350,7 @@ class PageService {
       // Remove the created page if no page has converted
       // Remove the created page if no page has converted
       const count = await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
       const count = await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
       if (count === 0) {
       if (count === 0) {
-        // TODO: delete systematicallyCreatedPage
+        // TODO 93939: delete systematicallyCreatedPage
         // await this.deleteCompletelySystematically(systematicallyCreatedPage, user, {}, false);
         // await this.deleteCompletelySystematically(systematicallyCreatedPage, user, {}, false);
       }
       }
     })();
     })();
@@ -2451,7 +2453,7 @@ class PageService {
       normalizedPage = await Page.findById(page._id);
       normalizedPage = await Page.findById(page._id);
     }
     }
     else {
     else {
-      const parent = await this.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 });
     }
     }
 
 
@@ -2505,7 +2507,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();
 
 
@@ -2553,7 +2555,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(), 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 };
@@ -2821,7 +2823,8 @@ class PageService {
     let nextCount = count;
     let nextCount = count;
     let nextSkiped = skiped;
     let nextSkiped = skiped;
 
 
-    const createEmptyPagesByPaths = this.createEmptyPagesByPaths.bind(this);
+    // eslint-disable-next-line max-len
+    const buildPipelineToCreateEmptyPagesByUser = this.buildPipelineToCreateEmptyPagesByUser.bind(this);
 
 
     const migratePagesStream = new Writable({
     const migratePagesStream = new Writable({
       objectMode: true,
       objectMode: true,
@@ -2861,7 +2864,9 @@ 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 createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
+        // TODOT: fix or create another method for this
+        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) => {
@@ -3009,7 +3014,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();
 
 
@@ -3055,52 +3060,75 @@ class PageService {
   }
   }
 
 
   /**
   /**
-   * Find parent or create parent if not exists.
-   * It also updates parent of ancestors
-   * @param path string
-   * @returns Promise<PageDocument>
+   * 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.
    */
    */
-  async getParentAndFillAncestors(path: string, user, options?: { isSystematically?: boolean }): Promise<PageDocument> {
+  private buildBasePipelineToCreateEmptyPages(paths: string[], onlyMigratedAsExistingPages = true, andFilter?): any[] {
+    const aggregationPipeline: any[] = [];
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const { PageQueryBuilder } = Page;
 
 
-    const parentPath = pathlib.dirname(path);
+    // -- 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 });
+    }
 
 
-    const builder1 = new PageQueryBuilder(Page.find({ path: parentPath }), true);
-    const pagesCanBeParent = await builder1
-      .addConditionAsMigrated()
-      .query
-      .exec();
+    return aggregationPipeline;
+  }
+
+  private async buildPipelineToCreateEmptyPagesByUser(user, paths: string[], onlyMigratedAsExistingPages = true, andFilter?): Promise<any[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    if (pagesCanBeParent.length >= 1) {
-      return pagesCanBeParent[0]; // the earliest page will be the result
+    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 });
 
 
-    /*
-     * Fill parents if parent is null
-     */
-    const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+    return pipeline;
+  }
 
 
-    // just create ancestors with empty pages
-    // TODO: separate process
-    await this.createEmptyPagesByPaths(ancestorPaths, user, true);
+  private buildPipelineToCreateEmptyPagesBySystem(paths: string[]): any[] {
+    return this.buildBasePipelineToCreateEmptyPages(paths);
+  }
 
 
-    // find ancestors
-    const builder2 = new PageQueryBuilder(Page.find(), true);
+  private async connectPageTree(path: string): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
 
 
-    // avoid including not normalized pages
-    builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
+    const ancestorPaths = collectAncestorPaths(path);
 
 
-    const ancestors = await builder2
+    // Find ancestors
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToFilterByApplicableAncestors(ancestorPaths); // avoid including not normalized pages
+    const ancestors = await builder
       .addConditionToListByPathsArray(ancestorPaths)
       .addConditionToListByPathsArray(ancestorPaths)
       .addConditionToSortPagesByDescPath()
       .addConditionToSortPagesByDescPath()
       .query
       .query
       .exec();
       .exec();
 
 
+    // Update parent attrs
     const ancestorsMap = new Map(); // Map<path, page>
     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
     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 nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
     const operations = nonRootAncestors.map((page) => {
     const operations = nonRootAncestors.map((page) => {
       const parentPath = pathlib.dirname(page.path);
       const parentPath = pathlib.dirname(page.path);
@@ -3116,129 +3144,147 @@ class PageService {
       };
       };
     });
     });
     await Page.bulkWrite(operations);
     await Page.bulkWrite(operations);
-
-    const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
-    const createdParent = await Page.findOne({ _id: parentId });
-    if (createdParent == null) {
-      throw Error('updated parent not Found');
-    }
-    return createdParent;
   }
   }
 
 
-  // TODO: separate processes for systematical and manual operations
   /**
   /**
-   * 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.
+   * Find parent or create parent if not exists.
+   * It also updates parent of ancestors
+   * @param path string
+   * @returns Promise<PageDocument>
    */
    */
-  async createEmptyPagesByPaths(
-      paths: string[],
-      user: any | null,
-      onlyMigratedAsExistingPages = true,
-      andFilter?,
-  ): Promise<void> {
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    const aggregationPipeline: any[] = [];
-    // 1. Filter by paths
-    aggregationPipeline.push({ $match: { path: { $in: paths } } });
-    // 2. Normalized condition
-    if (onlyMigratedAsExistingPages) {
-      aggregationPipeline.push({
-        $match: {
-          $or: [
-            { grant: Page.GRANT_PUBLIC },
-            { parent: { $ne: null } },
-            { path: '/' },
-          ],
-        },
-      });
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
     }
     }
-    // 3. Add custom pipeline
-    if (andFilter != null) {
-      aggregationPipeline.push({ $match: andFilter });
+
+    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');
     }
     }
-    // 4. Add grant conditions
-    // TODO: need to separate here
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    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;
     }
     }
-    const grantCondition = Page.generateGrantCondition(user, userGroups);
-    aggregationPipeline.push({ $match: grantCondition });
 
 
-    // Run aggregation
-    const existingPages = await Page.aggregate(aggregationPipeline);
+    // Fill ancestors
+    const ancestorPaths = collectAncestorPaths(path);
+    const aggregationPipeline: any[] = this.buildPipelineToCreateEmptyPagesBySystem(ancestorPaths);
 
 
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
 
 
-    const existingPagePaths = existingPages.map(page => page.path);
-    // paths to create empty pages
-    const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+    // Connect ancestors
+    await this.connectPageTree(path);
 
 
-    // insertMany empty pages
-    try {
-      await Page.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
-    }
-    catch (err) {
-      logger.error('Failed to insert empty pages.', err);
-      throw err;
+    // 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 ---------
 
 
-  async create(path: string, body: string, user, options: PageCreateOptions = {}) {
+  private async preparePageDocumentToCreate(path: string, shouldNew: boolean): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
 
 
-    const { isSystematically } = options;
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
 
 
-    if (user == null && !isSystematically) {
-      throw Error('Cannot call create() without a parameter "user" when shouldSkipUserValidation is false.');
+    // 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);
 
 
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    // v4 compatible process
-    if (!isV5Compatible) {
-      return Page.createV4(path, body, user, options);
+      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);
     const canOperate = await this.crowi.pageOperationService.canOperate(false, null, path);
     if (!canOperate) {
     if (!canOperate) {
-      throw Error(`Cannot operate create to path "${path}" right now.`);
+      logger.error(`Cannot operate create to path "${path}" right now.`);
+      return false;
     }
     }
 
 
-    const {
-      format = 'markdown', grantUserGroupId, grantedUserIds,
-    } = options;
-    let grant = options.grant;
-
-    // sanitize path
-    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
-    // throw if exists
+    // Existance validation
     const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
     const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
     if (isExist) {
     if (isExist) {
-      throw Error('Cannot create new page to existed path');
-    }
-    // force public
-    if (isTopPage(path)) {
-      grant = Page.GRANT_PUBLIC;
+      logger.error('Cannot create new page to existed path');
+      return false;
     }
     }
 
 
-    // find an existing empty page
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
+    // UserGroup & Owner validation
+    const { grant, grantedUserIds, grantUserGroupId } = grantData;
+    if (shouldValidateGrant) {
+      if (user == null) {
+        throw Error('user is required to validate grant');
+      }
 
 
-    /*
-     * UserGroup & Owner validation
-     */
-    if (!isSystematically && grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       let isGrantNormalized = false;
       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 shouldCheckDescendants = emptyPage != null;
-        const newGrantedUserIds = grant === Page.GRANT_OWNER ? [user._id] : undefined;
+        const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist;
 
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3249,45 +3295,74 @@ class PageService {
       }
       }
     }
     }
 
 
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    if (emptyPage != null && grant !== Page.GRANT_RESTRICTED) {
-      page = emptyPage;
-      const descendantCount = await Page.recountDescendantCount(page._id);
+    return true;
+  }
 
 
-      page.descendantCount = descendantCount;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
+  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);
     }
     }
 
 
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = Page.STATUS_PUBLISHED;
+    // 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,
+    };
 
 
-    // set parent to null when GRANT_RESTRICTED
     const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
     const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
-    if (isTopPage(path) || isGrantRestricted) {
+
+    // 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;
       page.parent = null;
     }
     }
     else {
     else {
-      const options = { isSystematically };
-      const parent = await this.getParentAndFillAncestors(path, user, options);
+      const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
       page.parent = parent._id;
     }
     }
 
 
-    const userForApplyScope = grantedUserIds?.[0] != null ? { _id: grantedUserIds[0] } : user;
-    page.applyScope(userForApplyScope, grant, grantUserGroupId);
-
+    // Save
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
-    /*
-     * After 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
     // Delete PageRedirect if exists
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     try {
     try {
@@ -3299,14 +3374,88 @@ class PageService {
       logger.error('Failed to delete PageRedirect');
       logger.error('Failed to delete PageRedirect');
     }
     }
 
 
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
+    return savedPage;
+  }
 
 
-    this.pageEvent.emit('create', savedPage, user);
+  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);
 
 
-    // update descendantCount asynchronously
-    await this.crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, dummyUser);
 
 
     return savedPage;
     return savedPage;
   }
   }

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

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