Taichi Masuyama 4 лет назад
Родитель
Сommit
a8c0ba1b38

+ 2 - 3
packages/app/src/client/services/AdminAppContainer.js

@@ -452,10 +452,9 @@ export default class AdminAppContainer extends Container {
   /**
   /**
    * Start v5 page migration
    * Start v5 page migration
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
-   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
    */
    */
-  async v5PageMigrationHandler(action) {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
     const { isV5Compatible } = response.data;
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
     return { isV5Compatible };
   }
   }

+ 2 - 2
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -6,7 +6,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 
 type Props = {
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 }
 
 
 const V5PageMigration: FC<Props> = (props: Props) => {
 const V5PageMigration: FC<Props> = (props: Props) => {
@@ -17,7 +17,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const onConfirm = async() => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     setIsV5PageMigrationModalShown(false);
     try {
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
       if (isV5Compatible) {
 
 
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));

+ 1 - 1
packages/app/src/components/PageHistory/RevisionDiff.jsx

@@ -29,7 +29,7 @@ class RevisionDiff extends React.Component {
       }
       }
 
 
       const patch = createPatch(
       const patch = createPatch(
-        currentRevision.path,
+        '', // currentRevision.path is DEPRECATED
         previousText,
         previousText,
         currentRevision.body,
         currentRevision.body,
       );
       );

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

@@ -1020,8 +1020,7 @@ export const getPageSchema = (crowi) => {
 
 
     let savedPage = await page.save();
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     pageEvent.emit('create', savedPage, user);
     pageEvent.emit('create', savedPage, user);
@@ -1043,8 +1042,7 @@ export const getPageSchema = (crowi) => {
     // update existing page
     // update existing page
     let savedPage = await pageData.save();
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     if (isSyncRevisionToHackmd) {
     if (isSyncRevisionToHackmd) {

+ 4 - 6
packages/app/src/server/models/page.ts

@@ -164,7 +164,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  * @returns Promise<void>
  */
  */
-schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
   // find parent
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
   if (parent == null) {
@@ -172,7 +172,7 @@ schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void
   }
   }
 
 
   // create empty page at path
   // create empty page at path
-  const newTarget = await this.createEmptyPage(exPage.path, parent);
+  const newTarget = pageToReplaceWith == null ? await this.createEmptyPage(exPage.path, parent) : pageToReplaceWith;
 
 
   // find children by ex-page _id
   // find children by ex-page _id
   const children = await this.find({ parent: exPage._id });
   const children = await this.find({ parent: exPage._id });
@@ -601,8 +601,7 @@ export default (crowi: Crowi): any => {
      * After save
      * After save
      */
      */
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     pageEvent.emit('create', savedPage, user);
     pageEvent.emit('create', savedPage, user);
@@ -657,8 +656,7 @@ export default (crowi: Crowi): any => {
     // update existing page
     // update existing page
     let savedPage = await newPageData.save();
     let savedPage = await newPageData.save();
     const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
     const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     if (isSyncRevisionToHackmd) {
     if (isSyncRevisionToHackmd) {

+ 0 - 7
packages/app/src/server/models/revision.js

@@ -30,13 +30,6 @@ module.exports = function(crowi) {
   });
   });
   revisionSchema.plugin(mongoosePaginate);
   revisionSchema.plugin(mongoosePaginate);
 
 
-  revisionSchema.statics.findRevisionIdList = function(path) {
-    return this.find({ path })
-      .select('_id author createdAt hasDiffToPrev')
-      .sort({ createdAt: -1 })
-      .exec();
-  };
-
   revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
   revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
     return this.updateMany({ pageId }, { $set: updateData });
     return this.updateMany({ pageId }, { $set: updateData });
   };
   };

+ 27 - 19
packages/app/src/server/routes/apiv3/pages.js

@@ -175,14 +175,14 @@ module.exports = (crowi) => {
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
     ],
     ],
-
     duplicatePage: [
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
     ],
-    v5PageMigration: [
-      body('action').isString().withMessage('action is required'),
+    legacyPagesMigration: [
+      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('isRecursively').isBoolean().withMessage('isRecursively is required'),
     ],
     ],
   };
   };
 
 
