فهرست منبع

Merge pull request #8440 from weseek/imprv/create-page-with-grant-by-parent-page

imprv: Typescriptize PageTagRelation model and some routing actions
Yuki Takei 2 سال پیش
والد
کامیت
f5607f1e4a
42فایلهای تغییر یافته به همراه685 افزوده شده و 779 حذف شده
  1. 36 2
      apps/app/src/client/services/use-create-page-and-transit.tsx
  2. 1 1
      apps/app/src/client/services/use-on-template-button-clicked.ts
  3. 2 3
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  4. 11 14
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  5. 3 2
      apps/app/src/components/PageTags/PageTags.tsx
  6. 1 1
      apps/app/src/components/Skeleton.tsx
  7. 1 0
      apps/app/src/interfaces/page-tag-relation.ts
  8. 6 2
      apps/app/src/server/crowi/index.js
  9. 1 1
      apps/app/src/server/events/activity.ts
  10. 6 3
      apps/app/src/server/models/GlobalNotificationSetting.ts
  11. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  12. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  13. 19 0
      apps/app/src/server/models/GlobalNotificationSetting/consts.ts
  14. 5 1
      apps/app/src/server/models/index.ts
  15. 0 180
      apps/app/src/server/models/page-tag-relation.js
  16. 208 0
      apps/app/src/server/models/page-tag-relation.ts
  17. 7 4
      apps/app/src/server/models/page.ts
  18. 3 4
      apps/app/src/server/models/tag.ts
  19. 1 1
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  20. 7 6
      apps/app/src/server/routes/apiv3/notification-setting.js
  21. 4 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  22. 234 0
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  23. 8 8
      apps/app/src/server/routes/apiv3/page/index.js
  24. 19 194
      apps/app/src/server/routes/apiv3/pages/index.js
  25. 2 1
      apps/app/src/server/routes/comment.js
  26. 0 2
      apps/app/src/server/routes/index.js
  27. 5 243
      apps/app/src/server/routes/page.js
  28. 3 3
      apps/app/src/server/routes/tag.js
  29. 10 10
      apps/app/src/server/service/global-notification/global-notification-mail.js
  30. 15 17
      apps/app/src/server/service/global-notification/global-notification-slack.js
  31. 2 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  32. 6 25
      apps/app/src/server/service/page/index.ts
  33. 11 1
      apps/app/src/server/service/page/page-service.ts
  34. 6 7
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  35. 2 3
      apps/app/src/server/service/user-notification/index.ts
  36. 10 3
      apps/app/src/stores/page.tsx
  37. 0 2
      apps/app/test/integration/models/v5.page.test.js
  38. 1 2
      apps/app/test/integration/service/page.test.js
  39. 6 6
      apps/app/test/integration/service/v5.non-public-page.test.ts
  40. 0 2
      apps/app/test/integration/service/v5.page.test.ts
  41. 10 11
      apps/app/test/integration/service/v5.public-page.test.ts
  42. 3 3
      apps/app/test/integration/setup-crowi.ts

+ 36 - 2
apps/app/src/components/Navbar/hooks.tsx → apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import { useRouter } from 'next/router';
 
 import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound } from '~/stores/page';
+import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -46,14 +46,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
+  const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { mutate: mutateEditorMode } = useEditorMode();
 
+  // const {
+  //   path: currentPagePath,
+  //   grant: currentPageGrant,
+  //   grantedGroups: currentPageGrantedGroups,
+  // } = currentPage ?? {};
+
   return useCallback(async(pagePath, opts = {}) => {
+    if (isLoading) {
+      return;
+    }
+
     const {
       onCreationStart, onCreated, onAborted, onError, onTerminated,
     } = opts;
 
     if (isNotFound == null || !isNotFound || pagePath == null) {
+      mutateEditorMode(EditorMode.Editor);
+
       onAborted?.();
       onTerminated?.();
       return;
@@ -62,6 +75,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
     try {
       onCreationStart?.();
 
+      /**
+       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
+       * since the new page path is not generated on the client side.
+       * need shouldGeneratePath flag.
+       */
+      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
+      // const parentPath = shouldCreateUnderRoot
+      //   ? '/'
+      //   : currentPagePath;
+
+      // const params = {
+      //   isSlackEnabled: false,
+      //   slackChannels: '',
+      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
+      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
+      //   shouldGeneratePath: true,
+      // };
+
+      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
+      // const response = await createPage(parentPath, '', params);
+
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
@@ -85,5 +119,5 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
       onTerminated?.();
     }
 
-  }, [isNotFound, mutateEditorMode, router]);
+  }, [isLoading, isNotFound, mutateEditorMode, router]);
 };

+ 1 - 1
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -4,7 +4,7 @@ import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 export const useOnTemplateButtonClicked = (
     currentPagePath?: string,

+ 2 - 3
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { toastError } from '~/client/util/toastr';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from './hooks';
+import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -76,12 +76,11 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
       path,
       {
         onCreationStart: () => { setIsCreating(true) },
-        onAborted: () => { mutateEditorMode(EditorMode.Editor) },
         onError: () => { toastError(t('toaster.create_failed', { target: path })) },
         onTerminated: () => { setIsCreating(false) },
       },
     );
