Quellcode durchsuchen

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

imprv: Creating/updating page APIs
Yuki Takei vor 2 Jahren
Ursprung
Commit
781b7e2a72
43 geänderte Dateien mit 1160 neuen und 1222 gelöschten Zeilen
  1. 2 0
      apps/app/src/client/services/create-page/index.ts
  2. 111 0
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  3. 38 0
      apps/app/src/client/services/create-page/use-create-template-page.ts
  4. 12 66
      apps/app/src/client/services/page-operation.ts
  5. 10 27
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  6. 11 28
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  7. 0 123
      apps/app/src/client/services/use-create-page-and-transit.tsx
  8. 0 55
      apps/app/src/client/services/use-on-template-button-clicked.ts
  9. 10 6
      apps/app/src/components/CreateTemplateModal.tsx
  10. 22 21
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  11. 22 56
      apps/app/src/components/PageEditor/PageEditor.tsx
  12. 13 7
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  13. 28 22
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  14. 25 40
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  15. 0 109
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  16. 2 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts
  17. 29 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  18. 45 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  19. 1 1
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  20. 1 0
      apps/app/src/interfaces/apiv3/index.ts
  21. 37 0
      apps/app/src/interfaces/apiv3/page.ts
  22. 0 10
      apps/app/src/interfaces/page-operation.ts
  23. 17 2
      apps/app/src/interfaces/page.ts
  24. 6 4
      apps/app/src/pages/_private-legacy-pages.page.tsx
  25. 6 4
      apps/app/src/pages/_search.page.tsx
  26. 7 5
      apps/app/src/pages/me/[[...path]].page.tsx
  27. 9 6
      apps/app/src/pages/tags.page.tsx
  28. 0 43
      apps/app/src/server/models/interfaces/page-operation.ts
  29. 35 6
      apps/app/src/server/models/page-operation.ts
  30. 5 11
      apps/app/src/server/models/page.ts
  31. 254 0
      apps/app/src/server/routes/apiv3/page/create-page.ts
  32. 0 234
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  33. 106 0
      apps/app/src/server/routes/apiv3/page/index.js
  34. 172 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  35. 0 58
      apps/app/src/server/routes/apiv3/pages/index.js
  36. 0 1
      apps/app/src/server/routes/index.js
  37. 21 168
      apps/app/src/server/routes/page.js
  38. 40 48
      apps/app/src/server/service/installer.ts
  39. 3 3
      apps/app/src/server/service/page-grant.ts
  40. 43 52
      apps/app/src/server/service/page/index.ts
  41. 10 3
      apps/app/src/server/service/page/page-service.ts
  42. 3 0
      packages/core/src/utils/page-path-utils/index.spec.ts
  43. 4 3
      packages/core/src/utils/page-path-utils/index.ts

+ 2 - 0
apps/app/src/client/services/create-page/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-page-and-transit';
+export * from './use-create-template-page';

+ 111 - 0
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -0,0 +1,111 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  shouldCheckPageExists?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+type UseCreatePageAndTransit = () => {
+  isCreating: boolean,
+  createAndTransit: CreatePageAndTransit,
+};
+
+export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
+
+  const router = useRouter();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
+    const {
+      shouldCheckPageExists,
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+
+    // check the page existence
+    if (shouldCheckPageExists && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const res = await exist(JSON.stringify([pagePath]));
+        const isExists = res.pages[pagePath];
+
+        if (isExists) {
+          // routing
+          if (pagePath !== currentPagePath) {
+            await router.push(`${pagePath}#edit`);
+          }
+          mutateEditorMode(EditorMode.Editor);
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    // create and transit
+    try {
+      setCreating(true);
+      onCreationStart?.();
+
+      const response = await createPage(params);
+
+      await router.push(`/${response.page._id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      onTerminated?.();
+      setCreating(false);
+    }
+
+  }, [currentPagePath, mutateEditorMode, router]);
+
+  return {
+    isCreating,
+    createAndTransit,
+  };
+};

+ 38 - 0
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
+
+import type { LabelType } from '~/interfaces/template';
+import { useCurrentPagePath } from '~/stores/page';
+
+import { useCreatePageAndTransit } from './use-create-page-and-transit';
+
+type UseCreateTemplatePage = () => {
+  isCreatable: boolean,
+  isCreating: boolean,
+  createTemplate?: (label: LabelType) => Promise<void>,
+}
+
+export const useCreateTemplatePage: UseCreateTemplatePage = () => {
+
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(async(label: LabelType) => {
+    if (isLoadingPagePath || !isCreatable) return;
+
+    return createAndTransit(
+      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { shouldCheckPageExists: true },
+    );
+  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+
+  return {
+    isCreatable,
+    isCreating,
+    createTemplate: isCreatable ? createTemplate : undefined,
+  };
+};

+ 12 - 66
apps/app/src/client/services/page-operation.ts

@@ -1,10 +1,12 @@
 import { useCallback } from 'react';
 
-import { SubscriptionStatusType, type Nullable } from '@growi/core';
+import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
-import { OptionsToSave } from '~/interfaces/page-operation';
-import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import type {
+  IApiv3PageCreateParams, IApiv3PageCreateResponse, IApiv3PageUpdateParams, IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
+import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
@@ -15,6 +17,7 @@ import { toastError } from '../util/toastr';
 
 const logger = loggerFactory('growi:services:page-operation');
 
+
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
     const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
@@ -87,71 +90,14 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
-// TODO: define return type
-export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    path: pagePath,
-    body: markdown,
-  });
-
-  const res = await apiv3Post('/pages/', params);
-  const { page, tags, revision } = res.data;
-
-  return { page, tags, revision };
+export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
+  return res.data;
 };
 
-// TODO: define return type
-const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    page_id: pageId,
-    revision_id: revisionId,
-    body: markdown,
-  });
-
-  const res: any = await apiPost('/pages.update', params);
-  if (!res.ok) {
-    throw new Error(res.error);
-  }
-  return res;
-};
-
-type PageInfo= {
-  path: string,
-  pageId: Nullable<string>,
-  revisionId: Nullable<string>,
-}
-
-type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
-
-// TODO: define return type
-export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
-  /* eslint-disable react-hooks/rules-of-hooks */
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  /* eslint-enable react-hooks/rules-of-hooks */
-
-  return useCallback(async(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => {
-    const { path, pageId, revisionId } = pageInfo;
-
-    const options: OptionsToSave = Object.assign({}, optionsToSave);
-
-    let res;
-    if (pageId == null || revisionId == null) {
-      res = await createPage(path, markdown, options);
-    }
-    else {
-      if (revisionId == null) {
-        const msg = '\'revisionId\' is required to update page';
-        throw new Error(msg);
-      }
-      res = await updatePage(pageId, revisionId, markdown, options);
-    }
-
-    mutateIsEnabledUnsavedWarning(false);
-
-    return res;
-  }, [mutateIsEnabledUnsavedWarning]);
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
 };
 
 export type UpdateStateAfterSaveOption = {

+ 10 - 27
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -1,17 +1,17 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
@@ -33,8 +33,6 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
       return;
@@ -43,28 +41,13 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -72,7 +55,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open DrawioModal

+ 11 - 28
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -1,16 +1,16 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
@@ -32,8 +32,6 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
       return;
@@ -42,28 +40,13 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -71,7 +54,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open HandsonTableModal

+ 0 - 123
apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -1,123 +0,0 @@
-import { useCallback } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Invoked when an error is occured
- */
-type OnError = (err) => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onError?: OnError,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  pagePath: string | undefined,
-  // grant?: number,
-  // grantUserGroupId?: string,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-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;
-    }
-
-    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: '',
-        grant: 4,
-        // grant,
-        // grantUserGroupId,
-      };
-
-      const response = await createPage(pagePath, '', params);
-
-      await router.push(`${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      logger.warn(err);
-      onError?.(err);
-    }
-    finally {
-      onTerminated?.();
-    }
-
-  }, [isLoading, isNotFound, mutateEditorMode, router]);
-};

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

@@ -1,55 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import type { LabelType } from '~/interfaces/template';
-
-export const useOnTemplateButtonClicked = (
-    currentPagePath?: string,
-    isLoading?: boolean,
-): {
-  onClickHandler: (label: LabelType) => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const onClickHandler = useCallback(async(label: LabelType) => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      const targetPath = currentPagePath == null || currentPagePath === '/'
-        ? `/${label}`
-        : `${currentPagePath}/${label}`;
-
-      const path = isCreatablePage(targetPath) ? targetPath : `/${label}`;
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        };
-
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPagePath, isLoading, router]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 10 - 6
apps/app/src/components/CreateTemplateModal.tsx

@@ -4,9 +4,9 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import { TargetType, LabelType } from '~/interfaces/template';
+import type { TargetType, LabelType } from '~/interfaces/template';
 
 
 type TemplateCardProps = {
@@ -55,16 +55,16 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
 }) => {
   const { t } = useTranslation();
 
-  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+  const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
 
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
-      await onClickTemplateButton(label);
+      await createTemplate?.(label);
     }
     catch (err) {
       toastError(err);
     }
-  }, [onClickTemplateButton]);
+  }, [createTemplate]);
 
   const parentPath = pathUtils.addTrailingSlash(path);
 