@@ -704,26 +704,14 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
-    const { action, pageIds } = req.body;
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
 
 
     try {
     try {
-      switch (action) {
-        case 'initialMigration':
-          if (!isV5Compatible) {
-            // this method throws and emit socketIo event when error occurs
-            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
-          }
-          break;
-        case 'privateLegacyPages':
-          crowi.pageService.v5MigrationByPageIds(pageIds);
-          break;
-
-        default:
-          logger.error(`${action} action is not supported.`);
-          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      if (!isV5Compatible) {
+        // this method throws and emit socketIo event when error occurs
+        crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -733,6 +721,26 @@ module.exports = (crowi) => {
     return res.apiv3({ isV5Compatible });
     return res.apiv3({ isV5Compatible });
   });
   });
 
 
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds, isRecursively } = req.body;
+
+    if (isRecursively) {
+      // this method innerly uses socket to send message
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+    }
+    else {
+      try {
+        await crowi.pageService.normalizeParentByPageIds(pageIds);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      }
+    }
+
+    return res.apiv3({});
+  });
+
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');

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

@@ -124,7 +124,7 @@ module.exports = (crowi) => {
       const page = await Page.findOne({ _id: pageId });
       const page = await Page.findOne({ _id: pageId });
 
 
       const paginateResult = await Revision.paginate(
       const paginateResult = await Revision.paginate(
-        { path: page.path },
+        { pageId: page._id },
         {
         {
           page: selectedPage,
           page: selectedPage,
           limit,
           limit,

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

@@ -87,7 +87,7 @@ module.exports = function(crowi) {
     // add owner after creating admin user
     // add owner after creating admin user
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
     const rootPage = await Page.findOne({ path: '/' });
     const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ path: '/' });
+    const rootRevision = await Revision.findOne({ pageId: rootPage._id });
     rootPage.creator = adminUser;
     rootPage.creator = adminUser;
     rootRevision.creator = adminUser;
     rootRevision.creator = adminUser;
     await Promise.all([rootPage.save(), rootRevision.save()]);
     await Promise.all([rootPage.save(), rootRevision.save()]);

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

@@ -3,7 +3,7 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 
 
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
-import { PageModel } from '~/server/models/page';
+import { PageDocument, PageModel } from '~/server/models/page';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 
@@ -212,7 +212,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns Promise<ComparableAncestor>
    * @returns Promise<ComparableAncestor>
    */
    */
-  private async generateComparableAncestor(targetPath: string): Promise<ComparableAncestor> {
+  private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
@@ -223,6 +223,9 @@ class PageGrantService {
      * make granted users list of ancestor's
      * make granted users list of ancestor's
      */
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    if (!includeNotMigratedPages) {
+      builderForAncestors.addConditionAsMigrated();
+    }
     const ancestors = await builderForAncestors
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToSortPagesByDescPath()
       .addConditionToSortPagesByDescPath()
@@ -254,7 +257,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    * @returns ComparableDescendants
    */
    */
-  private async generateComparableDescendants(targetPath: string): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     /*
     /*
@@ -263,12 +266,17 @@ class PageGrantService {
     const pathWithTrailingSlash = addTrailingSlash(targetPath);
     const pathWithTrailingSlash = addTrailingSlash(targetPath);
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
 
+    const $match: any = {
+      path: new RegExp(`^${startsPattern}`),
+      isEmpty: { $ne: true },
+    };
+    if (includeNotMigratedPages) {
+      $match.parent = { $ne: null };
+    }
+
     const result = await Page.aggregate([
     const result = await Page.aggregate([
       { // match to descendants excluding empty pages
       { // match to descendants excluding empty pages
-        $match: {
-          path: new RegExp(`^${startsPattern}`),
-          isEmpty: { $ne: true },
-        },
+        $match,
       },
       },
       {
       {
         $project: {
         $project: {
@@ -310,16 +318,18 @@ class PageGrantService {
 
 
   /**
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * Only v5 schema pages will be used to compare.
    * @returns Promise<boolean>
    * @returns Promise<boolean>
    */
    */
   async isGrantNormalized(
   async isGrantNormalized(
-      targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false,
+      // eslint-disable-next-line max-len
+      targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
   ): Promise<boolean> {
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
     if (isTopPage(targetPath)) {
       return true;
       return true;
     }
     }
 
 
-    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+    const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
 
     if (!shouldCheckDescendants) { // checking the parent is enough
     if (!shouldCheckDescendants) { // checking the parent is enough
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
@@ -327,11 +337,42 @@ class PageGrantService {
     }
     }
 
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
-    const comparableDescendants = await this.generateComparableDescendants(targetPath);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, includeNotMigratedPages);
 
 
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
   }
   }
 
 
+  async separateNormalizedAndNonNormalizedPages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+    const shouldCheckDescendants = true;
+    const shouldIncludeNotMigratedPages = true;
+
+    const normalizedPages: (PageDocument & { _id: any })[] = [];
+    const nonNormalizedPages: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
+
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionToListByPageIdsArray(pageIds);
+
+    const pages = await builder.query.exec();
+
+    for await (const page of pages) {
+      const {
+        path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+      } = page;
+
+      const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
+      if (isNormalized) {
+        normalizedPages.push(page);
+      }
+      else {
+        nonNormalizedPages.push(page);
+      }
+    }
+
+    return [normalizedPages, nonNormalizedPages];
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 125 - 34
packages/app/src/server/service/page.ts

@@ -15,6 +15,7 @@ import { stringifySnapshot } from '~/models/serializers/in-app-notification-snap
 import ActivityDefine from '../util/activityDefine';
 import ActivityDefine from '../util/activityDefine';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
 import { PageRedirectModel } from '../models/page-redirect';
 import { PageRedirectModel } from '../models/page-redirect';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
@@ -366,11 +367,6 @@ class PageService {
     // update Rivisions
     // update Rivisions
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
 
-    /*
-     * TODO: https://redmine.weseek.co.jp/issues/86577
-     * bulkWrite PageRedirectDocument if createRedirectPage is true
-     */
-
     this.pageEvent.emit('rename', page, user);
     this.pageEvent.emit('rename', page, user);
 
 
     return renamedPage;
     return renamedPage;
@@ -386,7 +382,7 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
 
-    const { updateMetadata } = options;
+    const { updateMetadata, createRedirectPage } = options;
 
 
     const updatePathOperations: any[] = [];
     const updatePathOperations: any[] = [];
     const insertPageRedirectOperations: any[] = [];
     const insertPageRedirectOperations: any[] = [];
@@ -396,11 +392,19 @@ class PageService {
 
 
       // increment updatePathOperations
       // increment updatePathOperations
       let update;
       let update;
-      if (updateMetadata && !page.isEmpty) {
+      if (!page.isEmpty && updateMetadata) {
         update = {
         update = {
           $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() },
           $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() },
         };
         };
 
 
+      }
+      else {
+        update = {
+          $set: { path: newPagePath },
+        };
+      }
+
+      if (!page.isEmpty && createRedirectPage) {
         // insert PageRedirect
         // insert PageRedirect
         insertPageRedirectOperations.push({
         insertPageRedirectOperations.push({
           insertOne: {
           insertOne: {
@@ -411,11 +415,6 @@ class PageService {
           },
           },
         });
         });
       }
       }
-      else {
-        update = {
-          $set: { path: newPagePath },
-        };
-      }
 
 
       updatePathOperations.push({
       updatePathOperations.push({
         updateOne: {
         updateOne: {
@@ -429,7 +428,6 @@ class PageService {
 
 
     try {
     try {
       await Page.bulkWrite(updatePathOperations);
       await Page.bulkWrite(updatePathOperations);
-      await PageRedirect.bulkWrite(insertPageRedirectOperations);
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
@@ -437,13 +435,22 @@ class PageService {
       }
       }
     }
     }
 
 
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
+
     this.pageEvent.emit('updateMany', pages, user);
     this.pageEvent.emit('updateMany', pages, user);
   }
   }
 
 
   private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
   private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const pageCollection = mongoose.connection.collection('pages');
     const pageCollection = mongoose.connection.collection('pages');
-    const { updateMetadata } = options;
+    const { updateMetadata, createRedirectPage } = options;
 
 
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
     const insertPageRedirectOperations: any[] = [];
     const insertPageRedirectOperations: any[] = [];
@@ -460,19 +467,20 @@ class PageService {
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
       }
       }
       // insert PageRedirect
       // insert PageRedirect
-      insertPageRedirectOperations.push({
-        insertOne: {
-          document: {
-            fromPath: page.path,
-            toPath: newPagePath,
+      if (!page.isEmpty && createRedirectPage) {
+        insertPageRedirectOperations.push({
+          insertOne: {
+            document: {
+              fromPath: page.path,
+              toPath: newPagePath,
+            },
           },
           },
-        },
-      });
+        });
+      }
     });
     });
 
 
     try {
     try {
       await unorderedBulkOp.execute();
       await unorderedBulkOp.execute();
-      await PageRedirect.bulkWrite(insertPageRedirectOperations);
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
@@ -480,6 +488,15 @@ class PageService {
       }
       }
     }
     }
 
 
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
+
     this.pageEvent.emit('updateMany', pages, user);
     this.pageEvent.emit('updateMany', pages, user);
   }
   }
 
 
@@ -784,7 +801,7 @@ class PageService {
       newPages.push(newPage);
       newPages.push(newPage);
 
 
       newRevisions.push({
       newRevisions.push({
-        _id: revisionId, path: newPagePath, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
       });
       });
 
 
     });
     });