-  }, [createPageAndTransit, path, mutateEditorMode, t]);
+  }, [createPageAndTransit, path, t]);
 
   return (
     <>

+ 11 - 14
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { Suspense, useCallback } from 'react';
 
 import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -37,7 +37,7 @@ type TagsProps = {
 const Tags = (props: TagsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
-  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId, { suspense: true });
 
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
@@ -51,7 +51,7 @@ const Tags = (props: TagsProps): JSX.Element => {
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
   }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
-  if (!showTagLabel) {
+  if (!showTagLabel || tagsInfoData == null) {
     return <></>;
   }
 
@@ -59,16 +59,11 @@ const Tags = (props: TagsProps): JSX.Element => {
 
   return (
     <div className="grw-taglabels-container">
-      { tagsInfoData?.tags != null
-        ? (
-          <PageTags
-            tags={tagsInfoData.tags}
-            isTagLabelsDisabled={isTagLabelsDisabled}
-            onClickEditTagsButton={onClickEditTagsButton}
-          />
-        )
-        : <PageTagsSkeleton />
-      }
+      <PageTags
+        tags={tagsInfoData.tags}
+        isTagLabelsDisabled={isTagLabelsDisabled}
+        onClickEditTagsButton={onClickEditTagsButton}
+      />
     </div>
   );
 };
@@ -97,7 +92,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Tags */}
-      <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      <Suspense fallback={<PageTagsSkeleton />}>
+        <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      </Suspense>
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}

+ 3 - 2
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { Skeleton } from '../Skeleton';
 
@@ -23,7 +24,7 @@ export const PageTags:FC<Props> = (props: Props) => {
   } = props;
 
   if (tags == null) {
-    return <PageTagsSkeleton />;
+    return <></>;
   }
 
   const printNoneClass = tags.length === 0 ? 'd-print-none' : '';

+ 1 - 1
apps/app/src/components/Skeleton.tsx

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );
 };

+ 1 - 0
apps/app/src/interfaces/page-tag-relation.ts

@@ -3,4 +3,5 @@ import type { IPage, ITag } from '@growi/core';
 export type IPageTagRelation = {
   relatedPage: IPage,
   relatedTag: ITag,
+  isPageTrashed: boolean,
 }

+ 6 - 2
apps/app/src/server/crowi/index.js