@@ -73,12 +73,16 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
       <TemplateCard
         target={target}
         label={label}
-        isPageCreating={isPageCreating}
+        isPageCreating={isCreating}
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
     </div>
   );
 
+  if (!isCreatable) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">

+ 22 - 21
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,12 +1,12 @@
-import React, { type ReactNode, useCallback, useState } from 'react';
+import React, { type ReactNode, useCallback } from 'react';
 
-import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
+import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -47,9 +47,6 @@ type Props = {
   editorMode: EditorMode | undefined,
   isBtnDisabled: boolean,
   path?: string,
-  grant?: number,
-  // grantUserGroupId?: string
-  grantUserGroupIds?: IGrantedGroup[]
 }
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -57,30 +54,34 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     isBtnDisabled,
     path,
-    // grant,
-    // grantUserGroupId,
   } = props;
 
   const { t } = useTranslation('common');
-  const [isCreating, setIsCreating] = useState(false);
 
+  const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const _isBtnDisabled = isCreating || isBtnDisabled;
-
-  const createPageAndTransit = useCreatePageAndTransit();
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
 
   const editButtonClickedHandler = useCallback(() => {
-    createPageAndTransit(
-      path,
-      {
-        onCreationStart: () => { setIsCreating(true) },
-        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
-        onTerminated: () => { setIsCreating(false) },
-      },
-    );
-  }, [createPageAndTransit, path, t]);
+    if (isNotFound == null || isNotFound === false) {
+      mutateEditorMode(EditorMode.Editor);
+      return;
+    }
+
+    try {
+      createAndTransit(
+        { path },
+        { shouldCheckPageExists: true },
+      );
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: path }));
+    }
+  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
+
+  const _isBtnDisabled = isCreating || isBtnDisabled;
 
   return (
     <>

+ 22 - 56
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -19,10 +19,9 @@ import { throttle, debounce } from 'throttle-debounce';
 
 
 import { useShouldExpandContent } from '~/client/services/layout';
-import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, updatePage } from '~/client/services/page-operation';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize, useCurrentUser,
@@ -32,7 +31,6 @@ import {
 import {
   useEditorSettings,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
-  useIsEnabledUnsavedWarning,
   useIsConflict,
   useEditingMarkdown,
   useWaitingSaveProcessing,
@@ -90,10 +88,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const router = useRouter();
 
   const previewRef = useRef<HTMLDivElement>(null);
-  const codeMirrorEditorContainerRef = useRef<HTMLDivElement>(null);
 
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -125,14 +122,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: socket } = useGlobalSocket();
 
   const { data: rendererOptions } = usePreviewOptions();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsConflict } = useIsConflict();
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
-  const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   const { resolvedTheme } = useNextThemes();
@@ -216,40 +211,27 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [socket, checkIsConflict]);
 
-  const optionsToSave = useMemo((): OptionsToSave | undefined => {
-    if (grantData == null) {
-      return;
-    }
-    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
-      return { item: group.id, type: group.type };
-    });
-    const optionsToSave = {
-      isSlackEnabled: isSlackEnabled ?? false,
-      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
-      grant: grantData.grant,
-      // pageTags: pageTags ?? [],
-      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-    };
-    return optionsToSave;
-  }, [grantData, isSlackEnabled]);
-
-
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (currentPathname == null || optionsToSave == null) {
-      logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
+    if (pageId == null || currentPagePath == null || currentRevisionId == null || grantData == null) {
+      logger.error('Some materials to save are invalid', {
+        pageId, currentPagePath, currentRevisionId, grantData,
+      });
       throw new Error('Some materials to save are invalid');
     }
 
-    const options = Object.assign(optionsToSave, opts);
-
     try {
       mutateWaitingSaveProcessing(true);
 
-      const { page } = await saveOrUpdate(
-        codeMirrorEditor?.getDoc() ?? '',
-        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        options,
-      );
+      const { page } = await updatePage({
+        pageId,
+        revisionId: currentRevisionId,
+        body: codeMirrorEditor?.getDoc() ?? '',
+        grant: grantData?.grant,
+        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
+          return { item: group.id, type: group.type };
+        }),
+        ...(opts ?? {}),
+      });
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
@@ -271,12 +253,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       mutateWaitingSaveProcessing(false);
     }
 