@@ -830,7 +847,7 @@ class PageService {
       });
       });
 
 
       newRevisions.push({
       newRevisions.push({
-        _id: revisionId, path: newPagePath, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
       });
       });
 
 
     });
     });
@@ -964,6 +981,12 @@ class PageService {
       throw new Error('Page is not deletable.');
       throw new Error('Page is not deletable.');
     }
     }
 
 
+    // replace with an empty page
+    const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
+    if (shouldReplace) {
+      await Page.replaceTargetWithPage(page);
+    }
+
     if (isRecursively) {
     if (isRecursively) {
       this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
       this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
     }
     }
@@ -1083,7 +1106,6 @@ class PageService {
 
 
     try {
     try {
       await Page.bulkWrite(deletePageOperations);
       await Page.bulkWrite(deletePageOperations);
-      await PageRedirect.bulkWrite(insertPageRedirectOperations);
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
@@ -1093,6 +1115,15 @@ class PageService {
     finally {
     finally {
       this.pageEvent.emit('syncDescendantsDelete', pages, user);
       this.pageEvent.emit('syncDescendantsDelete', pages, user);
     }
     }
+
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
   }
   }
 
 
   /**
   /**
@@ -1198,7 +1229,7 @@ class PageService {
     // replace with an empty page
     // replace with an empty page
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     if (shouldReplace) {
     if (shouldReplace) {
-      await Page.replaceTargetWithEmptyPage(page);
+      await Page.replaceTargetWithPage(page);
     }
     }
 
 
     await this.deleteCompletelyOperation(ids, paths);
     await this.deleteCompletelyOperation(ids, paths);
@@ -1285,7 +1316,7 @@ class PageService {
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
 
     const revertPageOperations: any[] = [];
     const revertPageOperations: any[] = [];
-    const fromPaths: string[] = [];
+    const fromPathsToDelete: string[] = [];
 
 
     pages.forEach((page) => {
     pages.forEach((page) => {
       // e.g. page.path = /trash/test, toPath = /test
       // e.g. page.path = /trash/test, toPath = /test
@@ -1301,12 +1332,12 @@ class PageService {
         },
         },
       });
       });
 
 
-      fromPaths.push(page.path);
+      fromPathsToDelete.push(page.path);
     });
     });
 
 
     try {
     try {
       await Page.bulkWrite(revertPageOperations);
       await Page.bulkWrite(revertPageOperations);
-      await PageRedirect.deleteMany({ fromPath: { $in: fromPaths } });
+      await PageRedirect.deleteMany({ fromPath: { $in: fromPathsToDelete } });
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
@@ -1573,16 +1604,76 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
   }
 
 
-  async v5MigrationByPageIds(pageIds) {
-    const Page = mongoose.model('Page');
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+    for await (const pageId of pageIds) {
+      try {
+        await this.normalizeParentByPageId(pageId);
+      }
+      catch (err) {
+        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
+      }
+    }
+  }
+
+  private async normalizeParentByPageId(pageId: ObjectIdLike) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const target = await Page.findById(pageId);
+    if (target == null) {
+      throw Error('target does not exist');
+    }
+
+    const {
+      path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+    } = target;
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (target.grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = true;
 
 
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}"`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('This page cannot be migrated since the selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+    else {
+      throw Error('Restricted pages can not be migrated');
+    }
+
+    // getParentAndFillAncestors
+    const parent = await Page.getParentAndFillAncestors(target.path);
+
+    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+  }
+
+  async normalizeParentRecursivelyByPageIds(pageIds) {
     if (pageIds == null || pageIds.length === 0) {
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       logger.error('pageIds is null or 0 length.');
       return;
       return;
     }
     }
 
 
+    const [normalizedIds, notNormalizedPaths] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
+
+    if (normalizedIds.length === 0) {
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+      return;
+    }
+
+    if (notNormalizedPaths.length !== 0) {
+      // TODO: iterate notNormalizedPaths and send socket error to client so that the user can know which path failed to migrate
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+    }
+
     // generate regexps
     // generate regexps
-    const regexps = await this._generateRegExpsByPageIds(pageIds);
+    const regexps = await this._generateRegExpsByPageIds(normalizedIds);
 
 
     // migrate recursively
     // migrate recursively
     try {
     try {
@@ -1590,7 +1681,7 @@ class PageService {
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
-      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
 
 
       throw err;
       throw err;
     }
     }
@@ -1691,7 +1782,7 @@ class PageService {
     }
     }
 
 
     const { pages } = result;
     const { pages } = result;
-    const regexps = pages.map(page => new RegExp(`^${page.path}`));
+    const regexps = pages.map(page => new RegExp(`^${escapeStringRegexp(page.path)}`));
 
 
     return regexps;
     return regexps;
   }
   }

+ 37 - 37
packages/app/src/test/integration/service/page.test.js

@@ -277,12 +277,12 @@ describe('PageService', () => {
     await Revision.insertMany([
     await Revision.insertMany([
       {
       {
         _id: '600d395667536503354cbe91',
         _id: '600d395667536503354cbe91',
-        path: parentForDuplicate.path,
+        pageId: parentForDuplicate._id,
         body: 'duplicateBody',
         body: 'duplicateBody',
       },
       },
       {
       {
         _id: '600d395667536503354cbe92',
         _id: '600d395667536503354cbe92',
-        path: childForDuplicate.path,
+        pageId: childForDuplicate._id,
         body: 'duplicateChildBody',
         body: 'duplicateChildBody',
       },
       },
     ]);
     ]);
@@ -347,7 +347,7 @@ describe('PageService', () => {
 
 
         const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
         const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename1' });
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename1' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1' });
+        const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -366,7 +366,7 @@ describe('PageService', () => {
 
 
         const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
         const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename2' });
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename2' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2' });
+        const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -384,8 +384,8 @@ describe('PageService', () => {
       test('rename page with createRedirectPage option', async() => {
       test('rename page with createRedirectPage option', async() => {
 
 
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
-        // const redirectedFromPage = await Page.findOne({ path: '/parentForRename3' });
-        // const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3' });
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename3' });
+        const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -399,16 +399,16 @@ describe('PageService', () => {
         // expect(redirectedFromPage.path).toBe('/parentForRename3');
         // expect(redirectedFromPage.path).toBe('/parentForRename3');
         // expect(redirectedFromPage.redirectTo).toBe('/renamed3');
         // expect(redirectedFromPage.redirectTo).toBe('/renamed3');
 
 
-        // expect(redirectedFromPageRevision).not.toBeNull();
-        // expect(redirectedFromPageRevision.path).toBe('/parentForRename3');
-        // expect(redirectedFromPageRevision.body).toBe('redirect /renamed3');
+        expect(redirectedFromPageRevision).not.toBeNull();
+        expect(redirectedFromPageRevision.pageId).toBe(redirectedFromPage._id);
+        expect(redirectedFromPageRevision.body).toBe('redirect /renamed3');
       });
       });
 
 
       test('rename page with isRecursively', async() => {
       test('rename page with isRecursively', async() => {
 
 
         const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
         const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename4' });
         const redirectedFromPage = await Page.findOne({ path: '/parentForRename4' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename4' });
+        const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -441,7 +441,7 @@ describe('PageService', () => {
       await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
       await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed1/child' });
       const resultPage = await Page.findOne({ path: '/renamed1/child' });
       const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
       const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(resultPage).not.toBeNull();
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
@@ -461,7 +461,7 @@ describe('PageService', () => {
       await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
       await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed2/child' });
       const resultPage = await Page.findOne({ path: '/renamed2/child' });
       const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
       const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(resultPage).not.toBeNull();
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
@@ -480,8 +480,8 @@ describe('PageService', () => {
 
 
       await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
       await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed3/child' });
       const resultPage = await Page.findOne({ path: '/renamed3/child' });
-      // const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
-      // const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(resultPage).not.toBeNull();
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
@@ -494,9 +494,9 @@ describe('PageService', () => {
       // expect(redirectedFromPage.path).toBe('/parentForRename3/child');
       // expect(redirectedFromPage.path).toBe('/parentForRename3/child');
       // expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
       // expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
 
 
-      // expect(redirectedFromPageRevision).not.toBeNull();
-      // expect(redirectedFromPageRevision.path).toBe('/parentForRename3/child');
-      // expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.pageId).toBe(redirectedFromPage._id);
+      expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
     });
     });
   });
   });
 
 
@@ -519,7 +519,7 @@ describe('PageService', () => {
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
 
 
       const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
       const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
-      const duplicatedToPageRevision = await Revision.findOne({ path: '/newParentDuplicate' });
+      const duplicatedToPageRevision = await Revision.findOne({ pageId: resultPage._id });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -539,7 +539,7 @@ describe('PageService', () => {
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
 
 
       const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
       const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
-      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ path: '/newParentDuplicateRecursively' });
+      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ pageId: resultPageRecursivly._id });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -557,14 +557,14 @@ describe('PageService', () => {
 
 
       const childForDuplicateRevision = await Revision.findOne({ path: childForDuplicate.path });
       const childForDuplicateRevision = await Revision.findOne({ path: childForDuplicate.path });
       const insertedPage = await Page.findOne({ path: '/newPathPrefix/child' });
       const insertedPage = await Page.findOne({ path: '/newPathPrefix/child' });
-      const insertedRevision = await Revision.findOne({ path: '/newPathPrefix/child' });
+      const insertedRevision = await Revision.findOne({ pageId: insertedPage._id });
 
 
       expect(insertedPage).not.toBeNull();
       expect(insertedPage).not.toBeNull();
       expect(insertedPage.path).toEqual('/newPathPrefix/child');
       expect(insertedPage.path).toEqual('/newPathPrefix/child');
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
 
 
       expect([insertedRevision]).not.toBeNull();
       expect([insertedRevision]).not.toBeNull();
-      expect(insertedRevision.path).toEqual('/newPathPrefix/child');
+      expect(insertedRevision.pageId).toEqual(insertedPage._id);
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
 
 
@@ -597,8 +597,8 @@ describe('PageService', () => {
 
 
     test('delete page without options', async() => {
     test('delete page without options', async() => {
       const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
       const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
-      // const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
-      // const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete1' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -614,9 +614,9 @@ describe('PageService', () => {
       // expect(redirectedFromPage.path).toBe('/parentForDelete1');
       // expect(redirectedFromPage.path).toBe('/parentForDelete1');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
 
 
-      // expect(redirectedFromPageRevision).not.toBeNull();
-      // expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
-      // expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.pageId).toBe(redirectedFromPage._id);
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
 
 
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
@@ -625,8 +625,8 @@ describe('PageService', () => {
 
 
     test('delete page with isRecursively', async() => {
     test('delete page with isRecursively', async() => {
       const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
       const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
-      // const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
-      // const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete2' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -642,9 +642,9 @@ describe('PageService', () => {
       // expect(redirectedFromPage.path).toBe('/parentForDelete2');
       // expect(redirectedFromPage.path).toBe('/parentForDelete2');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
 
 
-      // expect(redirectedFromPageRevision).not.toBeNull();
-      // expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
-      // expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.pageId).toBe(redirectedFromPage._id);
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
 
 
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
@@ -655,8 +655,8 @@ describe('PageService', () => {
     test('deleteDescendants', async() => {
     test('deleteDescendants', async() => {
       await crowi.pageService.deleteDescendants([childForDelete], testUser2);
       await crowi.pageService.deleteDescendants([childForDelete], testUser2);
       const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
       const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
-      // const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
-      // const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ pageId: redirectedFromPage._id });
 
 
       expect(resultPage.status).toBe(Page.STATUS_DELETED);
       expect(resultPage.status).toBe(Page.STATUS_DELETED);
       expect(resultPage.path).toBe('/trash/parentForDelete/child');
       expect(resultPage.path).toBe('/trash/parentForDelete/child');
@@ -669,9 +669,9 @@ describe('PageService', () => {
       // expect(redirectedFromPage.path).toBe('/parentForDelete/child');
       // expect(redirectedFromPage.path).toBe('/parentForDelete/child');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
       // expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
 
 
-      // expect(redirectedFromPageRevision).not.toBeNull();
-      // expect(redirectedFromPageRevision.path).toBe('/parentForDelete/child');
-      // expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.pageId).toBe(redirectedFromPage._id);
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
     });
     });
   });
   });
 
 
@@ -800,7 +800,7 @@ describe('PageService', () => {
       await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
       await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
       const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
       const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
       const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
       const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
-      const revrtedFromPageRevision = await Revision.findOne({ path: '/trash/parentForRevert/child' });
+      const revrtedFromPageRevision = await Revision.findOne({ pageId: revrtedFromPage._id });
 
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
       // expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });
       // expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });

+ 2 - 2
packages/app/src/test/integration/service/v5-migration.test.js

@@ -16,7 +16,7 @@ describe('V5 page migration', () => {
   });
   });
 
 
 
 
-  describe('v5MigrationByPageIds()', () => {
+  describe('normalizeParentRecursivelyByPageIds()', () => {
     test('should migrate all pages specified by pageIds', async() => {
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
       jest.restoreAllMocks();
 
 
@@ -50,7 +50,7 @@ describe('V5 page migration', () => {
 
 
       const pageIds = pages.map(page => page._id);
       const pageIds = pages.map(page => page._id);
       // migrate
       // migrate
-      await crowi.pageService.v5MigrationByPageIds(pageIds);
+      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
 
 
       const migratedPages = await Page.find({
       const migratedPages = await Page.find({
         path: {
         path: {