@@ -50,6 +50,12 @@ class Crowi {
   /** @type {AppService} */
   appService;
 
+  /** @type {import('../service/page').IPageService} */
+  pageService;
+
+  /** @type UserNotificationService */
+  userNotificationService;
+
   /** @type {FileUploader} */
   fileUploadService;
 
@@ -74,7 +80,6 @@ class Crowi {
     this.mailService = null;
     this.passportService = null;
     this.globalNotificationService = null;
-    this.userNotificationService = null;
     this.xssService = null;
     this.aclService = null;
     this.appService = null;
@@ -86,7 +91,6 @@ class Crowi {
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
-    this.pageService = null;
     this.syncPageStatusService = null;
     this.cdnResourcesService = new CdnResourcesService();
     this.slackIntegrationService = null;

+ 1 - 1
apps/app/src/server/events/activity.ts

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../crowi';
+import type Crowi from '../crowi';
 
 const logger = loggerFactory('growi:events:activity');
 

+ 6 - 3
apps/app/src/server/models/GlobalNotificationSetting.js → apps/app/src/server/models/GlobalNotificationSetting.ts

@@ -2,6 +2,7 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
+
 const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +11,7 @@ const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 /**
  * global notifcation event master
  */
-GlobalNotificationSettingSchema.statics.EVENT = {
+export const GlobalNotificationSettingEvent = {
   PAGE_CREATE: 'pageCreate',
   PAGE_EDIT: 'pageEdit',
   PAGE_DELETE: 'pageDelete',
@@ -22,13 +23,15 @@ GlobalNotificationSettingSchema.statics.EVENT = {
 /**
  * global notifcation type master
  */
-GlobalNotificationSettingSchema.statics.TYPE = {
+export const GlobalNotificationSettingType = {
   MAIL: 'mail',
   SLACK: 'slack',
 };
 
-module.exports = function(crowi) {
+const factory = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
   return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
 };
+
+export default factory;

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    GlobalNotificationSettingType.MAIL,
     new mongoose.Schema({
       toEmail: String,
     }, {

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    GlobalNotificationSettingType.SLACK,
     new mongoose.Schema({
       slackChannels: String,
     }, {

+ 19 - 0
apps/app/src/server/models/GlobalNotificationSetting/consts.ts

@@ -0,0 +1,19 @@
+/**
+ * global notifcation event master
+ */
+export const GlobalNotificationSettingEvent = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+export const GlobalNotificationSettingEventType = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};

+ 5 - 1
apps/app/src/server/models/index.js → apps/app/src/server/models/index.ts

@@ -1,3 +1,4 @@
+import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
 import Page from './page';
 
 export const modelsDependsOnCrowi = {
@@ -6,7 +7,7 @@ export const modelsDependsOnCrowi = {
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
@@ -19,5 +20,8 @@ export * as PageRedirect from './page-redirect';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';
+export * as PageTagRelation from './page-tag-relation';
 
 export * from './serializers';
+
+export * from './GlobalNotificationSetting';

+ 0 - 180
apps/app/src/server/models/page-tag-relation.js

@@ -1,180 +0,0 @@
-import Tag from './tag';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const flatMap = require('array.prototype.flatmap');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedPage: {
-    type: ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
-  },
-  relatedTag: {
-    type: ObjectId,
-    ref: 'Tag',
-    required: true,
-    index: true,
-  },
-  isPageTrashed: {
-    type: Boolean,
-    default: false,
-    required: true,
-    index: true,
-  },
-});
-// define unique compound index
-schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * PageTagRelation Class
- *
- * @class PageTagRelation
- */
-class PageTagRelation {
-
-  static async createTagListWithCount(option) {
-    const opt = option || {};
-    const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset;
-    const limit = opt.limit;
-
-    const tags = await this.aggregate()
-      .match({ isPageTrashed: false })
-      .lookup({
-        from: 'tags',
-        localField: 'relatedTag',
-        foreignField: '_id',
-        as: 'tag',
-      })
-      .unwind('$tag')
-      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
-      .sort(sortOpt)
-      .skip(offset)
-      .limit(limit);
-
-    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
-
-    return { data: tags, totalCount };
-  }
-
-  static async findByPageId(pageId, options = {}) {
-    const isAcceptRelatedTagNull = options.nullable || null;
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
-    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
-  }
-
-  static async listTagNamesByPage(pageId) {
-    const relations = await this.findByPageId(pageId);
-    return relations.map((relation) => { return relation.relatedTag.name });
-  }
-
-  /**
-   * @return {object} key: Page._id, value: array of tag names
-   */
-  static async getIdToTagNamesMap(pageIds) {
-    /**
-     * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
-     *
-     * results will be:
-     * [
-     *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
-     *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
-     *   ...
-     * ]
-     */
-    const results = await this.aggregate()
-      .match({ relatedPage: { $in: pageIds } })
-      .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
-
-    if (results.length === 0) {
-      return {};
-    }
-
-    results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
-
-    // extract distinct tag ids
-    const allTagIds = results
-      .flatMap(result => result.tagIds); // map + flatten
-    const distinctTagIds = Array.from(new Set(allTagIds));
-
-    // TODO: set IdToNameMap type by 93933
-    const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
-
-    // convert to map
-    const idToTagNamesMap = {};
-    results.forEach((result) => {
-      const tagNames = result.tagIds
-        .map(tagId => tagIdToNameMap[tagId])
-        .filter(tagName => tagName != null); // filter null object
-
-      idToTagNamesMap[result._id] = tagNames;
-    });
-
-    return idToTagNamesMap;
-  }
-
-  static async updatePageTags(pageId, tags) {
-    if (pageId == null || tags == null) {
-      throw new Error('args \'pageId\' and \'tags\' are required.');
-    }
-
-    // filter empty string
-    // eslint-disable-next-line no-param-reassign
-    tags = tags.filter((tag) => { return tag !== '' });
-
-    // get relations for this page
-    const relations = await this.findByPageId(pageId, { nullable: true });
-
-    const unlinkTagRelationIds = [];
-    const relatedTagNames = [];
-
-    relations.forEach((relation) => {
-      if (relation.relatedTag == null) {
-        unlinkTagRelationIds.push(relation._id);
-      }
-      else {
-        relatedTagNames.push(relation.relatedTag.name);
-        if (!tags.includes(relation.relatedTag.name)) {
-          unlinkTagRelationIds.push(relation._id);
-        }
-      }
-    });
-    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
-    // find or create tags
-    const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
-    const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
-
-    // create relations
-    const bulkCreatePromise = this.insertMany(
-      tagEntities.map((relatedTag) => {
-        return {
-          relatedPage: pageId,
-          relatedTag,
-        };
-      }),
-    );
-
-    return Promise.all([bulkDeletePromise, bulkCreatePromise]);
-  }
-
-}
-
-module.exports = function() {
-  schema.loadClass(PageTagRelation);
-  const model = mongoose.model('PageTagRelation', schema);
-  return model;
-};

+ 208 - 0
apps/app/src/server/models/page-tag-relation.ts

@@ -0,0 +1,208 @@
+import type { ITag } from '@growi/core';
+import type { Document, Model } from 'mongoose';
+import mongoose, { ObjectId } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
+
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { IdToNameMap } from './tag';
+import Tag from './tag';
+
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+
+const flatMap = require('array.prototype.flatmap');
+
+
+export interface PageTagRelationDocument extends IPageTagRelation, Document {
+}
+
+type CreateTagListWithCountOpts = {
+  sortOpt?: any,
+  offset?: number,
+  limit?: number,
+}
+type CreateTagListWithCountResult = {
+  data: ITag[],
+  totalCount: number
+}
+type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
+
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+
+type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
+
+export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
+  createTagListWithCount: CreateTagListWithCount
+  findByPageId(pageId: string, options?: { nullable?: boolean }): Promise<PageTagRelationDocument[]>
+  listTagNamesByPage(pageId: string): Promise<PageTagRelationDocument[]>
+  getIdToTagNamesMap: GetIdToTagNamesMap
+  updatePageTags: UpdatePageTags
+}
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  relatedTag: {
+    type: ObjectId,
+    ref: 'Tag',
+    required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
+  },
+});
+// define unique compound index
+schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+const createTagListWithCount: CreateTagListWithCount = async function(this, opts) {
+  const sortOpt = opts?.sortOpt || {};
+  const offset = opts?.offset ?? 0;
+  const limit = opts?.limit;
+
+  let query = this.aggregate()
+    .match({ isPageTrashed: false })
+    .lookup({
+      from: 'tags',
+      localField: 'relatedTag',
+      foreignField: '_id',
+      as: 'tag',
+    })
+    .unwind('$tag')
+    .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+    .sort(sortOpt)
+    .skip(offset);
+
+  if (limit != null) {
+    query = query.limit(limit);
+  }
+
+  const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+  return { data: await query.exec(), totalCount };
+};
+schema.statics.createTagListWithCount = createTagListWithCount;
+
+schema.statics.findByPageId = async function(pageId, options = {}) {
+  const isAcceptRelatedTagNull = options.nullable || null;
+  const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+  return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
+};
+
+schema.statics.listTagNamesByPage = async function(pageId) {
+  const relations = await this.findByPageId(pageId);
+  return relations.map((relation) => { return relation.relatedTag.name });
+};
+
+
+const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
+  /**
+   * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
+   *
+   * results will be:
+   * [
+   *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
+   *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
+   *   ...
+   * ]
+   */
+  const results = await this.aggregate<{ _id: ObjectId, tagIds: ObjectIdLike[] }>()
+    .match({ relatedPage: { $in: pageIds } })
+    .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
+
+  if (results.length === 0) {
+    return {};
+  }
+
+  results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
+
+  // extract distinct tag ids
+  const allTagIds = results
+    .flatMap(result => result.tagIds); // map + flatten
+  const distinctTagIds = Array.from(new Set(allTagIds));
+
+  // TODO: set IdToNameMap type by 93933
+  const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
+
+  // convert to map
+  const idToTagNamesMap = {};
+  results.forEach((result) => {
+    const tagNames = result.tagIds
+      .map(tagId => tagIdToNameMap[tagId.toString()])
+      .filter(tagName => tagName != null); // filter null object
+
+    idToTagNamesMap[result._id.toString()] = tagNames;
+  });
+
+  return idToTagNamesMap;
+};
+schema.statics.getIdToTagNamesMap = getIdToTagNamesMap;
+
+const updatePageTags: UpdatePageTags = async function(pageId, tags) {
+  if (pageId == null || tags == null) {
+    throw new Error('args \'pageId\' and \'tags\' are required.');
+  }
+
+  // filter empty string
+  // eslint-disable-next-line no-param-reassign
+  tags = tags.filter((tag) => { return tag !== '' });
+
+  // get relations for this page
+  const relations = await this.findByPageId(pageId, { nullable: true });
+
+  const unlinkTagRelationIds: string[] = [];
+  const relatedTagNames: string[] = [];
+
+  relations.forEach((relation) => {
+    if (relation.relatedTag == null) {
+      unlinkTagRelationIds.push(relation._id);
+    }
+    else {
+      relatedTagNames.push(relation.relatedTag.name);
+      if (!tags.includes(relation.relatedTag.name)) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+    }
+  });
+  const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
+  // find or create tags
+  const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+  const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
+
+  // create relations
+  const bulkCreatePromise = this.insertMany(
+    tagEntities.map((relatedTag) => {
+      return {
+        relatedPage: pageId,
+        relatedTag,
+      };
+    }),
+  );
+
+  await Promise.all([bulkDeletePromise, bulkCreatePromise]);
+};
+schema.statics.updatePageTags = updatePageTags;
+
+export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>('PageTagRelation', schema);

+ 7 - 4
apps/app/src/server/models/page.ts

@@ -8,26 +8,25 @@ import {
   type IGrantedGroup,
   GroupType, type HasObjectId,
 } from '@growi/core';
+import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
+import type { Model, Document, AnyObject } from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, AnyObject,
+  Schema,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -76,6 +75,10 @@ export interface PageModel extends Model<PageDocument> {
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
+  findTemplate(path: string): Promise<{
+    templateBody?: string,
+    templateTags?: string[],
+  }>
 
   PageQueryBuilder: typeof PageQueryBuilder
 

+ 3 - 4
apps/app/src/server/models/tag.ts

@@ -1,8 +1,7 @@
-import {
-  Types, Model, Schema,
-} from 'mongoose';
+import type { Types, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const mongoosePaginate = require('mongoose-paginate-v2');

+ 1 - 1
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,4 +1,4 @@
-import { Response } from 'express';
+import type { Response } from 'express';
 
 export interface ApiV3Response extends Response {
   apiv3(obj?: any, status?: number): any

+ 7 - 6
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GlobalNotificationSettingType } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -283,11 +284,11 @@ module.exports = (crowi) => {
 
     let notification;
 
-    if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+    if (notifyType === GlobalNotificationSettingType.MAIL) {
       notification = new GlobalNotificationMailSetting(crowi);
       notification.toEmail = toEmail;
     }
-    if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+    if (notifyType === GlobalNotificationSettingType.SLACK) {
       notification = new GlobalNotificationSlackSetting(crowi);
       notification.slackChannels = slackChannels;
     }
@@ -350,8 +351,8 @@ module.exports = (crowi) => {
     } = req.body;
 
     const models = {
-      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
-      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+      [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
     };
 
     try {
@@ -368,11 +369,11 @@ module.exports = (crowi) => {
         setting = setting.toObject();
       }
 
-      if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+      if (notifyType === GlobalNotificationSettingType.MAIL) {
         setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = toEmail;
       }
-      if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+      if (notifyType === GlobalNotificationSettingType.SLACK) {
         setting = GlobalNotificationSlackSetting.hydrate(setting);
         setting.slackChannels = slackChannels;
       }

+ 4 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
+  IPageInfoForListing, IPageInfo,
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -14,7 +14,6 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -76,7 +75,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     try {
       const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
@@ -95,7 +94,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
@@ -119,7 +118,7 @@ const routerFactory = (crowi: Crowi): Router => {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 

+ 234 - 0
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -0,0 +1,234 @@
+import type {
+  IGrantedGroup,
+  IPage, IUser, IUserHasId, PageGrant,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUniquePath(basePath: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = basePath + index;
+  const existingPageId = await Page.exists({ path, isEmpty: false });
+  if (existingPageId != null) {
+    return generateUniquePath(basePath, index + 1);
+  }
+  return path;
+}
+
+type ReqBody = {
+  path: string,
+
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+
+  body?: string,
+  overwriteScopesOfDescendants?: boolean,
+  isSlackEnabled?: boolean,
+  slackChannels?: any,
+  pageTags?: string[],
+  shouldGeneratePath?: boolean,
+}
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+
+  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  const validator: ValidationChain[] = [
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('path').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage('path is required'),
+    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
+      } = req.body;
+
+      let { path, grant, grantUserGroupIds } = req.body;
+
+      // check whether path starts slash
+      path = addHeadingSlash(path);
+
+      if (shouldGeneratePath) {
+        try {
+          const rootPath = '/';
+          const defaultTitle = '/Untitled';
+          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
+          path = await generateUniquePath(basePath);
+
+          // if the generated path is not creatable, create the path under the root path
+          if (!isCreatablePage(path)) {
+            path = await generateUniquePath(defaultTitle);
+            // initialize grant data
+            grant = 1;
+            grantUserGroupIds = undefined;
+          }
+        }
+        catch (err) {
+          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
+        }
+      }
+
+      if (!isCreatablePage(path)) {
+        return res.apiv3Err(`Could not use the path '${path}'`);
+      }
+
+      if (isUserPage(path)) {
+        const isExistUser = await User.isExistUserByUserPagePath(path);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+      if (grant != null) {
+        options.grant = grant;
+        options.grantUserGroupIds = grantUserGroupIds;
+      }
+
+      const isNoBodyPage = body === undefined;
+      let initialTags: string[] = [];
+      let initialBody = '';
+      if (isNoBodyPage) {
+        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+        if (isEnabledAttachTitleHeader) {
+          initialBody += `${attachTitleHeader(path)}\n`;
+        }
+
+        const templateData = await Page.findTemplate(path);
+        if (templateData.templateTags != null) {
+          initialTags = templateData.templateTags;
+        }
+        if (templateData.templateBody != null) {
+          initialBody += `${templateData.templateBody}\n`;
+        }
+      }
+
+      let createdPage;
+      try {
+        createdPage = await crowi.pageService.create(
+          path,
+          body ?? initialBody,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: createdPage,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      res.apiv3(result, 201);
+
+      try {
+      // global notification
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+
+      // user notification
+      if (isSlackEnabled) {
+        try {
+          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+          results.forEach((result) => {
+            if (result.status === 'rejected') {
+              logger.error('Create user notification failed', result.reason);
+            }
+          });
+        }
+        catch (err) {
+          logger.error('Create user notification failed', err);
+        }
+      }
+
+      // create subscription
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
+    },
+  ];
+};

+ 8 - 8
apps/app/src/server/routes/apiv3/page.js → apps/app/src/server/routes/apiv3/page/index.js

@@ -12,8 +12,10 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { GlobalNotificationSettingEvent } from '~/server/models';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
@@ -166,16 +168,14 @@ const router = express.Router();
  *            example: 5e07345972560e001761fa63
  */
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const configManager = crowi.configManager;
-
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const activityEvent = crowi.event('activity');
@@ -372,7 +372,7 @@ module.exports = (crowi) => {
     if (isLiked) {
       try {
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
       }
       catch (err) {
         logger.error('Like notification failed', err);

+ 19 - 194
apps/app/src/server/routes/apiv3/pages.js → apps/app/src/server/routes/apiv3/pages/index.js

@@ -2,25 +2,27 @@
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { GlobalNotificationSettingEvent } from '~/server/models';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
-import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import { serializePageSecurely } from '../../../models/serializers/page-serializer';
+import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
+import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
+import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
-const { body } = require('express-validator');
-const { query } = require('express-validator');
-const mongoose = require('mongoose');
-
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -144,40 +146,21 @@ const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  */
 
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const activityEvent = crowi.event('activity');
 
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-  const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const validator = {
-    createPage: [
-      body('body').optional().isString()
-        .withMessage('body must be string or undefined'),
-      body('path').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('path is required'),
-      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-      body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-    ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -222,33 +205,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  async function createPageAction({
-    path, body, user, options,
-  }) {
-    const createdPage = await crowi.pageService.create(path, body, user, options);
-    return createdPage;
-  }
-
-  async function saveTagsAction({ createdPage, pageTags }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  async function generateUniquePath(basePath, index = 1) {
-    const path = basePath + index;
-    const existingPageId = await Page.exists({ path, isEmpty: false });
-    if (existingPageId != null) {
-      return generateUniquePath(basePath, index + 1);
-    }
-    return path;
-  }
-
   /**
    * @swagger
    *
@@ -304,137 +260,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
-    const {
-      // body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-    } = req.body;
-
-    let { path, grant, grantUserGroupIds } = req.body;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    path = addHeadingSlash(path);
-
-    if (shouldGeneratePath) {
-      try {
-        const rootPath = '/';
-        const defaultTitle = '/Untitled';
-        const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-        path = await generateUniquePath(basePath);
-
-        // if the generated path is not creatable, create the path under the root path
-        if (!isCreatablePage(path)) {
-          path = await generateUniquePath(defaultTitle);
-          // initialize grant data
-          grant = 1;
-          grantUserGroupIds = undefined;
-        }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-      }
-    }
-
-    if (!isCreatablePage(path)) {
-      return res.apiv3Err(`Could not use the path '${path}'`);
-    }
-
-    if (isUserPage(path)) {
-      const isExistUser = await User.isExistUserByUserPagePath(path);
-      if (!isExistUser) {
-        return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-      }
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const isNoBodyPage = body === undefined;
-    let initialTags = [];
-    let initialBody = '';
-    if (isNoBodyPage) {
-      const isEnabledAttachTitleHeader = await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-      if (isEnabledAttachTitleHeader) {
-        initialBody += `${attachTitleHeader(path)}\n`;
-      }
-
-      const templateData = await Page.findTemplate(path);
-      if (templateData?.templateTags != null) {
-        initialTags = templateData.templateTags;
-      }
-      if (templateData?.templateBody != null) {
-        initialBody += `${templateData.templateBody}\n`;
-      }
-    }
-
-    let createdPage;
-    try {
-      createdPage = await createPageAction({
-        path, body: isNoBodyPage ? initialBody : body, user: req.user, options,
-      });
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.', err);
-      return res.apiv3Err(err);
-    }
-
-    const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : pageTags });
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      tags: savedTags,
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: createdPage,
-      action: SupportedAction.ACTION_PAGE_CREATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    res.apiv3(result, 201);
-
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    // create subscription
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
-  });
-
+  router.post('/', createPageHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -471,7 +297,6 @@ module.exports = (crowi) => {
         }
       });
 
-      const PageTagRelation = mongoose.model('PageTagRelation');
       const ids = result.pages.map((page) => { return page._id });
       const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
 
@@ -622,7 +447,7 @@ module.exports = (crowi) => {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
         oldPath: page.path,
       });
     }
@@ -840,7 +665,7 @@ module.exports = (crowi) => {
       const copyPage = { ...page };
       copyPage.path = newPagePath;
       try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, copyPage, req.user);
       }
       catch (err) {
         logger.error('Create grobal notification failed', err);

+ 2 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,7 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -281,7 +282,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.COMMENT, page, req.user, {
         comment: createdComment,
       });
     }

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,7 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
@@ -130,7 +129,6 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
   apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);

+ 5 - 243
apps/app/src/server/routes/page.js

@@ -5,7 +5,9 @@ import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
+import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import { preNotifyService } from '../service/pre-notify';
 
@@ -137,12 +139,9 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
+  const { pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
@@ -221,171 +220,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.list:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: listPages
-   *        summary: /pages.list
-   *        description: Get list of pages
-   *        parameters:
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: user
-   *            schema:
-   *              $ref: '#/components/schemas/User/properties/username'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of pages.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                      description: page list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.list List pages by user
-   * @apiName ListPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   * @apiParam {String} user
-   */
-  api.list = async function(req, res) {
-    const username = req.query.user || null;
-    const path = req.query.path || null;
-    const limit = +req.query.limit || 50;
-    const offset = parseInt(req.query.offset) || 0;
-
-    const queryOptions = { offset, limit: limit + 1 };
-
-    // Accepts only one of these
-    if (username === null && path === null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-    if (username !== null && path !== null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-
-    try {
-      let result = null;
-      if (path == null) {
-        const user = await User.findUserByUsername(username);
-        if (user === null) {
-          throw new Error('The user not found.');
-        }
-        result = await Page.findListByCreator(user, req.user, queryOptions);
-      }
-      else {
-        result = await Page.findListByStartWith(path, req.user, queryOptions);
-      }
-
-      if (result.pages.length > limit) {
-        result.pages.pop();
-      }
-
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  // TODO If everything that depends on this route, delete it too
-  api.create = async function(req, res) {
-    const body = req.body.body || null;
-    let pagePath = req.body.path || null;
-    const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    if (body === null || pagePath === null) {
-      return res.json(ApiResponse.error('Parameters body and path are required.'));
-    }
-
-    // check whether path starts slash
-    pagePath = pathUtils.addHeadingSlash(pagePath);
-
-    // check page existence
-    const isExist = await Page.count({ path: pagePath }) > 0;
-    if (isExist) {
-      return res.json(ApiResponse.error('Page exists', 'already_exists'));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-  };
-
   /**
    * @swagger
    *
@@ -502,7 +336,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
     }
     catch (err) {
       logger.error('Edit notification failed', err);
@@ -812,7 +646,7 @@ module.exports = function(crowi, app) {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_DELETE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_DELETE, page, req.user);
     }
     catch (err) {
       logger.error('Delete notification failed', err);
@@ -867,78 +701,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.duplicate:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: duplicatePage
-   *        summary: /pages.duplicate
-   *        description: Duplicate page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  new_path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to duplicate page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    tags:
-   *                      $ref: '#/components/schemas/Tags'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.duplicate Duplicate page
-   * @apiName DuplicatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} new_path New path name.
-   */
-  api.duplicate = async function(req, res) {
-    const pageId = req.body.page_id;
-    let newPagePath = pathUtils.normalizePath(req.body.new_path);
-
-    const page = await Page.findByIdAndViewer(pageId, req.user);
-
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // check whether path starts slash
-    newPagePath = pathUtils.addHeadingSlash(newPagePath);
-
-    await page.populateDataToShowRevision();
-    const originTags = await page.findRelatedTagsById();
-
-    req.body.path = newPagePath;
-    req.body.body = page.revision.body;
-    req.body.grant = page.grant;
-    req.body.grantedUsers = page.grantedUsers;
-    req.body.grantUserGroupIds = page.grantedGroups;
-    req.body.pageTags = originTags;
-
-    return api.create(req, res);
-  };
-
   /**
    * @api {post} /pages.unlink Remove the redirecting page
    * @apiName UnlinkPage

+ 3 - 3
apps/app/src/server/routes/tag.js

@@ -1,6 +1,9 @@
 import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
+import PageTagRelation from '../models/page-tag-relation';
+import ApiResponse from '../util/apiResponse';
+
 /**
  * @swagger
  *
@@ -32,9 +35,7 @@ import Tag from '~/server/models/tag';
  */
 module.exports = function(crowi, app) {
 
-  const PageTagRelation = crowi.model('PageTagRelation');
   const activityEvent = crowi.event('activity');
-  const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
@@ -138,7 +139,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const PageTagRelation = crowi.model('PageTagRelation');
     const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;

+ 10 - 10
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,8 +1,10 @@
+import nodePath from 'path';
+
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
-const nodePath = require('path');
 
 /**
  * sub service class of GlobalNotificationSetting
@@ -11,8 +13,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
   /**
@@ -29,7 +29,7 @@ class GlobalNotificationMailService {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, GlobalNotificationSettingType.MAIL);
 
     const option = this.generateOption(event, page, triggeredBy, vars);
 
@@ -73,19 +73,19 @@ class GlobalNotificationMailService {
     };
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
@@ -99,11 +99,11 @@ class GlobalNotificationMailService {
         };
         break;
 
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);

+ 15 - 17
apps/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
-import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import loggerFactory from '~/utils/logger';
 
 import {
   prepareSlackMessageForGlobalNotification,
@@ -18,9 +19,6 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
 
@@ -39,7 +37,7 @@ class GlobalNotificationSlackService {
     const { appService, slackIntegrationService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, GlobalNotificationSettingType.SLACK);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
@@ -74,16 +72,16 @@ class GlobalNotificationSlackService {
     let messageBody;
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         messageBody = `:bell: ${username} created ${parmaLink}`;
         break;
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         messageBody = `:bell: ${username} edited ${parmaLink}`;
         break;
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         messageBody = `:bell: ${username} deleted ${pathLink}`;
         break;
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -91,10 +89,10 @@ class GlobalNotificationSlackService {
         // eslint-disable-next-line no-case-declarations
         messageBody = `:bell: ${username} moved ${oldPath} to ${parmaLink}`;
         break;
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         messageBody = `:bell: ${username} liked ${parmaLink}`;
         break;
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -128,17 +126,17 @@ class GlobalNotificationSlackService {
     // attachment body is intended for comment or page diff
 
     // switch (event) {
-    //   case this.event.PAGE_CREATE:
+    //   case GlobalNotificationSettingEvent.PAGE_CREATE:
     //     break;
-    //   case this.event.PAGE_EDIT:
+    //   case GlobalNotificationSettingEvent.PAGE_EDIT:
     //     break;
-    //   case this.event.PAGE_DELETE:
+    //   case GlobalNotificationSettingEvent.PAGE_DELETE:
     //     break;
-    //   case this.event.PAGE_MOVE:
+    //   case GlobalNotificationSettingEvent.PAGE_MOVE:
     //     break;
-    //   case this.event.PAGE_LIKE:
+    //   case GlobalNotificationSettingEvent.PAGE_LIKE:
     //     break;
-    //   case this.event.COMMENT:
+    //   case GlobalNotificationSettingEvent.COMMENT:
     //     break;
     //   default:
     //     throw new Error(`unknown global notificaiton event: ${event}`);

+ 2 - 2
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -90,12 +90,12 @@ describe('delete-completely-user-home-by-system test', () => {
     const mockPageEvent = mock<EventEmitter>();
     const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
 
-    const mockPageService: IPageService = {
+    const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
       deleteCompletelyOperation: mockDeleteCompletelyOperation,
       getEventEmitter: () => mockPageEvent,
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
-    };
+    });
 
     it('should call used page service functions', async() => {
       // when

+ 6 - 25
apps/app/src/server/service/page/index.ts

@@ -33,6 +33,8 @@ import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawDa
 import {
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
+import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
@@ -43,7 +45,7 @@ import { PathAlreadyExistsError } from '../../models/errors';
 import type { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
-import type { PageRedirectModel } from '../../models/page-redirect';
+import PageRedirect from '../../models/page-redirect';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
 import Subscription from '../../models/subscription';
@@ -642,7 +644,6 @@ class PageService implements IPageService {
 
     // create page redirect
     if (options.createRedirectPage) {
-      const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
     this.pageEvent.emit('rename');
@@ -827,7 +828,6 @@ class PageService implements IPageService {
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
     if (createRedirectPage) {
-      const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
 
@@ -843,7 +843,6 @@ class PageService implements IPageService {
     }
 
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const { updateMetadata, createRedirectPage } = options;
 
@@ -911,7 +910,6 @@ class PageService implements IPageService {
   }
 
   private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const pageCollection = mongoose.connection.collection('pages');
     const { updateMetadata, createRedirectPage } = options;
 
@@ -1069,7 +1067,6 @@ class PageService implements IPageService {
     }
 
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
     if (!isRecursively && page.isEmpty) {
       throw Error('Page not found.');
@@ -1146,7 +1143,7 @@ class PageService implements IPageService {
 
     // 4. Take over tags
     const originTags = await page.findRelatedTagsById();
-    let savedTags = [];
+    let savedTags: PageTagRelationDocument[] = [];
     if (originTags.length !== 0) {
       await PageTagRelation.updatePageTags(duplicatedTarget._id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(duplicatedTarget._id);
@@ -1240,7 +1237,6 @@ class PageService implements IPageService {
   }
 
   async duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources: boolean) {
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
 
@@ -1263,7 +1259,7 @@ class PageService implements IPageService {
 
     // take over tags
     const originTags = await page.findRelatedTagsById();
-    let savedTags = [];
+    let savedTags: PageTagRelationDocument[] = [];
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
@@ -1280,8 +1276,6 @@ class PageService implements IPageService {
    * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
    */
   private async duplicateTags(pageIdMapping) {
-    const PageTagRelation = mongoose.model('PageTagRelation');
-
     // convert pageId from string to ObjectId
     const pageIds = Object.keys(pageIdMapping);
     const stage = { $or: pageIds.map((pageId) => { return { relatedPage: new mongoose.Types.ObjectId(pageId) } }) };
@@ -1643,8 +1637,6 @@ class PageService implements IPageService {
 
   private async deleteNonEmptyTarget(page, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const newPath = Page.getDeletedPageName(page.path);
 
     const deletedPage = await Page.findByIdAndUpdate(page._id, {
@@ -1684,9 +1676,7 @@ class PageService implements IPageService {
 
   private async deletePageV4(page, user, options = {}, isRecursively = false) {
     const Page = mongoose.model('Page') as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -1728,7 +1718,6 @@ class PageService implements IPageService {
 
   private async deleteDescendants(pages, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const deletePageOperations: any[] = [];
     const insertPageRedirectOperations: any[] = [];
@@ -1851,9 +1840,7 @@ class PageService implements IPageService {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
@@ -2133,7 +2120,6 @@ class PageService implements IPageService {
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const revertPageOperations: any[] = [];
     const fromPathsToDelete: string[] = [];
@@ -2171,7 +2157,6 @@ class PageService implements IPageService {
      * Common Operation
      */
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
 
     const parameters = {
       ip: activityParameters.ip,
@@ -2331,7 +2316,6 @@ class PageService implements IPageService {
 
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const originPage = await Page.findByPath(newPath);
@@ -2569,7 +2553,7 @@ class PageService implements IPageService {
 
   }
 
-  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user): Promise<Record<string, string | null>> {
+  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const MAX_LENGTH = 350;
 
@@ -3851,9 +3835,6 @@ class PageService implements IPageService {
    * Used to run sub operation in create method
    */
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
 

+ 11 - 1
apps/app/src/server/service/page/page-service.ts

@@ -1,12 +1,22 @@
 import type EventEmitter from 'events';
 
-import type { IUser } from '@growi/core';
+import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type { ObjectId } from 'mongoose';
 
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument } from '~/server/models/page';
 
 export interface IPageService {
+  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
+  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }

+ 6 - 7
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -7,18 +7,18 @@ import streamToPromise from 'stream-to-promise';
 
 import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import {
-  ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
-} from '~/interfaces/search';
+import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SocketEventName } from '~/interfaces/websocket';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import loggerFactory from '~/utils/logger';
 
-import {
+import type {
   SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
-import { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -460,7 +460,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 

+ 2 - 3
apps/app/src/server/service/user-notification/index.ts

@@ -31,7 +31,7 @@ export class UserNotificationService {
    * @param {Comment} comment
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: string }, comment = {}): Promise<PromiseSettledResult<any>[]> {
     const {
       appService, slackIntegrationService,
     } = this.crowi;
@@ -43,8 +43,7 @@ export class UserNotificationService {
     // update slackChannels attribute asynchronously
     page.updateSlackChannels(slackChannelsStr);
 
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
+    const { previousRevision } = option ?? {};
 
     // "dev,slacktest" => [dev,slacktest]
     const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);

+ 10 - 3
apps/app/src/stores/page.tsx

@@ -125,14 +125,21 @@ export const useSWRxPageByPath = (path?: string, config?: SWRConfiguration): SWR
   );
 };
 
-export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo | undefined, Error> => {
+export const useSWRxTagsInfo = (pageId: Nullable<string>, config?: SWRConfiguration): SWRResponse<IPageTagsInfo | null, Error> => {
   const { data: shareLinkId } = useShareLinkId();
 
   const endpoint = `/pages.getPageTag?pageId=${pageId}`;
 
-  return useSWRImmutable(
+  return useSWR(
     shareLinkId == null && pageId != null ? [endpoint, pageId] : null,
-    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId }).then(result => result),
+    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId })
+      .then(result => result)
+      .catch(getPageApiErrorHandler),
+    {
+      ...config,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
   );
 };
 

+ 0 - 2
apps/app/test/integration/models/v5.page.test.js

@@ -16,7 +16,6 @@ describe('Page', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -488,7 +487,6 @@ describe('Page', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 1 - 2
apps/app/test/integration/service/page.test.js

@@ -3,6 +3,7 @@ import { GroupType } from '@growi/core';
 import { advanceTo } from 'jest-date-mock';
 
 import { PageSingleDeleteCompConfigValue, PageRecursiveDeleteCompConfigValue } from '~/interfaces/page-delete-config';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import Tag from '~/server/models/tag';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
@@ -62,7 +63,6 @@ describe('PageService', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -75,7 +75,6 @@ describe('PageService', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 6 - 6
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -5,6 +5,8 @@ import mongoose from 'mongoose';
 import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import UserGroup from '../../../src/server/models/user-group';
 import UserGroupRelation from '../../../src/server/models/user-group-relation';
@@ -29,7 +31,6 @@ describe('PageService page operations with non-public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let xssSpy;
 
   let rootPage;
@@ -121,7 +122,6 @@ describe('PageService page operations with non-public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
 
     /*
      * Common
@@ -1527,7 +1527,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
@@ -1536,7 +1536,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage.parent).toBeNull();
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage.grant).toBe(Page.GRANT_RESTRICTED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test('should revert single deleted page with GRANT_USER_GROUP', async() => {
       const beforeRevertPath = '/trash/np_revert2';
@@ -1557,7 +1557,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();
@@ -1569,7 +1569,7 @@ describe('PageService page operations with non-public pages', () => {
         { item: groupIdA, type: GroupType.userGroup },
         { item: externalGroupIdA, type: GroupType.externalUserGroup },
       ]);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test(`revert multiple pages: only target page should be reverted.
           Non-existant middle page and leaf page with GRANT_RESTRICTED shoud not be reverted`, async() => {

+ 0 - 2
apps/app/test/integration/service/v5.page.test.ts

@@ -11,7 +11,6 @@ describe('Test page service methods', () => {
   let Revision;
   let User;
   let Tag;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -42,7 +41,6 @@ describe('Test page service methods', () => {
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
     Tag = mongoose.model('Tag');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 10 - 11
apps/app/test/integration/service/v5.public-page.test.ts

@@ -1,8 +1,9 @@
 /* eslint-disable no-unused-vars */
-import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
 import { PageActionType, PageActionStage } from '../../../src/interfaces/page-operation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
@@ -15,7 +16,6 @@ describe('PageService page operations with only public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -49,7 +49,6 @@ describe('PageService page operations with only public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -2016,13 +2015,13 @@ describe('PageService page operations with only public pages', () => {
         endpoint: '/_api/v3/pages/delete',
       });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
-      const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
-      const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
+      const deletedTagRelation1 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation1?._id });
+      const deletedTagRelation2 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation2?._id });
 
       expect(page).toBe(null);
       expect(deletedPage.status).toBe(Page.STATUS_DELETED);
-      expect(deletedTagRelation1.isPageTrashed).toBe(true);
-      expect(deletedTagRelation2.isPageTrashed).toBe(true);
+      expect(deletedTagRelation1?.isPageTrashed).toBe(true);
+      expect(deletedTagRelation2?.isPageTrashed).toBe(true);
     });
   });
   describe('Delete completely', () => {
@@ -2103,7 +2102,7 @@ describe('PageService page operations with only public pages', () => {
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
-      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
+      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1?._id, pageTagRelation2?._id] } });
       const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
       const deletedComments = await Comment.find({ _id: comment._id });
       const deletedPageRedirects = await PageRedirect.find({ _id: { $in: [pageRedirect1._id, pageRedirect2._id] } });
@@ -2115,7 +2114,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedRevisions.length).toBe(0);
       // tag should be Truthy
       expect(tags).toBeTruthy();
-      // pageTagRelation should be null
+      // PageTagRelation should be null
       expect(deletedPageTagRelations.length).toBe(0);
       // bookmark should be null
       expect(deletedBookmarks.length).toBe(0);
@@ -2201,12 +2200,12 @@ describe('PageService page operations with only public pages', () => {
         ip: '::ffff:127.0.0.1',
         endpoint: '/_api/v3/pages/revert',
       });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.path).toBe('/v5_revert1');
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
 
     });
 

+ 3 - 3
apps/app/test/integration/setup-crowi.js → apps/app/test/integration/setup-crowi.ts

@@ -1,8 +1,8 @@
 import { Server } from 'http';
 
-import Crowi from '~/server/crowi';
+import Crowi from '../../src/server/crowi';
 
-let _instance = null;
+let _instance: Crowi;
 
 const initCrowi = async(crowi) => {
   await crowi.setupModels();
@@ -27,7 +27,7 @@ const initCrowi = async(crowi) => {
   ]);
 };
 
-export async function getInstance(isNewInstance) {
+export async function getInstance(isNewInstance?: boolean): Promise<Crowi> {
   if (isNewInstance) {
     const crowi = new Crowi();
     await initCrowi(crowi);