-  }, [
-    codeMirrorEditor,
-    currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId,
-    currentPagePath, currentRevisionId,
-    mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [codeMirrorEditor, grantData, pageId, currentPagePath, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     const page = await save(opts);
@@ -284,14 +262,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
     }
 
-    if (isNotFound) {
-      await router.push(`/${page._id}`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     mutateEditorMode(EditorMode.View);
-  }, [save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [mutateEditorMode, save, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     const page = await save();
@@ -299,16 +272,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
     }
 
-    if (isNotFound) {
-      await router.push(`/${page._id}#edit`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     toastSuccess(t('toaster.save_succeeded'));
-    mutateEditorMode(EditorMode.Editor);
-
-  }, [isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [save, t, updateStateAfterSave]);
 
 
   // the upload event handler

+ 13 - 7
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback, useState } from 'react';
 
-import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
+import {
+  PageGrant, isPopulated, GroupType, type IGrantedGroup,
+} from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledDropdown,
@@ -16,24 +18,28 @@ import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
-    grant: 1, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+    grant: PageGrant.GRANT_PUBLIC, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
   },
   {
-    grant: 2, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
-    grant: 4, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
   },
   {
-    grant: 5, iconClass: 'icon-options', btnStyleClass: 'outline-purple', label: 'Only inside the group', reselectLabel: 'Reselect the group',
+    grant: PageGrant.GRANT_USER_GROUP,
+    iconClass: 'icon-options',
+    btnStyleClass: 'outline-purple',
+    label: 'Only inside the group',
+    reselectLabel: 'Reselect the group',
   },
 ];
 
 
 type Props = {
   disabled?: boolean,
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
     id: string,
     name: string,
@@ -72,7 +78,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
   /**
    * change event handler for grant selector
    */
-  const changeGrantHandler = useCallback((grant: number) => {
+  const changeGrantHandler = useCallback((grant: PageGrant) => {
     // select group
     if (grant === 5) {
       showSelectGroupModal();

+ 28 - 22
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -3,21 +3,21 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { DropdownMenu, DropdownItem } from 'reactstrap';
 
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 
 type DropendMenuProps = {
-  onClickCreateNewPageButtonHandler: () => Promise<void>
-  onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
+  onClickCreateNewPage: () => Promise<void>
+  onClickCreateTodaysMemo: () => Promise<void>
+  onClickCreateTemplate?: (label: LabelType) => Promise<void>
   todaysPath: string | null,
 }
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
-    onClickCreateNewPageButtonHandler,
-    onClickCreateTodaysButtonHandler,
-    onClickTemplateButtonHandler,
+    onClickCreateNewPage,
+    onClickCreateTodaysMemo,
+    onClickCreateTemplate,
     todaysPath,
   } = props;
 
@@ -28,33 +28,39 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       container="body"
     >
       <DropdownItem
-        onClick={onClickCreateNewPageButtonHandler}
+        onClick={onClickCreateNewPage}
       >
         {t('create_page_dropdown.new_page')}
       </DropdownItem>
-      {todaysPath != null && (
+
+      { todaysPath != null && (
         <>
           <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
           <DropdownItem
-            onClick={onClickCreateTodaysButtonHandler}
+            onClick={onClickCreateTodaysMemo}
           >
             {todaysPath}
           </DropdownItem>
         </>
       )}
-      <DropdownItem divider />
-      <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('_template')}
-      >
-        {t('create_page_dropdown.template.children')}
-      </DropdownItem>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('__template')}
-      >
-        {t('create_page_dropdown.template.descendants')}
-      </DropdownItem>
+
+      { onClickCreateTemplate != null && (
+        <>
+          <DropdownItem divider />
+          <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('_template')}
+          >
+            {t('create_page_dropdown.template.children')}
+          </DropdownItem>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('__template')}
+          >
+            {t('create_page_dropdown.template.descendants')}
+          </DropdownItem>
+        </>
+      ) }
     </DropdownMenu>
   );
 });

+ 25 - 40
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,60 +1,45 @@
 import React, { useState, useCallback } from 'react';
 
-import type { IUserHasId } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
-import { format } from 'date-fns';
-import { useTranslation } from 'react-i18next';
 import { Dropdown } from 'reactstrap';
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import { LabelType } from '~/interfaces/template';
-import { useCurrentUser } from '~/stores/context';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
-import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
+import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
 
-const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
-  const now = format(new Date(), 'yyyy/MM/dd');
-  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  return `${userHomepagePath}/${parentDirName}/${now}`;
+const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
+  return useCallback(async(param) => {
+    try {
+      return await method?.(param);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [method]);
 };
 
-export const PageCreateButton = React.memo((): JSX.Element => {
-  const { t } = useTranslation('commons');
-
-  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { data: currentUser } = useCurrentUser();
 
+export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
-  const todaysPath = currentUser == null
-    ? null
-    : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
-
-  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(
-    currentPage?.path, currentPage?.grant, currentPage?.grantedGroups, isLoading,
-  );
+  const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
-  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath);
+  const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
   // TODO: https://redmine.weseek.co.jp/issues/138805
-  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPagePath, isLoadingPagePath);
+  const {
+    createTemplate,
+    isCreating: isTemplatePageCreating, isCreatable: isTemplatePageCreatable,
+  } = useCreateTemplatePage();
 
-  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
-    try {
-      await onClickTemplateButton(label);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [onClickTemplateButton]);
+  const createNewPageWithToastr = useToastrOnError(createNewPage);
+  const createTodaysMemoWithToastr = useToastrOnError(createTodaysMemo);
+  const createTemplateWithToastr = useToastrOnError(createTemplate);
 
   const onMouseEnterHandler = () => {
     setIsHovered(true);
@@ -76,7 +61,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
         <CreateButton
           className="z-2"
-          onClick={onClickNewButton}
+          onClick={createNewPageWithToastr}
           disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
       </div>
@@ -89,9 +74,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
         >
           <DropendToggle />
           <DropendMenu
-            onClickCreateNewPageButtonHandler={onClickNewButton}
-            onClickCreateTodaysButtonHandler={onClickTodaysButton}
-            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
+            onClickCreateNewPage={createNewPageWithToastr}
+            onClickCreateTodaysMemo={createTodaysMemoWithToastr}
+            onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
             todaysPath={todaysPath}
           />
         </Dropdown>

+ 0 - 109
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -1,109 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import type { PageGrant, IGrantedGroup } from '@growi/core';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-export const useOnNewButtonClicked = (
-    currentPagePath?: string,
-    currentPageGrant?: PageGrant,
-    currentPageGrantedGroups?: IGrantedGroup[],
-    isLoading?: boolean,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      /**
-       * !! 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 shouldUseRootPath = currentPagePath == null || currentPageGrant == null;
-      const parentPath = shouldUseRootPath
-        ? '/'
-        : currentPagePath;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: shouldUseRootPath ? 1 : currentPageGrant,
-        grantUserGroupIds: shouldUseRootPath ? undefined : currentPageGrantedGroups,
-        shouldGeneratePath: true,
-      };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      const response = await createPage(parentPath, '', params);
-
-      await router.push(`/${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, mutateEditorMode, router]);
-
-  return { onClickHandler, isPageCreating };
-};
-
-export const useOnTodaysButtonClicked = (
-    todaysPath: string | null,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (todaysPath == null) {
-      return;
-    }
-
-    try {
-      setIsPageCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      await router.push(`${todaysPath}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [mutateEditorMode, router, todaysPath]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 2 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-new-page';
+export * from './use-create-todays-memo';

+ 29 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentPagePath } from '~/stores/page';
+
+
+type UseCreateNewPage = () => {
+  isCreating: boolean,
+  createNewPage: () => Promise<void>,
+}
+
+export const useCreateNewPage: UseCreateNewPage = () => {
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const createNewPage = useCallback(async() => {
+    if (isLoadingPagePath) return;
+
+    return createAndTransit(
+      { parentPath: currentPagePath, optionalParentPath: '/' },
+    );
+  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+
+  return {
+    isCreating,
+    createNewPage,
+  };
+};

+ 45 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -0,0 +1,45 @@
+import { useCallback } from 'react';
+
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentUser } from '~/stores/context';
+
+
+type UseCreateTodaysMemo = () => {
+  isCreating: boolean,
+  todaysPath: string | null,
+  createTodaysMemo: () => Promise<void>,
+}
+
+export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
+  const { t } = useTranslation('commons');
+
+  const { data: currentUser } = useCurrentUser();
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const isCreatable = currentUser != null;
+
+  const parentDirName = t('create_page_dropdown.todays.memo');
+  const now = format(new Date(), 'yyyy/MM/dd');
+  const todaysPath = isCreatable
+    ? `${userHomepagePath(currentUser)}/${parentDirName}/${now}`
+    : null;
+
+  const createTodaysMemo = useCallback(async() => {
+    if (!isCreatable || todaysPath == null) return;
+
+    return createAndTransit(
+      { path: todaysPath },
+      { shouldCheckPageExists: true },
+    );
+  }, [createAndTransit, isCreatable, todaysPath]);
+
+  return {
+    isCreating,
+    todaysPath,
+    createTodaysMemo,
+  };
+};

+ 1 - 1
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -67,7 +67,7 @@ export const useNewPageInput = (): UseNewPageInput => {
 
       setShowInput(false);
 
-      await apiv3Post('/pages/', {
+      await apiv3Post('/page', {
         path: newPagePath,
         body: undefined,
         grant: page.grant,

+ 1 - 0
apps/app/src/interfaces/apiv3/index.ts

@@ -0,0 +1 @@
+export * from './page';

+ 37 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -0,0 +1,37 @@
+import type {
+  IPageHasId, IRevisionHasId, ITag,
+} from '@growi/core';
+
+import type { IOptionsForCreate, IOptionsForUpdate } from '../page';
+
+export type IApiv3PageCreateParams = IOptionsForCreate & {
+  path?: string,
+  parentPath?: string,
+  optionalParentPath?: string,
+
+  body?: string,
+  pageTags?: string[],
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageCreateResponse = {
+  page: IPageHasId,
+  tags: ITag[],
+  revision: IRevisionHasId,
+};
+
+export type IApiv3PageUpdateParams = IOptionsForUpdate & {
+  pageId: string,
+  revisionId: string,
+  body: string,
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageUpdateResponse = {
+  page: IPageHasId,
+  revision: IRevisionHasId,
+};

+ 0 - 10
apps/app/src/interfaces/page-operation.ts

@@ -1,5 +1,3 @@
-import type { IGrantedGroup } from '@growi/core';
-
 export const PageActionType = {
   Create: 'Create',
   Update: 'Update',
@@ -28,11 +26,3 @@ export type IPageOperationProcessData = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }
-
-export type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
-  // isSyncRevisionToHackmd?: boolean;
-};

+ 17 - 2
apps/app/src/interfaces/page.ts

@@ -1,4 +1,6 @@
-import type { GroupType, IPageHasId, Nullable } from '@growi/core';
+import type {
+  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant,
+} from '@growi/core';
 
 import type { IPageOperationProcessData } from './page-operation';
 
@@ -9,7 +11,7 @@ export {
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
 export type IPageGrantData = {
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
     id: string,
     name: string,
@@ -29,3 +31,16 @@ export type IDeleteManyPageApiv3Result = {
   isRecursively: Nullable<true>,
   isCompletely: Nullable<true>,
 };
+
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
+  // isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+  overwriteScopesOfDescendants?: boolean,
+};

+ 6 - 4
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -1,5 +1,5 @@
-import type { IUser, IUserHasId } from '@growi/core';
-import {
+import type { IUser } from '@growi/core';
+import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { useTranslation } from 'next-i18next';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import type { CommonProps } from './utils/commons';
 import {
@@ -50,6 +50,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_private-legacy-pages');
 
   // Search
   useIsSearchPage(true);

+ 6 - 4
apps/app/src/pages/_search.page.tsx

@@ -1,6 +1,6 @@
-import { ReactNode } from 'react';
+import type { ReactNode } from 'react';
 
-import type { IUser, IUserHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import { SearchPage } from '../components/SearchPage';
 
@@ -54,6 +54,8 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_search');
 
   // Search
   useIsSearchPage(true);

+ 7 - 5
apps/app/src/pages/me/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { type ReactNode, useMemo } from 'react';
 
-import {
+import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { useTranslation } from 'next-i18next';
@@ -10,18 +10,18 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { NextPageWithLayout } from '../_app.page';
+import type { NextPageWithLayout } from '../_app.page';
 import type { CommonProps } from '../utils/commons';
 import {
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle, useInitSidebarConfig,
@@ -101,6 +101,8 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/me');
 
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);

+ 9 - 6
apps/app/src/pages/tags.page.tsx

@@ -1,7 +1,8 @@
-import React, { useState, useCallback, ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React, { useState, useCallback } from 'react';
 
-import type { IUser, IUserHasId } from '@growi/core';
-import { GetServerSideProps, GetServerSidePropsContext } from 'next';
+import type { IUser } from '@growi/core';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
@@ -10,17 +11,17 @@ import Head from 'next/head';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IDataTagCount } from '~/interfaces/tag';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri,
+  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri, useCurrentPathname,
 } from '../stores/context';
 
-import { NextPageWithLayout } from './_app.page';
+import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import {
   getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle, useInitSidebarConfig,
@@ -49,6 +50,8 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/tags');
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { t } = useTranslation('');

+ 0 - 43
apps/app/src/server/models/interfaces/page-operation.ts

@@ -1,43 +0,0 @@
-import { PageGrant, type IGrantedGroup } from '@growi/core';
-
-import { ObjectIdLike } from '../../interfaces/mongoose-utils';
-
-export type IPageForResuming = {
-  _id: ObjectIdLike,
-  path: string,
-  isEmpty: boolean,
-  parent?: ObjectIdLike,
-  grant?: number,
-  grantedUsers?: ObjectIdLike[],
-  grantedGroups: IGrantedGroup[],
-  descendantCount: number,
-  status?: number,
-  revision?: ObjectIdLike,
-  lastUpdateUser?: ObjectIdLike,
-  creator?: ObjectIdLike,
-};
-
-export type IUserForResuming = {
-  _id: ObjectIdLike,
-};
-
-export type IOptionsForUpdate = {
-  grant?: PageGrant,
-  userRelatedGrantUserGroupIds?: IGrantedGroup[],
-  isSyncRevisionToHackmd?: boolean,
-  overwriteScopesOfDescendants?: boolean,
-};
-
-export type IOptionsForCreate = {
-  format?: string,
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: PageGrant,
-  overwriteScopesOfDescendants?: boolean,
-  isSynchronously?: boolean,
-};
-
-export type IOptionsForResuming = {
-  updateMetadata?: boolean,
-  createRedirectPage?: boolean,
-  prevDescendantCount?: number,
-} & IOptionsForUpdate & IOptionsForCreate;

+ 35 - 6
apps/app/src/server/models/page-operation.ts

@@ -1,17 +1,18 @@
+import type { IGrantedGroup } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns';
+import type {
+  Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, QueryOptions, FilterQuery,
+  Schema,
 } from 'mongoose';
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
-import {
-  IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/models/interfaces/page-operation';
-
 
 import loggerFactory from '../../utils/logger';
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const TIME_TO_ADD_SEC = 10;
@@ -20,6 +21,34 @@ const logger = loggerFactory('growi:models:page-operation');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
+
+type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroups: IGrantedGroup[],
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+type IOptionsForResuming = {
+  format: 'md' | 'pdf',
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+  prevDescendantCount?: number,
+} & IOptionsForUpdate & IOptionsForCreate;
+
+
 /*
  * Main Schema
  */

+ 5 - 11
apps/app/src/server/models/page.ts

@@ -5,10 +5,8 @@ import nodePath from 'path';
 
 import {
   type IPage,
-  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';
@@ -21,6 +19,7 @@ import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
@@ -61,7 +60,8 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
+
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
@@ -74,6 +74,8 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
+  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
+  findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
     templateBody?: string,
@@ -92,7 +94,6 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
 }
 
-type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<PageDocument, PageModel>({
@@ -1050,13 +1051,6 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
   await this.save();
 };
 
-export type PageCreateOptions = {
-  format?: string
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: number
-  overwriteScopesOfDescendants?: boolean
-}
-
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */

+ 254 - 0
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -0,0 +1,254 @@
+import type {
+  IPage, IUser, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { attachTitleHeader, normalizePath } 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 type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type { IOptionsForCreate } from '~/interfaces/page';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+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 generateUntitledPath(parentPath: string, basePathname: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = normalizePath(`${normalizePath(parentPath)}/${basePathname}-${index}`);
+  if (await Page.exists({ path, isEmpty: false }) != null) {
+    return generateUntitledPath(parentPath, basePathname, index + 1);
+  }
+  return path;
+}
+
+async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
+  // TODO: i18n
+  const basePathname = 'Untitled';
+
+  if (_path != null) {
+    const path = normalizePath(_path);
+
+    // when path is valid
+    if (isCreatablePage(path)) {
+      return normalizePath(path);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when path is invalid
+    throw new Error('Could not create the page for the path');
+  }
+
+  if (_parentPath != null) {
+    const parentPath = normalizePath(_parentPath);
+
+    // when parentPath is valid
+    if (isCreatablePage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when parentPath is invalid
+    throw new Error('Could not create the page for the parentPath');
+  }
+
+  // when both path and parentPath are not specified
+  return generateUntitledPath('/', basePathname);
+}
+
+
+type ReqBody = IApiv3PageCreateParams
+
+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);
+
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('path').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'path'"),
+    body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'parentPath'"),
+    body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'optionalParentPath'"),
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('pageTags').optional().isArray().withMessage('pageTags must be array'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function determineBodyAndTags(
+      path: string,
+      _body: string | null | undefined, _tags: string[] | null | undefined,
+  ): Promise<{ body: string, tags: string[] }> {
+
+    let body: string = _body ?? '';
+    let tags: string[] = _tags ?? [];
+
+    if (_body == null) {
+      const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+      if (isEnabledAttachTitleHeader) {
+        body += `${attachTitleHeader(path)}\n`;
+      }
+
+      const templateData = await Page.findTemplate(path);
+      if (templateData.templateTags != null) {
+        tags = templateData.templateTags;
+      }
+      if (templateData.templateBody != null) {
+        body += `${templateData.templateBody}\n`;
+      }
+    }
+
+    return { body, tags };
+  }
+
+  async function saveTags({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    const tagEvent = crowi.event('tag');
+    await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+    tagEvent.emit('update', createdPage, pageTags);
+    return PageTagRelation.listTagNamesByPage(createdPage.id);
+  }
+
+  async function postAction(req: CreatePageRequest, res: ApiV3Response, createdPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: createdPage,
+      action: SupportedAction.ACTION_PAGE_CREATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    // user notification
+    const { isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.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);
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body: bodyByParam, pageTags: tagsByParam,
+      } = req.body;
+
+      let pathToCreate: string;
+      try {
+        const { path, parentPath, optionalParentPath } = req.body;
+        pathToCreate = await determinePath(parentPath, path, optionalParentPath);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(err.toString(), 'could_not_create_page'));
+      }
+
+      if (isUserPage(pathToCreate)) {
+        const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const { body, tags } = await determineBodyAndTags(pathToCreate, bodyByParam, tagsByParam);
+
+      let createdPage;
+      try {
+        const { grant, grantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.grantUserGroupIds = grantUserGroupIds;
+        }
+        createdPage = await crowi.pageService.create(
+          pathToCreate,
+          body,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTags({ createdPage, pageTags: tags });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, createdPage);
+    },
+  ];
+};

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

@@ -1,234 +0,0 @@
-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);
-      }
-    },
-  ];
-};

+ 106 - 0
apps/app/src/server/routes/apiv3/page/index.js

@@ -20,6 +20,9 @@ import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
+import { createPageHandlersFactory } from './create-page';
+import { updatePageHandlersFactory } from './update-page';
+
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
@@ -311,6 +314,109 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
   });
 
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      post:
+   *        tags: [Page]
+   *        operationId: createPage
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    type: string
+   *                    description: Text of page
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                  grantUserGroupId:
+   *                    type: string
+   *                    description: UserGroup ID
+   *                    example: 5ae5fccfc5577b0004dbd8ab
+   *                  pageTags:
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/Tag'
+   *                  shouldGeneratePath:
+   *                    type: boolean
+   *                    description: Determine whether a new path should be generated
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          201:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        tags:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/Tags'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          409:
+   *            description: page path is already existed
+   */
+  router.post('/', createPageHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      put:
+   *        tags: [Page]
+   *        operationId: updatePage
+   *        description: Update page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - page_id
+   *                  - revision_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to update page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  router.put('/', updatePageHandlersFactory(crowi));
+
   /**
    * @swagger
    *

+ 172 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -0,0 +1,172 @@
+import type {
+  IPage, IRevisionHasId, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+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 type { IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import type { IOptionsForUpdate } from '~/interfaces/page';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
+} from '~/server/models';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+import { preNotifyService } from '~/server/service/pre-notify';
+import Xss from '~/services/xss';
+import XssOption from '~/services/xss/xssOption';
+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:update-page');
+
+
+type ReqBody = IApiv3PageUpdateParams;
+
+interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const Revision = mongoose.model<IRevisionHasId>('Revision');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+
+  const xss = (() => {
+    const initializedConfig = {
+      isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+      tagWhitelist: crowi.xssService.getTagWhitelist(),
+      attrWhitelist: crowi.xssService.getAttrWhitelist(),
+      // TODO: Omit rehype related property from XssOptionConfig type
+      //  Server side xss implementation does not require it.
+      xssOption: RehypeSanitizeOption.CUSTOM,
+    };
+    const xssOption = new XssOption(initializedConfig);
+    return new Xss(xssOption);
+  })();
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'pageId' must be specified"),
+    body('revisionId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'revisionId' must be specified"),
+    body('body').exists().isString()
+      .withMessage("The empty value is not allowd for the 'body'"),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: updatedPage,
+      action: SupportedAction.ACTION_PAGE_UPDATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit(
+      'update', res.locals.activity._id, parameters,
+      { path: updatedPage.path, creator: updatedPage.creator._id.toString() },
+      preNotifyService.generatePreNotify,
+    );
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, updatedPage, req.user);
+    }
+    catch (err) {
+      logger.error('Edit notification failed', err);
+    }
+
+    // user notification
+    const { revisionId, isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', { previousRevision: revisionId });
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: UpdatePageRequest, res: ApiV3Response) => {
+      const { pageId, revisionId, body } = req.body;
+
+      // check page existence
+      const isExist = await Page.count({ _id: pageId }) > 0;
+      if (!isExist) {
+        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+      }
+
+      // check revision
+      const currentPage = await Page.findByIdAndViewer(pageId, req.user);
+      if (currentPage != null && !currentPage.isUpdatable(revisionId)) {
+        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
+        const returnLatestRevision = {
+          revisionId: latestRevision?._id.toString(),
+          revisionBody: xss.process(latestRevision?.body),
+          createdAt: latestRevision?.createdAt,
+          user: serializeUserSecurely(latestRevision?.author),
+        };
+        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', 'conflict'), 409, {
+          returnLatestRevision,
+        });
+      }
+
+      let updatedPage;
+      try {
+        const { grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForUpdate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
+        }
+        const previousRevision = await Revision.findById(revisionId);
+        updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
+      }
+      catch (err) {
+        logger.error('Error occurred while updating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const result = {
+        page: serializePageSecurely(updatedPage),
+        revision: serializeRevisionSecurely(updatedPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, updatedPage);
+    },
+  ];
+};

+ 0 - 58
apps/app/src/server/routes/apiv3/pages/index.js

@@ -19,7 +19,6 @@ 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
@@ -205,63 +204,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: createPage
-   *        description: Create page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    type: string
-   *                    description: Text of page
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                  grantUserGroupId:
-   *                    type: string
-   *                    description: UserGroup ID
-   *                    example: 5ae5fccfc5577b0004dbd8ab
-   *                  pageTags:
-   *                    type: array
-   *                    items:
-   *                      $ref: '#/components/schemas/Tag'
-   *                  shouldGeneratePath:
-   *                    type: boolean
-   *                    description: Determine whether a new path should be generated
-   *                required:
-   *                  - body
-   *                  - path
-   *        responses:
-   *          201:
-   *            description: Succeeded to create page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    data:
-   *                      type: object
-   *                      properties:
-   *                        page:
-   *                          $ref: '#/components/schemas/Page'
-   *                        tags:
-   *                          type: array
-   *                          items:
-   *                            $ref: '#/components/schemas/Tags'
-   *                        revision:
-   *                          $ref: '#/components/schemas/Revision'
-   *          409:
-   *            description: page path is already existed
-   */
-  router.post('/', createPageHandlersFactory(crowi));
-
   /**
    * @swagger
    *

+ 0 - 1
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.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);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);

+ 21 - 168
apps/app/src/server/routes/page.js

@@ -1,7 +1,6 @@
 import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
@@ -9,11 +8,7 @@ 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';
-
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+import { configManager } from '../service/config-manager';
 
 /**
  * @swagger
@@ -135,22 +130,24 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
  */
 
 /* eslint-disable no-use-before-define */
+/**
+ * @type { (crowi: import('../crowi').default, app) => any }
+ */
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
   const { pagePathUtils } = require('@growi/core/dist/utils');
 
+  /** @type {import('../models/page').PageModel} */
   const Page = crowi.model('Page');
+
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
 
-  const { configManager, xssService } = crowi;
+  const { xssService } = crowi;
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const activityEvent = crowi.event('activity');
 
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
@@ -220,154 +217,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.update:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: updatePage
-   *        summary: /pages.update
-   *        description: Update page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  revision_id:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                required:
-   *                  - body
-   *                  - page_id
-   *                  - revision_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to update page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.update Update page
-   * @apiName UpdatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} body
-   * @apiParam {String} page_id
-   * @apiParam {String} revision_id
-   * @apiParam {String} grant
-   *
-   * In the case of the page exists:
-   * - If revision_id is specified => update the page,
-   * - If revision_id is not specified => force update by the new contents.
-   */
-  api.update = async function(req, res) {
-    const pageBody = req.body.body ?? null;
-    const pageId = req.body.page_id || null;
-    const revisionId = req.body.revision_id || null;
-    const grant = req.body.grant || null;
-    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    if (pageId === null || pageBody === null || revisionId === null) {
-      return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
-    }
-
-    // check page existence
-    const isExist = await Page.count({ _id: pageId }) > 0;
-    if (!isExist) {
-      return res.json(ApiResponse.error(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // check revision
-    const Revision = crowi.model('Revision');
-    let page = await Page.findByIdAndViewer(pageId, req.user);
-    if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      const latestRevision = await Revision.findById(page.revision).populate('author');
-      const returnLatestRevision = {
-        revisionId: latestRevision._id.toString(),
-        revisionBody: xss.process(latestRevision.body),
-        createdAt: latestRevision.createdAt,
-        user: serializeUserSecurely(latestRevision.author),
-      };
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
-    }
-
-    const previousRevision = await Revision.findById(revisionId);
-    try {
-      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
-    }
-    catch (err) {
-      logger.error('error on _api/pages.update', err);
-      return res.json(ApiResponse.error(err));
-    }
-
-
-    const result = {
-      page: serializePageSecurely(page),
-      revision: serializeRevisionSecurely(page.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
-    }
-    catch (err) {
-      logger.error('Edit notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(page, req.user, slackChannels, 'update', { previousRevision });
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_UPDATE,
-    };
-
-    activityEvent.emit(
-      'update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() }, preNotifyService.generatePreNotify,
-    );
-  };
-
   /**
    * @swagger
    *
@@ -563,6 +412,7 @@ module.exports = function(crowi, app) {
       endpoint: req.originalUrl,
     };
 
+    /** @type {import('../models/page').PageDocument | undefined} */
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     if (page == null) {
@@ -573,15 +423,18 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
     }
 
-    let creator;
-    if (page.isEmpty) {
-      // If empty, the creator is inherited from the closest non-empty ancestor page.
-      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
-      creator = notEmptyClosestAncestor.creator;
-    }
-    else {
-      creator = page.creator;
-    }
+    // -- canDelete no longer needs creator,
+    //  however it might be required to retrieve the closest non-empty ancestor page's owner -- 2024.02.09 Yuki Takei
+    //
+    // let creator;
+    // if (page.isEmpty) {
+    //   // If empty, the creator is inherited from the closest non-empty ancestor page.
+    //   const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+    //   creator = notEmptyClosestAncestor.creator;
+    // }
+    // else {
+    //   creator = page.creator;
+    // }
 
     debug('Delete page', page._id, page.path);
 
@@ -615,7 +468,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
-        if (!crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDelete(page, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 

+ 40 - 48
apps/app/src/server/service/installer.ts

@@ -1,7 +1,8 @@
 import path from 'path';
 
-import { Lang } from '@growi/core';
-import type { IPage, IUser } from '@growi/core';
+import type {
+  Lang, IPage, IUser,
+} from '@growi/core';
 import { addSeconds } from 'date-fns';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
@@ -9,10 +10,10 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { generateConfigsForInstalling } from '../models/config';
 
-import type { ConfigManager } from './config-manager';
-import SearchService from './search';
+import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
 
@@ -26,17 +27,16 @@ export type AutoInstallOptions = {
 
 export class InstallerService {
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  crowi: any;
+  crowi: Crowi;
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi: any) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
   private async initSearchIndex() {
-    const searchService: SearchService = this.crowi.searchService;
-    if (!searchService.isReachable) {
+    const { searchService } = this.crowi;
+
+    if (searchService == null || !searchService.isReachable) {
       return;
     }
 
@@ -48,18 +48,19 @@ export class InstallerService {
     }
   }
 
-  private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
+  private async createPage(filePath, pagePath): Promise<IPage|undefined> {
+    const { pageService } = this.crowi;
+
     try {
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
+      return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
     }
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private async createInitialPages(owner, lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
+  private async createInitialPages(lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     /*
@@ -67,10 +68,10 @@ export class InstallerService {
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math');
 
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
@@ -110,8 +111,6 @@ export class InstallerService {
    * Execute only once for installing application
    */
   private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
-    const configManager: ConfigManager = this.crowi.configManager;
-
     const initialConfig = generateConfigsForInstalling();
     initialConfig['app:globalLang'] = globalLang;
 
@@ -125,45 +124,38 @@ export class InstallerService {
   async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
     await this.initDB(globalLang, options);
 
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const User = mongoose.model('User') as any;
-    const Page = mongoose.model('Page') as any;
+    const User = mongoose.model<IUser, { createUser }>('User');
 
     // create portal page for '/' before creating admin user
-    await this.createPage(
-      path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
-      '/',
-      { _id: '000000000000000000000000' }, // use 0 as a mock user id
-    );
-
-    // create first admin user
-    // TODO: with transaction
-    let adminUser;
     try {
+      await this.createPage(
+        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        '/',
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+
+    try {
+      // create first admin user
       const {
         name, username, email, password,
       } = firstAdminUserToSave;
-      adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncGrantAdmin();
+      const adminUser = await User.createUser(name, username, email, password, globalLang);
+      await (adminUser as any).asyncGrantAdmin();
+
+      // create initial pages
+      await this.createInitialPages(globalLang, options?.serverDate);
+
+      return adminUser;
     }
     catch (err) {
+      logger.error(err);
       throw new FailedToCreateAdminUserError(err);
     }
 
-    // add owner after creating admin user
-    const Revision = this.crowi.model('Revision');
-    const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ path: '/' });
-    rootPage.creator = adminUser._id;
-    rootPage.lastUpdateUser = adminUser._id;
-    rootRevision.author = adminUser._id;
-    await Promise.all([rootPage.save(), rootRevision.save()]);
-
-    // create initial pages
-    await this.createInitialPages(adminUser, globalLang, options?.serverDate);
-
-    return adminUser;
   }
 
 }

+ 3 - 3
apps/app/src/server/service/page-grant.ts

@@ -10,12 +10,12 @@ import mongoose from 'mongoose';
 
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import { PageDocument, PageModel } from '~/server/models/page';
+import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
 import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
 import { divideByType } from '../util/granted-group';
 

+ 43 - 52
apps/app/src/server/service/page/index.ts

@@ -21,6 +21,7 @@ import { Comment } from '~/features/comment/server';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
@@ -30,8 +31,9 @@ import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
+import type { CreateMethod } from '~/server/models/page';
 import {
-  type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
+  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';
@@ -42,7 +44,6 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { Attachment } from '../../models';
 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 PageRedirect from '../../models/page-redirect';
@@ -420,14 +421,6 @@ class PageService implements IPageService {
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
-    let creatorId = page.creator;
-    if (page.isEmpty) {
-      // Need non-empty ancestor page to get its creatorId because empty page does NOT have it.
-      // Use creatorId of ancestor page to determine whether the empty page is deletable
-      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
-      creatorId = notEmptyClosestAncestor.creator;
-    }
-
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
 
     const isDeletable = this.canDelete(page, user, false);
@@ -1124,7 +1117,7 @@ class PageService implements IPageService {
     const copyPage = { ...page };
 
     // 3. Duplicate target
-    const options: PageCreateOptions = {
+    const options: IOptionsForCreate = {
       grant,
       grantUserGroupIds: grantedGroupIds,
     };
@@ -2626,7 +2619,7 @@ class PageService implements IPageService {
   }
 
   async normalizeParentByPath(path: string, user): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
     const { PageQueryBuilder } = Page;
 
     // This validation is not 100% correct since it ignores user to count
@@ -2664,16 +2657,14 @@ class PageService implements IPageService {
     if (shouldCreateNewPage) {
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
 
-      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
-        grant: notEmptyParent.grant,
-        grantUserGroupIds: notEmptyParent.grantedGroups,
-        grantedUsers: notEmptyParent.grantedUsers,
-      };
-
       systematicallyCreatedPage = await this.forceCreateBySystem(
         path,
         '',
-        options,
+        {
+          grant: notEmptyParent?.grant,
+          grantUserIds: notEmptyParent?.grantedUsers.map(u => getIdForRef(u)),
+          grantUserGroupIds: notEmptyParent?.grantedGroups,
+        },
       );
       page = systematicallyCreatedPage;
     }
@@ -3680,12 +3671,12 @@ class PageService implements IPageService {
       path: string,
       grantData: {
         grant?: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupIds?: IGrantedGroup[],
       },
       shouldValidateGrant: boolean,
       user?,
-      options?: Partial<PageCreateOptions>,
+      options?: IOptionsForCreate,
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -3704,7 +3695,7 @@ class PageService implements IPageService {
     }
 
     // UserGroup & Owner validation
-    const { grant, grantedUserIds, grantUserGroupIds } = grantData;
+    const { grant, grantUserIds, grantUserGroupIds } = grantData;
     if (shouldValidateGrant) {
       if (user == null) {
         throw Error('user is required to validate grant');
@@ -3716,7 +3707,7 @@ class PageService implements IPageService {
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
-        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3743,25 +3734,32 @@ class PageService implements IPageService {
    * Create a page
    * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
    */
-  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
+  async create(_path: string, body: string, user: HasObjectId, options: IOptionsForCreate = {}): Promise<PageDocument> {
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
+      return this.createV4(_path, body, user, options);
     }
 
     // Values
-    // eslint-disable-next-line no-param-reassign
-    path = this.crowi.xss.process(path); // sanitize path
-    const {
-      format = 'markdown', grantUserGroupIds,
-    } = options;
-    const grant = isTopPage(path) ? PageGrant.GRANT_PUBLIC : options.grant;
+    const path: string = this.crowi.xss.process(_path); // sanitize path
+
+    // Retrieve closest ancestor document
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+    const closestAncestor = await Page.findNonEmptyClosestAncestor(path);
+
+    // Determine grantData
+    const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
+    const grantedUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
+    const grantUserGroupIds = options.grantUserGroupIds
+      ?? (
+        closestAncestor != null
+          ? await this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user)
+          : undefined
+      );
     const grantData = {
       grant,
-      grantedUserIds: grant === PageGrant.GRANT_OWNER ? [user._id] : undefined,
+      grantedUserIds,
       grantUserGroupIds,
     };
 
@@ -3797,7 +3795,7 @@ class PageService implements IPageService {
 
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -3821,12 +3819,7 @@ class PageService implements IPageService {
       throw err;
     }
 
-    if (options.isSynchronously) {
-      await this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
-    else {
-      this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
+    this.createSubOperation(savedPage, user, options, pageOp._id);
 
     return savedPage;
   }
@@ -3912,7 +3905,7 @@ class PageService implements IPageService {
       path: string,
       grantData: {
         grant: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupId?: ObjectIdLike,
       },
   ): Promise<boolean> {
@@ -3921,13 +3914,13 @@ class PageService implements IPageService {
 
   /**
    * @private
-   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantedUsers?: ObjectIdLike[] }'.
+   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
    * This additional value is used to determine the grantedUser of the page to be created by system.
    * This method must not run isGrantNormalized method to validate grant. **If necessary, run it before use this method.**
    * -- Reason 1: This is because it is not expected to use this method when the grant validation is required.
    * -- Reason 2: This is because it is not expected to use this method when the program cannot determine the operator.
    */
-  private async forceCreateBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+  async forceCreateBySystem(path: string, body: string, options: IOptionsForCreate & { grantUserIds?: ObjectIdLike[] }): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -3940,7 +3933,7 @@ class PageService implements IPageService {
     path = this.crowi.xss.process(path); // sanitize path
 
     const {
-      format = 'markdown', grantUserGroupIds, grantedUsers,
+      grantUserGroupIds, grantUserIds,
     } = options;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
 
@@ -3949,12 +3942,12 @@ class PageService implements IPageService {
 
     const grantData = {
       grant,
-      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserIds: isGrantOwner ? grantUserIds : undefined,
       grantUserGroupIds,
     };
 
     // Validate
-    if (isGrantOwner && grantedUsers?.length !== 1) {
+    if (isGrantOwner && grantUserIds?.length !== 1) {
       throw Error('grantedUser must exist when grant is GRANT_OWNER');
     }
     const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
@@ -3970,7 +3963,7 @@ class PageService implements IPageService {
     this.setFieldExceptForGrantRevisionParent(page, path);
 
     // Apply scope
-    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupIds);
+    page.applyScope({ _id: grantUserIds?.[0] }, grant, grantUserGroupIds);
 
     // Set parent
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
@@ -3987,7 +3980,7 @@ class PageService implements IPageService {
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const dummyUser = { _id: new mongoose.Types.ObjectId() };
-    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
     // Update descendantCount
@@ -4016,7 +4009,6 @@ class PageService implements IPageService {
     const options: IOptionsForUpdate = {
       grant,
       userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-      isSyncRevisionToHackmd: false,
     };
 
     return this.updatePage(page, null, null, user, options);
@@ -4091,7 +4083,7 @@ class PageService implements IPageService {
       pageData: PageDocument,
       body: string | null,
       previousBody: string | null,
-      user,
+      user: IUserHasId,
       options: IOptionsForUpdate = {},
   ): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -4230,7 +4222,6 @@ class PageService implements IPageService {
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
       ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
       : pageData.grantedGroups;
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
     // validate multiple group grant before save using pageData and options
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);

+ 10 - 3
apps/app/src/server/service/page/page-service.ts

@@ -1,15 +1,20 @@
 import type EventEmitter from 'events';
 
-import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageInfo, IPageInfoForEntity, IUser,
+} from '@growi/core';
 import type { ObjectId } from 'mongoose';
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 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>,
+  create(path: string, body: string, user: HasObjectId, options: IOptionsForCreate): Promise<PageDocument>,
+  forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
+  updatePage(pageData: PageDocument, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate,): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
@@ -18,5 +23,7 @@ export interface IPageService {
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
+  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }

+ 3 - 0
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -52,6 +52,9 @@ describe.concurrent('isCreatablePage test', () => {
   test('should decide creatable or not', () => {
     expect(isCreatablePage('/hoge')).toBeTruthy();
 
+    // starts with multiple slash
+    expect(isCreatablePage('//multiple-slash')).toBeFalsy();
+
     // edge cases
     expect(isCreatablePage('/me')).toBeFalsy();
     expect(isCreatablePage('/me/')).toBeFalsy();

+ 4 - 3
packages/core/src/utils/page-path-utils/index.ts

@@ -1,5 +1,7 @@
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { IUser } from '~/interfaces';
+
 import { isValidObjectId } from '../objectid-utils';
 import { addTrailingSlash } from '../path-utils';
 
@@ -124,9 +126,8 @@ export const isCreatablePage = (path: string): boolean => {
  * return user's homepage path
  * @param user
  */
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const userHomepagePath = (user: any): string => {
-  if (!user || !user.username) {
+export const userHomepagePath = (user: IUser | null | undefined): string => {
+  if (user?.username == null) {
     return '';
   }
   return `/user/${user.username}`;