Explorar o código

Merge branch 'master' into imprv/170424-improve-assistant-instruction-edit-modal

satof3 hai 7 meses
pai
achega
4fbe0929d0
Modificáronse 100 ficheiros con 2040 adicións e 1244 borrados
  1. 2 2
      .github/mergify.yml
  2. 5 0
      apps/app/.eslintrc.js
  3. 4 0
      apps/app/public/static/locales/en_US/translation.json
  4. 5 1
      apps/app/public/static/locales/fr_FR/translation.json
  5. 4 0
      apps/app/public/static/locales/ja_JP/translation.json
  6. 4 0
      apps/app/public/static/locales/ko_KR/translation.json
  7. 4 0
      apps/app/public/static/locales/zh_CN/translation.json
  8. 51 17
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  9. 72 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx
  10. 20 16
      apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts
  11. 23 21
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts
  12. 23 20
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts
  13. 49 35
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts
  14. 30 17
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts
  15. 39 29
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  16. 12 9
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  17. 15 16
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts
  18. 10 5
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts
  19. 28 25
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts
  20. 15 8
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts
  21. 32 17
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  22. 27 14
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  23. 1 1
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  24. 48 22
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts
  25. 26 12
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts
  26. 10 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts
  27. 12 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts
  28. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts
  29. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts
  30. 3 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts
  31. 11 13
      apps/app/src/features/opentelemetry/server/logger.ts
  32. 44 30
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  33. 1 2
      apps/app/src/features/opentelemetry/server/node-sdk-resource.ts
  34. 68 50
      apps/app/src/features/opentelemetry/server/node-sdk.spec.ts
  35. 52 22
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  36. 52 24
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  37. 15 7
      apps/app/src/features/page-bulk-export/client/stores/modal.tsx
  38. 27 19
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  39. 38 19
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  40. 26 10
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts
  41. 38 16
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  42. 19 10
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  43. 71 28
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  44. 63 26
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  45. 0 4
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  46. 111 52
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  47. 33 17
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  48. 33 11
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  49. 22 17
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  50. 61 34
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  51. 53 26
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts
  52. 9 9
      apps/app/src/interfaces/access-token.ts
  53. 126 65
      apps/app/src/interfaces/activity.ts
  54. 1 2
      apps/app/src/interfaces/admin.ts
  55. 5 5
      apps/app/src/interfaces/apiv3/attachment.ts
  56. 22 24
      apps/app/src/interfaces/apiv3/page.ts
  57. 6 5
      apps/app/src/interfaces/attachment.ts
  58. 18 18
      apps/app/src/interfaces/bookmark-info.ts
  59. 9 9
      apps/app/src/interfaces/cdn.ts
  60. 15 18
      apps/app/src/interfaces/comment.ts
  61. 2 2
      apps/app/src/interfaces/common.ts
  62. 4 7
      apps/app/src/interfaces/crowi-request.ts
  63. 4 4
      apps/app/src/interfaces/customize.ts
  64. 15 15
      apps/app/src/interfaces/editor-methods.ts
  65. 3 1
      apps/app/src/interfaces/errors/external-account-login-error.ts
  66. 4 2
      apps/app/src/interfaces/errors/forgot-password.ts
  67. 4 2
      apps/app/src/interfaces/errors/login-error.ts
  68. 4 2
      apps/app/src/interfaces/errors/user-activation.ts
  69. 1 1
      apps/app/src/interfaces/errors/v3-error.ts
  70. 2 1
      apps/app/src/interfaces/errors/v5-conversion-error.ts
  71. 2 1
      apps/app/src/interfaces/external-auth-provider.ts
  72. 8 6
      apps/app/src/interfaces/file-uploader.ts
  73. 4 3
      apps/app/src/interfaces/g2g-transfer.ts
  74. 16 16
      apps/app/src/interfaces/github-api.ts
  75. 17 17
      apps/app/src/interfaces/in-app-notification.ts
  76. 1 1
      apps/app/src/interfaces/indeterminate-input-elm.ts
  77. 5 5
      apps/app/src/interfaces/ldap.ts
  78. 4 5
      apps/app/src/interfaces/named-query.ts
  79. 22 6
      apps/app/src/interfaces/page-delete-config.ts
  80. 17 14
      apps/app/src/interfaces/page-grant.ts
  81. 5 7
      apps/app/src/interfaces/page-listing-results.ts
  82. 10 8
      apps/app/src/interfaces/page-operation.ts
  83. 4 4
      apps/app/src/interfaces/page-tag-relation.ts
  84. 53 43
      apps/app/src/interfaces/page.ts
  85. 4 4
      apps/app/src/interfaces/paging-result.ts
  86. 2 1
      apps/app/src/interfaces/registration-mode.ts
  87. 13 8
      apps/app/src/interfaces/renderer-options.ts
  88. 54 54
      apps/app/src/interfaces/res/admin/app-settings.ts
  89. 18 18
      apps/app/src/interfaces/search.ts
  90. 7 6
      apps/app/src/interfaces/services/rehype-sanitize.ts
  91. 10 10
      apps/app/src/interfaces/services/renderer.ts
  92. 6 6
      apps/app/src/interfaces/share-link.ts
  93. 2 3
      apps/app/src/interfaces/sidebar-config.ts
  94. 18 18
      apps/app/src/interfaces/tag.ts
  95. 1 1
      apps/app/src/interfaces/theme.ts
  96. 4 4
      apps/app/src/interfaces/transfer-key.ts
  97. 20 14
      apps/app/src/interfaces/ui.ts
  98. 36 25
      apps/app/src/interfaces/user-group-response.ts
  99. 8 3
      apps/app/src/interfaces/user-group.ts
  100. 1 2
      apps/app/src/interfaces/user-trigger-notification.ts

+ 2 - 2
.github/mergify.yml

@@ -12,8 +12,8 @@ queue_rules:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
-      - check-success = test-prod-node22 / build-prod
-      - check-success = test-prod-node22 / launch-prod
+      - check-success ~= test-prod-node22 / build-prod
+      - check-success ~= test-prod-node22 / launch-prod
       - check-success ~= test-prod-node22 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-

+ 5 - 0
apps/app/.eslintrc.js

@@ -37,6 +37,11 @@ module.exports = {
     'src/features/search/**',
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
+    'src/features/page-bulk-export/**',
+    'src/features/opentelemetry/**',
+    'src/stores-universal/**',
+    'src/interfaces/**',
+    'src/utils/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 4 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -675,6 +675,10 @@
       "thread_deleted_failed": "Failed to delete thread",
       "ai_assistant_set_default_success": "Default assistant set successfully",
       "ai_assistant_set_default_failed": "Failed to set default assistant"
+    },
+    "delete_modal": {
+      "title": "Delete Assistant",
+      "confirm_message": "Are you sure you want to delete this assistant?"
     }
   },
   "link_edit": {

+ 5 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -258,7 +258,7 @@
       "title": "Créer un nouveau jeton d'accès",
       "expiredAt_desc": "Sélectionnez la date d'expiration de ce jeton d'accès.",
       "description_desc": "Fournissez une description pour vous aider à identifier ce jeton ultérieurement.",
-      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères."
+      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères.",
       "scope_desc": "Sélectionnez la portée du jeton d'accès."
     },
     "copy_to_clipboard": "Copier dans le presse-papiers"
@@ -669,6 +669,10 @@
       "thread_deleted_failed": "Échec de la suppression de la discussion",
       "ai_assistant_set_default_success": "Assistant par défaut défini avec succès",
       "ai_assistant_set_default_failed": "Échec de la définition de l'assistant par défaut"
+    },
+    "delete_modal": {
+      "title": "Supprimer l'assistant",
+      "confirm_message": "Êtes-vous sûr de vouloir supprimer cet assistant ?"
     }
   },
   "link_edit": {

+ 4 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -708,6 +708,10 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
+      },
+    "delete_modal": {
+      "title": "アシスタントを削除する",
+      "confirm_message": "本当にアシスタントを削除しますか?"
     }
   },
   "link_edit": {

+ 4 - 0
apps/app/public/static/locales/ko_KR/translation.json

@@ -635,6 +635,10 @@
       "thread_deleted_failed": "스레드 삭제 실패",
       "ai_assistant_set_default_success": "기본 어시스턴트 설정 성공",
       "ai_assistant_set_default_failed": "기본 어시스턴트 설정 실패"
+    },
+    "delete_modal": {
+      "title": "어시스턴트 삭제",
+      "confirm_message": "정말로 이 어시스턴트를 삭제하시겠습니까?"
     }
   },
   "link_edit": {

+ 4 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -666,6 +666,10 @@
       "thread_deleted_failed": "删除会话失败",
       "ai_assistant_set_default_success": "已成功设置默认助手",
       "ai_assistant_set_default_failed": "设置默认助手失败"
+    },
+    "delete_modal": {
+      "title": "删除助手",
+      "confirm_message": "确定要删除此助手吗?"
     }
   },
   "link_edit": {

+ 51 - 17
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -15,6 +15,8 @@ import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-a
 import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
+import { DeleteAiAssistantModal } from './DeleteAiAssistantModal';
+
 const logger = loggerFactory('growi:openai:client:components:AiAssistantList');
 
 /*
@@ -25,8 +27,8 @@ type AiAssistantItemProps = {
   aiAssistant: AiAssistantHasId;
   onEditClick: (aiAssistantData: AiAssistantHasId) => void;
   onItemClick: (aiAssistantData: AiAssistantHasId) => void;
+  onDeleteClick: (aiAssistant: AiAssistantHasId) => void;
   onUpdated?: () => void;
-  onDeleted?: (aiAssistantId: string) => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
@@ -34,8 +36,8 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   aiAssistant,
   onEditClick,
   onItemClick,
+  onDeleteClick,
   onUpdated,
-  onDeleted,
 }) => {
 
   const { t } = useTranslation();
@@ -61,18 +63,6 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     }
   }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
 
-  const deleteAiAssistantHandler = useCallback(async() => {
-    try {
-      await deleteAiAssistant(aiAssistant._id);
-      onDeleted?.(aiAssistant._id);
-      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
-    }
-  }, [aiAssistant._id, onDeleted, t]);
-
   const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
   const isPublicAiAssistantOperable = currentUser?.admin
     && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
@@ -95,7 +85,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
           <p className="text-truncate m-auto">{aiAssistant.name}</p>
         </div>
 
-        <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
+        <div className="grw-btn-actions opacity-0 d-flex justify-content-center">
           {isPublicAiAssistantOperable && (
             <button
               type="button"
@@ -125,7 +115,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
                 className="btn btn-link text-secondary p-0"
                 onClick={(e) => {
                   e.stopPropagation();
-                  deleteAiAssistantHandler();
+                  onDeleteClick(aiAssistant);
                 }}
               >
                 <span className="material-symbols-outlined fs-5">delete</span>
@@ -160,6 +150,10 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
 
   const [isCollapsed, setIsCollapsed] = useState(false);
 
+  const [aiAssistantToBeDeleted, setAiAssistantToBeDeleted] = useState<AiAssistantHasId | null>(null);
+  const [isDeleteModalShown, setIsDeleteModalShown] = useState(false);
+  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState('');
+
   const toggleCollapse = useCallback(() => {
     setIsCollapsed((prev) => {
       if (!prev) {
@@ -169,6 +163,38 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
     });
   }, [onCollapsed]);
 
+  const onClickDeleteButton = useCallback((aiAssistant: AiAssistantHasId) => {
+    setAiAssistantToBeDeleted(aiAssistant);
+    setIsDeleteModalShown(true);
+  }, []);
+
+  const onCancelDeleteAiAssistant = useCallback(() => {
+    setAiAssistantToBeDeleted(null);
+    setIsDeleteModalShown(false);
+    setErrorMessageOnDelete('');
+  }, []);
+
+  const onDeleteAiAssistantAfterOperation = useCallback((aiAssistantId: string) => {
+    onCancelDeleteAiAssistant();
+    onDeleted?.(aiAssistantId);
+  }, [onCancelDeleteAiAssistant, onDeleted]);
+
+  const onDeleteAiAssistant = useCallback(async() => {
+    if (aiAssistantToBeDeleted == null) return;
+
+    try {
+      await deleteAiAssistant(aiAssistantToBeDeleted._id);
+      onDeleteAiAssistantAfterOperation(aiAssistantToBeDeleted._id);
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
+    }
+    catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      setErrorMessageOnDelete(message);
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
+    }
+  }, [aiAssistantToBeDeleted, onDeleteAiAssistantAfterOperation, t]);
+
   return (
     <>
       <button
@@ -196,12 +222,20 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
               aiAssistant={assistant}
               onEditClick={openAiAssistantManagementModal}
               onItemClick={openChat}
+              onDeleteClick={onClickDeleteButton}
               onUpdated={onUpdated}
-              onDeleted={onDeleted}
             />
           ))}
         </ul>
       </Collapse>
+
+      <DeleteAiAssistantModal
+        isShown={isDeleteModalShown}
+        aiAssistant={aiAssistantToBeDeleted}
+        errorMessage={errorMessageOnDelete}
+        onCancel={onCancelDeleteAiAssistant}
+        onConfirm={onDeleteAiAssistant}
+      />
     </>
   );
 };

+ 72 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+
+export type DeleteAiAssistantModalProps = {
+  isShown: boolean;
+  aiAssistant: AiAssistantHasId | null;
+  errorMessage?: string;
+  onCancel: () => void;
+  onConfirm: () => void;
+};
+
+export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
+  isShown, aiAssistant, errorMessage, onCancel, onConfirm,
+}) => {
+  const { t } = useTranslation();
+
+  const headerContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined me-1">delete_forever</span>
+        <span className="fw-bold">{t('ai_assistant_substance.delete_modal.title')}</span>
+      </>
+    );
+  };
+
+  const bodyContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return <p className="fw-bold mb-0">{t('ai_assistant_substance.delete_modal.confirm_message')}</p>;
+  };
+
+  const footerContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return (
+      <>
+        {errorMessage && <span className="text-danger">{errorMessage}</span>}
+        <Button color="outline-neutral-secondary" onClick={onCancel}>
+          {t('Cancel')}
+        </Button>
+        <Button color="danger" onClick={onConfirm}>
+          {t('Delete')}
+        </Button>
+      </>
+    );
+  };
+
+  return (
+    <Modal isOpen={isShown} toggle={onCancel} centered>
+      <ModalHeader tag="h5" toggle={onCancel} className="text-danger px-4">
+        {headerContent()}
+      </ModalHeader>
+      <ModalBody className="px-4">
+        {bodyContent()}
+      </ModalBody>
+      <ModalFooter className="px-4 gap-2">
+        {footerContent()}
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 20 - 16
apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts

@@ -2,24 +2,28 @@ import type { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentati
 
 import { anonymizationModules } from './handlers';
 
-export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] = {
-  startIncomingSpanHook: (request) => {
-    // Get URL from IncomingMessage (server-side requests)
-    const incomingRequest = request;
-    const url = incomingRequest.url || '';
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] =
+  {
+    startIncomingSpanHook: (request) => {
+      // Get URL from IncomingMessage (server-side requests)
+      const incomingRequest = request;
+      const url = incomingRequest.url || '';
 
-    const attributes = {};
+      const attributes = {};
 
-    // Use efficient module-based approach
-    for (const anonymizationModule of anonymizationModules) {
-      if (anonymizationModule.canHandle(url)) {
-        const moduleAttributes = anonymizationModule.handle(incomingRequest, url);
-        if (moduleAttributes != null) {
-          Object.assign(attributes, moduleAttributes);
+      // Use efficient module-based approach
+      for (const anonymizationModule of anonymizationModules) {
+        if (anonymizationModule.canHandle(url)) {
+          const moduleAttributes = anonymizationModule.handle(
+            incomingRequest,
+            url,
+          );
+          if (moduleAttributes != null) {
+            Object.assign(attributes, moduleAttributes);
+          }
         }
       }
-    }
 
-    return attributes;
-  },
-};
+      return attributes;
+    },
+  };

+ 23 - 21
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts

@@ -1,26 +1,26 @@
 import type { IncomingMessage } from 'http';
 
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { pageAccessModule } from './page-access-handler';
 
 describe('pageAccessModule', () => {
   describe('canHandle', () => {
     it.each`
-      description                   | url                            | expected
-      ${'root path'}                | ${'/'}                         | ${false}
-      ${'API endpoint'}             | ${'/_api/v3/search'}           | ${false}
-      ${'static resource'}          | ${'/static/css/style.css'}     | ${false}
-      ${'favicon'}                  | ${'/favicon.ico'}              | ${false}
-      ${'assets'}                   | ${'/assets/image.png'}         | ${false}
-      ${'Next.js resource'}         | ${'/_next/chunk.js'}           | ${false}
-      ${'file with extension'}      | ${'/file.pdf'}                 | ${false}
-      ${'Users top page'}           | ${'/user'}                     | ${false}
-      ${'Users homepage'}           | ${'/user/john'}                | ${true}
-      ${'Users page'}               | ${'/user/john/projects'}       | ${true}
-      ${'page path'}                | ${'/path/to/page'}             | ${true}
-      ${'ObjectId path'}            | ${'/58a4569921a8424d00a1aa0e'} | ${false}
-      `('should return $expected for $description', ({ url, expected }) => {
+      description              | url                            | expected
+      ${'root path'}           | ${'/'}                         | ${false}
+      ${'API endpoint'}        | ${'/_api/v3/search'}           | ${false}
+      ${'static resource'}     | ${'/static/css/style.css'}     | ${false}
+      ${'favicon'}             | ${'/favicon.ico'}              | ${false}
+      ${'assets'}              | ${'/assets/image.png'}         | ${false}
+      ${'Next.js resource'}    | ${'/_next/chunk.js'}           | ${false}
+      ${'file with extension'} | ${'/file.pdf'}                 | ${false}
+      ${'Users top page'}      | ${'/user'}                     | ${false}
+      ${'Users homepage'}      | ${'/user/john'}                | ${true}
+      ${'Users page'}          | ${'/user/john/projects'}       | ${true}
+      ${'page path'}           | ${'/path/to/page'}             | ${true}
+      ${'ObjectId path'}       | ${'/58a4569921a8424d00a1aa0e'} | ${false}
+    `('should return $expected for $description', ({ url, expected }) => {
       const result = pageAccessModule.canHandle(url);
       expect(result).toBe(expected);
     });
@@ -29,10 +29,10 @@ describe('pageAccessModule', () => {
   describe('handle', () => {
     describe('URL path anonymization', () => {
       it.each`
-        description                     | url                                 | expectedPath
-        ${'user subpage path'}          | ${'/user/john/projects'}            | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
-        ${'complex path'}               | ${'/wiki/project/documentation'}    | ${'/[HASHED:22ca1a8b9f281349]'}
-        ${'path with special chars'}    | ${'/user-name_123/project!'}        | ${'/[HASHED:7aa6a8f4468baa96]'}
+        description                  | url                              | expectedPath
+        ${'user subpage path'}       | ${'/user/john/projects'}         | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
+        ${'complex path'}            | ${'/wiki/project/documentation'} | ${'/[HASHED:22ca1a8b9f281349]'}
+        ${'path with special chars'} | ${'/user-name_123/project!'}     | ${'/[HASHED:7aa6a8f4468baa96]'}
       `('should handle $description', ({ url, expectedPath }) => {
         // Ensure canHandle returns true before calling handle
         expect(pageAccessModule.canHandle(url)).toBe(true);
@@ -56,7 +56,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
+        'http.target':
+          '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
       });
     });
 
@@ -70,7 +71,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
+        'http.target':
+          '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
       });
     });
   });

+ 23 - 20
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts

@@ -1,20 +1,21 @@
-import { createHash } from 'crypto';
-import type { IncomingMessage } from 'http';
-
 import {
+  getUsernameByPath,
   isCreatablePage,
-  isUsersHomepage,
+  isPermalink,
   isUserPage,
+  isUsersHomepage,
   isUsersTopPage,
-  isPermalink,
-  getUsernameByPath,
 } from '@growi/core/dist/utils/page-path-utils';
 import { diag } from '@opentelemetry/api';
+import { createHash } from 'crypto';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-access-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-access-handler',
+});
 
 /**
  * Create a hash of the given string
@@ -54,7 +55,8 @@ function anonymizeUrlPath(urlPath: string): string {
           const cleanRemainingPath = remainingPath.replace(/^\/+|\/+$/g, '');
           const hashedRemainingPath = hashString(cleanRemainingPath);
           const leadingSlash = remainingPath.startsWith('/') ? '/' : '';
-          const trailingSlash = remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
+          const trailingSlash =
+            remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
 
           return `/user/[USERNAME_HASHED:${hashedUsername}]${leadingSlash}[HASHED:${hashedRemainingPath}]${trailingSlash}`;
         }
@@ -72,11 +74,11 @@ function anonymizeUrlPath(urlPath: string): string {
     // Hash the path and return with original slash structure
     const hashedPath = hashString(cleanPath);
     const leadingSlash = urlPath.startsWith('/') ? '/' : '';
-    const trailingSlash = urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
+    const trailingSlash =
+      urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
 
     return `${leadingSlash}[HASHED:${hashedPath}]${trailingSlash}`;
-  }
-  catch (error) {
+  } catch (error) {
     logger.warn(`Failed to anonymize URL path: ${error}`);
     return urlPath;
   }
@@ -98,11 +100,14 @@ export const pageAccessModule: AnonymizationModule = {
       if (path === '/') return false;
 
       // Exclude static resources first
-      if (path.includes('/static/')
-        || path.includes('/_next/')
-        || path.includes('/favicon')
-        || path.includes('/assets/')
-        || path.includes('.')) { // Exclude file extensions (images, css, js, etc.)
+      if (
+        path.includes('/static/') ||
+        path.includes('/_next/') ||
+        path.includes('/favicon') ||
+        path.includes('/assets/') ||
+        path.includes('.')
+      ) {
+        // Exclude file extensions (images, css, js, etc.)
         return false;
       }
 
@@ -118,8 +123,7 @@ export const pageAccessModule: AnonymizationModule = {
       // Use GROWI's isCreatablePage logic to determine if this is a valid page path
       // This excludes API endpoints, system paths, etc.
       return isCreatablePage(path);
-    }
-    catch {
+    } catch {
       // If URL parsing fails, don't handle it
       return false;
     }
@@ -148,8 +152,7 @@ export const pageAccessModule: AnonymizationModule = {
       }
 
       return null;
-    }
-    catch (error) {
+    } catch (error) {
       logger.warn(`Failed to anonymize page access URL: ${error}`);
       return null;
     }

+ 49 - 35
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageApiModule } from './page-api-handler';
 
@@ -15,22 +13,22 @@ describe('pageApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                                          | url                                                              | expected
-      ${'pages list endpoint'}                             | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
-      ${'subordinated list endpoint'}                      | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
-      ${'check page existence endpoint'}                   | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
-      ${'get page paths with descendant count endpoint'}   | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
-      ${'pages list without query'}                        | ${'/_api/v3/pages/list'}                                         | ${true}
-      ${'subordinated list without query'}                 | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
-      ${'check page existence without query'}              | ${'/_api/v3/page/check-page-existence'}                          | ${true}
-      ${'get page paths without query'}                    | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
-      ${'other pages endpoint'}                            | ${'/_api/v3/pages/create'}                                       | ${false}
-      ${'different API version'}                           | ${'/_api/v2/pages/list'}                                         | ${false}
-      ${'non-page API'}                                    | ${'/_api/v3/search'}                                             | ${false}
-      ${'regular page path'}                               | ${'/page/path'}                                                  | ${false}
-      ${'root path'}                                       | ${'/'}                                                           | ${false}
-      ${'empty URL'}                                       | ${''}                                                            | ${false}
-      ${'partial match but different endpoint'}            | ${'/_api/v3/pages-other/list'}                                   | ${false}
+      description                                        | url                                                              | expected
+      ${'pages list endpoint'}                           | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
+      ${'subordinated list endpoint'}                    | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
+      ${'check page existence endpoint'}                 | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
+      ${'get page paths with descendant count endpoint'} | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
+      ${'pages list without query'}                      | ${'/_api/v3/pages/list'}                                         | ${true}
+      ${'subordinated list without query'}               | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
+      ${'check page existence without query'}            | ${'/_api/v3/page/check-page-existence'}                          | ${true}
+      ${'get page paths without query'}                  | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
+      ${'other pages endpoint'}                          | ${'/_api/v3/pages/create'}                                       | ${false}
+      ${'different API version'}                         | ${'/_api/v2/pages/list'}                                         | ${false}
+      ${'non-page API'}                                  | ${'/_api/v3/search'}                                             | ${false}
+      ${'regular page path'}                             | ${'/page/path'}                                                  | ${false}
+      ${'root path'}                                     | ${'/'}                                                           | ${false}
+      ${'empty URL'}                                     | ${''}                                                            | ${false}
+      ${'partial match but different endpoint'}          | ${'/_api/v3/pages-other/list'}                                   | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -66,7 +64,8 @@ describe('pageApiModule', () => {
 
     describe('pages/subordinated-list endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -74,12 +73,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
         });
       });
 
       it('should handle encoded path parameters', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -87,14 +88,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
         });
       });
     });
 
     describe('page/check-page-existence endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/project/wiki';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/project/wiki';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -102,12 +105,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle multiple parameters including path', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -115,14 +120,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
         });
       });
     });
 
     describe('page/get-page-paths-with-descendant-count endpoint', () => {
       it('should anonymize paths parameter when present', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -130,12 +137,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should handle encoded paths parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,12 +152,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should return null when no paths parameter is present', () => {
-        const url = '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
+        const url =
+          '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(url)).toBe(true);
@@ -217,12 +228,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle empty paths array parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -230,7 +243,8 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
         });
       });
     });

+ 30 - 17
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-api-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-api-handler',
+});
 
 /**
  * Page API anonymization module
@@ -16,10 +17,12 @@ export const pageApiModule: AnonymizationModule = {
    * Check if this module can handle page API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-      || url.includes('/_api/v3/page/get-page-paths-with-descendant-count');
+    return (
+      url.includes('/_api/v3/pages/list') ||
+      url.includes('/_api/v3/pages/subordinated-list') ||
+      url.includes('/_api/v3/page/check-page-existence') ||
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count')
+    );
   },
 
   /**
@@ -30,11 +33,12 @@ export const pageApiModule: AnonymizationModule = {
     let hasAnonymization = false;
 
     // Handle endpoints with 'path' parameter
-    if (url.includes('path=') && (
-      url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-    )) {
+    if (
+      url.includes('path=') &&
+      (url.includes('/_api/v3/pages/list') ||
+        url.includes('/_api/v3/pages/subordinated-list') ||
+        url.includes('/_api/v3/page/check-page-existence'))
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
@@ -42,18 +46,27 @@ export const pageApiModule: AnonymizationModule = {
       // Determine endpoint type for logging
       let endpointType = 'page API';
       if (url.includes('/_api/v3/pages/list')) endpointType = '/pages/list';
-      else if (url.includes('/_api/v3/pages/subordinated-list')) endpointType = '/pages/subordinated-list';
-      else if (url.includes('/_api/v3/page/check-page-existence')) endpointType = '/page/check-page-existence';
+      else if (url.includes('/_api/v3/pages/subordinated-list'))
+        endpointType = '/pages/subordinated-list';
+      else if (url.includes('/_api/v3/page/check-page-existence'))
+        endpointType = '/page/check-page-existence';
 
-      logger.debug(`Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     // Handle page/get-page-paths-with-descendant-count endpoint with paths parameter
-    if (url.includes('/_api/v3/page/get-page-paths-with-descendant-count') && url.includes('paths=')) {
+    if (
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count') &&
+      url.includes('paths=')
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['paths']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
-      logger.debug(`Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     return hasAnonymization ? attributes : null;

+ 39 - 29
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageListingApiModule } from './page-listing-api-handler';
 
@@ -15,20 +13,20 @@ describe('pageListingApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                           | url                                                    | expected
-      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}  | ${true}
-      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}        | ${true}
-      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}            | ${true}
-      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}         | ${true}
-      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                   | ${true}
-      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                       | ${true}
-      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                      | ${false}
-      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                   | ${false}
-      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                              | ${false}
-      ${'regular page path'}                | ${'/page/path'}                                       | ${false}
-      ${'root path'}                        | ${'/'}                                                | ${false}
-      ${'empty URL'}                        | ${''}                                                 | ${false}
-      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}             | ${false}
+      description                           | url                                                  | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'} | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}       | ${true}
+      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}           | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}        | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                  | ${true}
+      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                      | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                     | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                  | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                             | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                      | ${false}
+      ${'root path'}                        | ${'/'}                                               | ${false}
+      ${'empty URL'}                        | ${''}                                                | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}            | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageListingApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -38,7 +36,8 @@ describe('pageListingApiModule', () => {
   describe('handle', () => {
     describe('ancestors-children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -46,12 +45,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
       it('should anonymize empty path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=&limit=5';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=&limit=5';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -60,7 +61,8 @@ describe('pageListingApiModule', () => {
 
         // Empty path parameter should now be anonymized
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
         });
       });
 
@@ -78,7 +80,8 @@ describe('pageListingApiModule', () => {
 
     describe('children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -86,12 +89,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
         });
       });
 
       it('should handle encoded path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -99,7 +104,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
@@ -117,7 +123,8 @@ describe('pageListingApiModule', () => {
 
     describe('info endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/info?path=/wiki/documentation';
+        const originalUrl =
+          '/_api/v3/page-listing/info?path=/wiki/documentation';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,7 +150,8 @@ describe('pageListingApiModule', () => {
 
     describe('edge cases', () => {
       it('should handle URL with complex query parameters', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -151,7 +159,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
         });
       });
 
@@ -165,7 +174,8 @@ describe('pageListingApiModule', () => {
 
         // Fragment should be preserved by anonymizeQueryParams
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
         });
       });
     });

+ 12 - 9
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-listing-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-listing-handler',
+});
 
 /**
  * Page listing API anonymization module
@@ -16,9 +17,11 @@ export const pageListingApiModule: AnonymizationModule = {
    * Check if this module can handle page-listing API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info');
+    return (
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
+    );
     // Add other page-listing endpoints here as needed
   },
 
@@ -31,9 +34,9 @@ export const pageListingApiModule: AnonymizationModule = {
 
     // Handle ancestors-children endpoint
     if (
-      url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
     ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       // Only set attributes if the URL was actually modified

+ 15 - 16
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { searchApiModule } from './search-api-handler';
 
@@ -15,18 +13,18 @@ describe('searchApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                     | url                                 | expected
-      ${'search API endpoint'}        | ${'/_api/search?q=test'}            | ${true}
-      ${'search API without query'}   | ${'/_api/search'}                   | ${true}
-      ${'search endpoint'}            | ${'/_search?q=keyword'}             | ${true}
-      ${'search endpoint without q'}  | ${'/_search'}                       | ${true}
-      ${'nested search API'}          | ${'/admin/_api/search?q=admin'}     | ${true}
-      ${'nested search endpoint'}     | ${'/docs/_search?q=documentation'}  | ${true}
-      ${'other API endpoint'}         | ${'/_api/pages'}                    | ${false}
-      ${'regular page path'}          | ${'/search/results'}                | ${false}
-      ${'similar but different'}      | ${'/_api/search-results'}           | ${false}
-      ${'root path'}                  | ${'/'}                              | ${false}
-      ${'empty URL'}                  | ${''}                               | ${false}
+      description                    | url                                | expected
+      ${'search API endpoint'}       | ${'/_api/search?q=test'}           | ${true}
+      ${'search API without query'}  | ${'/_api/search'}                  | ${true}
+      ${'search endpoint'}           | ${'/_search?q=keyword'}            | ${true}
+      ${'search endpoint without q'} | ${'/_search'}                      | ${true}
+      ${'nested search API'}         | ${'/admin/_api/search?q=admin'}    | ${true}
+      ${'nested search endpoint'}    | ${'/docs/_search?q=documentation'} | ${true}
+      ${'other API endpoint'}        | ${'/_api/pages'}                   | ${false}
+      ${'regular page path'}         | ${'/search/results'}               | ${false}
+      ${'similar but different'}     | ${'/_api/search-results'}          | ${false}
+      ${'root path'}                 | ${'/'}                             | ${false}
+      ${'empty URL'}                 | ${''}                              | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = searchApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -147,7 +145,8 @@ describe('searchApiModule', () => {
 
         // The actual output may have different parameter order due to URL parsing
         expect(result).toEqual({
-          'http.target': '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
+          'http.target':
+            '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
         });
       });
 

+ 10 - 5
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:search-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:search-handler',
+});
 
 /**
  * Search API anonymization module
@@ -17,8 +18,12 @@ export const searchApiModule: AnonymizationModule = {
    */
   canHandle(url: string): boolean {
     // More precise matching to avoid false positives
-    return url.match(/\/_api\/search(\?|$)/) !== null || url.match(/\/_search(\?|$)/) !== null
-           || url.includes('/_api/search/') || url.includes('/_search/');
+    return (
+      url.match(/\/_api\/search(\?|$)/) !== null ||
+      url.match(/\/_search(\?|$)/) !== null ||
+      url.includes('/_api/search/') ||
+      url.includes('/_search/')
+    );
   },
 
   /**

+ 28 - 25
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts

@@ -1,38 +1,41 @@
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { anonymizeQueryParams } from './anonymize-query-params';
 
 describe('anonymizeQueryParams', () => {
   /* eslint-disable max-len */
   it.each`
-    description                       | target                                                                 | paramNames         | expected
-    ${'no matching parameters'}       | ${'/_api/v3/test?other=value&another=test'}                            | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
-    ${'single string parameter'}      | ${'/_api/v3/search?q=sensitive-query'}                                 | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
-    ${'array-style parameters'}       | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}          | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'JSON array format'}            | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}                   | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'multiple parameters'}          | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}                 | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
-    ${'empty parameter value'}        | ${'/_api/v3/test?q=&other=value'}                                      | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'parameter without value'}      | ${'/_api/v3/test?q&other=value'}                                       | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'mixed array and single'}       | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'}      | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'with section'}                 | ${'/_api/v3/test?q=search#section'}                                    | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
-    ${'malformed JSON array'}         | ${'/_api/v3/test?paths=["/user/john"'}                                 | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'empty JSON array'}             | ${'/_api/v3/test?paths=[]'}                                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'single item JSON array'}       | ${'/_api/v3/test?paths=["/user/john"]'}                                | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'URL with no query params'}     | ${'/_api/v3/test'}                                                     | ${['q']}           | ${'/_api/v3/test'}
-    ${'complex path with encoding'}   | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                           | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
+    description                     | target                                                            | paramNames         | expected
+    ${'no matching parameters'}     | ${'/_api/v3/test?other=value&another=test'}                       | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
+    ${'single string parameter'}    | ${'/_api/v3/search?q=sensitive-query'}                            | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
+    ${'array-style parameters'}     | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}     | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'JSON array format'}          | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}              | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'multiple parameters'}        | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}            | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
+    ${'empty parameter value'}      | ${'/_api/v3/test?q=&other=value'}                                 | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'parameter without value'}    | ${'/_api/v3/test?q&other=value'}                                  | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'mixed array and single'}     | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'} | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'with section'}               | ${'/_api/v3/test?q=search#section'}                               | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
+    ${'malformed JSON array'}       | ${'/_api/v3/test?paths=["/user/john"'}                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'empty JSON array'}           | ${'/_api/v3/test?paths=[]'}                                       | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'single item JSON array'}     | ${'/_api/v3/test?paths=["/user/john"]'}                           | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'URL with no query params'}   | ${'/_api/v3/test'}                                                | ${['q']}           | ${'/_api/v3/test'}
+    ${'complex path with encoding'} | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                      | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
   `('should handle $description', ({ target, paramNames, expected }) => {
-  /* eslint-enable max-len */
+    /* eslint-enable max-len */
     const result = anonymizeQueryParams(target, paramNames);
     expect(result).toBe(expected);
   });
 
   it.each`
-    description                    | target                         | paramNames    | expected
-    ${'invalid URL format'}       | ${'not-a-valid-url'}           | ${['q']}      | ${'not-a-valid-url'}
-    ${'empty string target'}      | ${''}                          | ${['q']}      | ${''}
-    ${'empty paramNames array'}   | ${'/_api/v3/test?q=secret'}    | ${[]}         | ${'/_api/v3/test?q=secret'}
-  `('should handle edge cases: $description', ({ target, paramNames, expected }) => {
-    const result = anonymizeQueryParams(target, paramNames);
-    expect(result).toBe(expected);
-  });
+    description                 | target                      | paramNames | expected
+    ${'invalid URL format'}     | ${'not-a-valid-url'}        | ${['q']}   | ${'not-a-valid-url'}
+    ${'empty string target'}    | ${''}                       | ${['q']}   | ${''}
+    ${'empty paramNames array'} | ${'/_api/v3/test?q=secret'} | ${[]}      | ${'/_api/v3/test?q=secret'}
+  `(
+    'should handle edge cases: $description',
+    ({ target, paramNames, expected }) => {
+      const result = anonymizeQueryParams(target, paramNames);
+      expect(result).toBe(expected);
+    },
+  );
 });

+ 15 - 8
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts

@@ -1,6 +1,8 @@
 import { diag } from '@opentelemetry/api';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:anonymize-query-params' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:anonymize-query-params',
+});
 
 /**
  * Try to parse JSON array, return null if invalid
@@ -9,8 +11,7 @@ function tryParseJsonArray(value: string): unknown[] | null {
   try {
     const parsed = JSON.parse(value);
     return Array.isArray(parsed) ? parsed : null;
-  }
-  catch {
+  } catch {
     return null;
   }
 }
@@ -21,7 +22,10 @@ function tryParseJsonArray(value: string): unknown[] | null {
  * @param paramNames - Array of parameter names to anonymize
  * @returns Anonymized HTTP target URL
  */
-export function anonymizeQueryParams(target: string, paramNames: string[]): string {
+export function anonymizeQueryParams(
+  target: string,
+  paramNames: string[],
+): string {
   try {
     const url = new URL(target, 'http://localhost');
     const searchParams = new URLSearchParams(url.search);
@@ -54,10 +58,13 @@ export function anonymizeQueryParams(target: string, paramNames: string[]): stri
       }
     }
 
-    return hasChange ? `${url.pathname}?${searchParams.toString()}${url.hash}` : target;
-  }
-  catch (error) {
-    logger.warn(`Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`);
+    return hasChange
+      ? `${url.pathname}?${searchParams.toString()}${url.hash}`
+      : target;
+  } catch (error) {
+    logger.warn(
+      `Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`,
+    );
     return target;
   }
 }

+ 32 - 17
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -1,6 +1,5 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import crypto from 'crypto';
-
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { configManager } from '~/server/service/config-manager';
@@ -51,11 +50,17 @@ describe('addApplicationMetrics', () => {
   it('should create observable gauge and set up metrics collection', () => {
     addApplicationMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-application-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.configs', {
-      description: 'GROWI instance information (always 1)',
-      unit: '1',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-application-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.configs',
+      {
+        description: 'GROWI instance information (always 1)',
+        unit: '1',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockGauge],
@@ -76,7 +81,7 @@ describe('addApplicationMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async() => {
+    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -89,7 +94,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,
@@ -98,7 +105,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async() => {
+    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return true;
         return undefined;
@@ -116,7 +123,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: '[hashed]',
         site_url_hashed: expectedHash,
@@ -125,7 +134,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle empty external auth types', async() => {
+    it('should handle empty external auth types', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -153,12 +162,14 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
+    it('should handle errors in metrics collection gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
       });
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addApplicationMetrics();
@@ -172,7 +183,7 @@ describe('addApplicationMetrics', () => {
       expect(mockResult.observe).not.toHaveBeenCalled();
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -184,14 +195,18 @@ describe('addApplicationMetrics', () => {
         wikiType: 'open',
         additionalInfo: undefined,
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addApplicationMetrics();
 
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,

+ 27 - 14
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -1,13 +1,15 @@
-import crypto from 'crypto';
-
 import { diag, metrics } from '@opentelemetry/api';
+import crypto from 'crypto';
 
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-metrics:application-metrics');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:application' });
-
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:application-metrics',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:application',
+});
 
 function getSiteUrlHashed(siteUrl: string): string {
   const hasher = crypto.createHash('sha256');
@@ -28,25 +30,36 @@ export function addApplicationMetrics(): void {
 
   // Config metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // Dynamic import to avoid circular dependencies
-        const { growiInfoService } = await import('~/server/service/growi-info');
-        const growiInfo = await growiInfoService.getGrowiInfo({ includeAttachmentInfo: true });
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeAttachmentInfo: true,
+        });
 
-        const isAppSiteUrlHashed = configManager.getConfig('otel:isAppSiteUrlHashed');
+        const isAppSiteUrlHashed = configManager.getConfig(
+          'otel:isAppSiteUrlHashed',
+        );
 
         // Config metrics always have value 1, with information stored in labels
         result.observe(growiInfoGauge, 1, {
           // Dynamic information that can change through configuration
           site_url: isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl,
-          site_url_hashed: isAppSiteUrlHashed ? getSiteUrlHashed(growiInfo.appSiteUrl) : undefined,
+          site_url_hashed: isAppSiteUrlHashed
+            ? getSiteUrlHashed(growiInfo.appSiteUrl)
+            : undefined,
           wiki_type: growiInfo.wikiType,
-          external_auth_types: growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') || '',
+          external_auth_types:
+            growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
+            '',
+        });
+      } catch (error) {
+        loggerDiag.error('Failed to collect application config metrics', {
+          error,
         });
-      }
-      catch (error) {
-        loggerDiag.error('Failed to collect application config metrics', { error });
       }
     },
     [growiInfoGauge],

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,7 +1,7 @@
 export { addApplicationMetrics } from './application-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 
-export const setupCustomMetrics = async(): Promise<void> => {
+export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
 

+ 48 - 22
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts

@@ -1,4 +1,4 @@
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { addUserCountsMetrics } from './user-counts-metrics';
@@ -25,7 +25,7 @@ vi.mock('@opentelemetry/api', () => ({
 const mockGrowiInfoService = {
   getGrowiInfo: vi.fn(),
 };
-vi.mock('~/server/service/growi-info', async() => ({
+vi.mock('~/server/service/growi-info', async () => ({
   growiInfoService: mockGrowiInfoService,
 }));
 
@@ -49,15 +49,24 @@ describe('addUserCountsMetrics', () => {
   it('should create observable gauges and set up metrics collection', () => {
     addUserCountsMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-user-counts-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.total', {
-      description: 'Total number of users in GROWI',
-      unit: 'users',
-    });
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.active', {
-      description: 'Number of active users in GROWI',
-      unit: 'users',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-user-counts-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.total',
+      {
+        description: 'Total number of users in GROWI',
+        unit: 'users',
+      },
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.active',
+      {
+        description: 'Number of active users in GROWI',
+        unit: 'users',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockUserCountGauge, mockActiveUserCountGauge],
@@ -76,7 +85,7 @@ describe('addUserCountsMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe user count metrics when growi info is available', async() => {
+    it('should observe user count metrics when growi info is available', async () => {
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();
@@ -85,12 +94,17 @@ describe('addUserCountsMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeUserCountInfo: true });
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includeUserCountInfo: true,
+      });
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 150);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 75);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        75,
+      );
     });
 
-    it('should use default values when user counts are missing', async() => {
+    it('should use default values when user counts are missing', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutCounts = {
@@ -98,7 +112,9 @@ describe('addUserCountsMetrics', () => {
           // Missing currentUsersCount and currentActiveUsersCount
         },
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutCounts);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutCounts,
+      );
 
       addUserCountsMetrics();
 
@@ -106,16 +122,21 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutAdditionalInfo = {
         // Missing additionalInfo entirely
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addUserCountsMetrics();
 
@@ -123,11 +144,16 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+    it('should handle errors in metrics collection gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();

+ 26 - 12
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts

@@ -3,7 +3,9 @@ import { diag, metrics } from '@opentelemetry/api';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:custom-metrics:user-counts');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:user-counts' });
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:user-counts',
+});
 
 export function addUserCountsMetrics(): void {
   logger.info('Starting user counts metrics collection');
@@ -17,25 +19,37 @@ export function addUserCountsMetrics(): void {
   });
 
   // Active user count gauge
-  const activeUserCountGauge = meter.createObservableGauge('growi.users.active', {
-    description: 'Number of active users in GROWI',
-    unit: 'users',
-  });
+  const activeUserCountGauge = meter.createObservableGauge(
+    'growi.users.active',
+    {
+      description: 'Number of active users in GROWI',
+      unit: 'users',
+    },
+  );
 
   // User metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // Dynamic import to avoid circular dependencies
-        const { growiInfoService } = await import('~/server/service/growi-info');
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
 
-        const growiInfo = await growiInfoService.getGrowiInfo({ includeUserCountInfo: true });
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeUserCountInfo: true,
+        });
 
         // Observe user count metrics
-        result.observe(userCountGauge, growiInfo.additionalInfo?.currentUsersCount || 0);
-        result.observe(activeUserCountGauge, growiInfo.additionalInfo?.currentActiveUsersCount || 0);
-      }
-      catch (error) {
+        result.observe(
+          userCountGauge,
+          growiInfo.additionalInfo?.currentUsersCount || 0,
+        );
+        result.observe(
+          activeUserCountGauge,
+          growiInfo.additionalInfo?.currentActiveUsersCount || 0,
+        );
+      } catch (error) {
         loggerDiag.error('Failed to collect user counts metrics', { error });
       }
     },

+ 10 - 6
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts

@@ -21,7 +21,7 @@ describe('getApplicationResourceAttributes', () => {
     vi.clearAllMocks();
   });
 
-  it('should return complete application resource attributes when growi info is available', async() => {
+  it('should return complete application resource attributes when growi info is available', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -43,10 +43,12 @@ describe('getApplicationResourceAttributes', () => {
       'growi.installedAt': '2023-01-01T00:00:00.000Z',
       'growi.installedAt.by_oldest_user': '2023-01-01T00:00:00.000Z',
     });
-    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeInstalledInfo: true });
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+      includeInstalledInfo: true,
+    });
   });
 
-  it('should handle missing additionalInfo gracefully', async() => {
+  it('should handle missing additionalInfo gracefully', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -66,15 +68,17 @@ describe('getApplicationResourceAttributes', () => {
     });
   });
 
-  it('should return empty object when growiInfoService throws error', async() => {
-    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+  it('should return empty object when growiInfoService throws error', async () => {
+    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+      new Error('Service unavailable'),
+    );
 
     const result = await getApplicationResourceAttributes();
 
     expect(result).toEqual({});
   });
 
-  it('should handle partial additionalInfo data', async() => {
+  it('should handle partial additionalInfo data', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'docker',

+ 12 - 6
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -2,7 +2,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:application');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:application',
+);
 
 /**
  * Get application fixed information as OpenTelemetry Resource Attributes
@@ -15,7 +17,9 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
     // Dynamic import to avoid circular dependencies
     const { growiInfoService } = await import('~/server/service/growi-info');
 
-    const growiInfo = await growiInfoService.getGrowiInfo({ includeInstalledInfo: true });
+    const growiInfo = await growiInfoService.getGrowiInfo({
+      includeInstalledInfo: true,
+    });
 
     const attributes: Attributes = {
       // Service configuration (rarely changes after system setup)
@@ -25,15 +29,17 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
 
       // Installation information (fixed values)
       'growi.installedAt': growiInfo.additionalInfo?.installedAt?.toISOString(),
-      'growi.installedAt.by_oldest_user': growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
+      'growi.installedAt.by_oldest_user':
+        growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
     };
 
     logger.info('Application resource attributes collected', { attributes });
 
     return attributes;
-  }
-  catch (error) {
-    logger.error('Failed to collect application resource attributes', { error });
+  } catch (error) {
+    logger.error('Failed to collect application resource attributes', {
+      error,
+    });
     return {};
   }
 }

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts

@@ -1,2 +1,2 @@
-export { getOsResourceAttributes } from './os-resource-attributes';
 export { getApplicationResourceAttributes } from './application-resource-attributes';
+export { getOsResourceAttributes } from './os-resource-attributes';

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

@@ -16,7 +16,7 @@ describe('getOsResourceAttributes', () => {
     totalmem: ReturnType<typeof vi.fn>;
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
     // Get the mocked os module
     mockOs = await vi.importMock('node:os');

+ 3 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts

@@ -4,7 +4,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:os');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:os',
+);
 
 /**
  * Get OS information as OpenTelemetry Resource Attributes

+ 11 - 13
apps/app/src/features/opentelemetry/server/logger.ts

@@ -1,13 +1,14 @@
-import { diag, type DiagLogger } from '@opentelemetry/api';
+import { type DiagLogger, diag } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:diag');
 
-
 class DiagLoggerBunyanAdapter implements DiagLogger {
-
-  private parseMessage(message: string, args: unknown[]): [logMessage: string, data: object] {
+  private parseMessage(
+    message: string,
+    args: unknown[],
+  ): [logMessage: string, data: object] {
     let logMessage = message;
     let data = {};
 
@@ -17,12 +18,12 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
       if (typeof parsedMessage === 'object' && parsedMessage !== null) {
         data = parsedMessage;
         // if parsed successfully, use 'message' property as log message
-        logMessage = 'message' in data && typeof data.message === 'string'
-          ? data.message
-          : message;
+        logMessage =
+          'message' in data && typeof data.message === 'string'
+            ? data.message
+            : message;
       }
-    }
-    catch (e) {
+    } catch (e) {
       // do nothing if the message is not a JSON string
     }
 
@@ -34,8 +35,7 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
           try {
             const parsed = JSON.parse(arg);
             return { ...acc, ...parsed };
-          }
-          catch (e) {
+          } catch (e) {
             return { ...acc, additionalInfo: arg };
           }
         }
@@ -66,10 +66,8 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
   verbose(message: string, ...args): void {
     logger.trace(...this.parseMessage(message, args));
   }
-
 }
 
-
 export const initLogger = (): void => {
   // Enable global logger for OpenTelemetry
   diag.setLogger(new DiagLoggerBunyanAdapter());

+ 44 - 30
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -5,7 +5,10 @@ import type { Resource } from '@opentelemetry/resources';
 import { resourceFromAttributes } from '@opentelemetry/resources';
 import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
 import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
-import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
+import {
+  ATTR_SERVICE_NAME,
+  ATTR_SERVICE_VERSION,
+} from '@opentelemetry/semantic-conventions';
 
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -14,8 +17,8 @@ import { httpInstrumentationConfig as httpInstrumentationConfigForAnonymize } fr
 import { ATTR_SERVICE_INSTANCE_ID } from './semconv';
 
 type Option = {
-  enableAnonymization?: boolean,
-}
+  enableAnonymization?: boolean;
+};
 
 type Configuration = Partial<NodeSDKConfiguration> & {
   resource: Resource;
@@ -34,7 +37,9 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
     });
 
     // Data anonymization configuration
-    const httpInstrumentationConfig = opts?.enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
+    const httpInstrumentationConfig = opts?.enableAnonymization
+      ? httpInstrumentationConfigForAnonymize
+      : {};
 
     configuration = {
       resource,
@@ -43,23 +48,24 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
         exporter: new OTLPMetricExporter(),
         exportIntervalMillis: 300000, // 5 minute
       }),
-      instrumentations: [getNodeAutoInstrumentations({
-        '@opentelemetry/instrumentation-bunyan': {
-          enabled: false,
-        },
-        // disable fs instrumentation since this generates very large amount of traces
-        // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
-        '@opentelemetry/instrumentation-fs': {
-          enabled: false,
-        },
-        // HTTP instrumentation with anonymization
-        '@opentelemetry/instrumentation-http': {
-          enabled: true,
-          ...httpInstrumentationConfig,
-        },
-      })],
+      instrumentations: [
+        getNodeAutoInstrumentations({
+          '@opentelemetry/instrumentation-bunyan': {
+            enabled: false,
+          },
+          // disable fs instrumentation since this generates very large amount of traces
+          // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
+          '@opentelemetry/instrumentation-fs': {
+            enabled: false,
+          },
+          // HTTP instrumentation with anonymization
+          '@opentelemetry/instrumentation-http': {
+            enabled: true,
+            ...httpInstrumentationConfig,
+          },
+        }),
+      ],
     };
-
   }
 
   return configuration;
@@ -69,19 +75,27 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
  * Generate additional attributes after database initialization
  * This function should be called after database is available
  */
-export const generateAdditionalResourceAttributes = async(opts?: Option): Promise<Resource> => {
+export const generateAdditionalResourceAttributes = async (
+  opts?: Option,
+): Promise<Resource> => {
   if (resource == null) {
-    throw new Error('Resource is not initialized. Call generateNodeSDKConfiguration first.');
+    throw new Error(
+      'Resource is not initialized. Call generateNodeSDKConfiguration first.',
+    );
   }
 
-  const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
-    ?? configManager.getConfig('app:serviceInstanceId');
+  const serviceInstanceId =
+    configManager.getConfig('otel:serviceInstanceId') ??
+    configManager.getConfig('app:serviceInstanceId');
 
-  const { getApplicationResourceAttributes, getOsResourceAttributes } = await import('./custom-resource-attributes');
+  const { getApplicationResourceAttributes, getOsResourceAttributes } =
+    await import('./custom-resource-attributes');
 
-  return resource.merge(resourceFromAttributes({
-    [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
-    ...await getApplicationResourceAttributes(),
-    ...await getOsResourceAttributes(),
-  }));
+  return resource.merge(
+    resourceFromAttributes({
+      [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
+      ...(await getApplicationResourceAttributes()),
+      ...(await getOsResourceAttributes()),
+    }),
+  );
 };

+ 1 - 2
apps/app/src/features/opentelemetry/server/node-sdk-resource.ts

@@ -23,8 +23,7 @@ export const setResource = (sdk: NodeSDK, resource: Resource): void => {
   // Verify that we can access the _resource property
   try {
     getResource(sdk);
-  }
-  catch (e) {
+  } catch (e) {
     throw new Error('Failed to access SDK resource');
   }
 

+ 68 - 50
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -3,7 +3,11 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { setupAdditionalResourceAttributes, initInstrumentation, startOpenTelemetry } from './node-sdk';
+import {
+  initInstrumentation,
+  setupAdditionalResourceAttributes,
+  startOpenTelemetry,
+} from './node-sdk';
 import { getResource } from './node-sdk-resource';
 
 // Only mock configManager as it's external to what we're testing
@@ -37,24 +41,28 @@ vi.mock('~/server/service/growi-info', () => ({
 describe('node-sdk', () => {
   // Helper functions to reduce duplication
   const mockInstrumentationEnabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? true : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? true : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
   const mockInstrumentationDisabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? false : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? false : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
 
     // Reset SDK instance using __testing__ export
@@ -66,14 +74,14 @@ describe('node-sdk', () => {
   });
 
   describe('initInstrumentation', () => {
-    it('should call setupCustomMetrics when instrumentation is enabled', async() => {
+    it('should call setupCustomMetrics when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
       await initInstrumentation();
     });
 
-    it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
+    it('should not call setupCustomMetrics when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -85,7 +93,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should create SDK instance when instrumentation is enabled', async() => {
+    it('should create SDK instance when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
@@ -98,7 +106,7 @@ describe('node-sdk', () => {
       expect(sdkInstance).toBeInstanceOf(NodeSDK);
     });
 
-    it('should not create SDK instance when instrumentation is disabled', async() => {
+    it('should not create SDK instance when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -112,17 +120,19 @@ describe('node-sdk', () => {
   });
 
   describe('setupAdditionalResourceAttributes', () => {
-    it('should update service.instance.id when app:serviceInstanceId is available', async() => {
+    it('should update service.instance.id when app:serviceInstanceId is available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-        // For service instance IDs, only respond when no source is specified
-        if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+          // For service instance IDs, only respond when no source is specified
+          if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          return undefined;
+        },
+      );
 
       // Initialize SDK first
       await initInstrumentation();
@@ -147,25 +157,29 @@ describe('node-sdk', () => {
 
       // Verify that resource was updated with app:serviceInstanceId
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('test-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'test-instance-id',
+      );
     });
 
-    it('should update service.instance.id with otel:serviceInstanceId if available', async() => {
+    it('should update service.instance.id with otel:serviceInstanceId if available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-
-        // For service instance IDs, only respond when no source is specified
-        if (source === undefined) {
-          if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
-          if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        }
-
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+
+          // For service instance IDs, only respond when no source is specified
+          if (source === undefined) {
+            if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
+            if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          }
+
+          return undefined;
+        },
+      );
 
       // Initialize SDK
       await initInstrumentation();
@@ -184,10 +198,12 @@ describe('node-sdk', () => {
 
       // Verify that otel:serviceInstanceId was used
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('otel-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'otel-instance-id',
+      );
     });
 
-    it('should handle gracefully when instrumentation is disabled', async() => {
+    it('should handle gracefully when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -195,12 +211,14 @@ describe('node-sdk', () => {
       await initInstrumentation();
 
       // Call setupAdditionalResourceAttributes should not throw error
-      await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
+      await expect(
+        setupAdditionalResourceAttributes(),
+      ).resolves.toBeUndefined();
     });
   });
 
   describe('startOpenTelemetry', () => {
-    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async() => {
+    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled
@@ -228,7 +246,7 @@ describe('node-sdk', () => {
       }
     });
 
-    it('should not start SDK when instrumentation is disabled', async() => {
+    it('should not start SDK when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -244,7 +262,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should not start SDK when SDK instance does not exist', async() => {
+    it('should not start SDK when SDK instance does not exist', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled but don't initialize SDK

+ 52 - 22
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -16,28 +16,37 @@ let sdkInstance: NodeSDK | undefined;
  * Since otel library sees it.
  */
 function overwriteSdkDisabled(): void {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
-
-  if (instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'true'
-    || process.env.OTEL_SDK_DISABLED === '1'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.");
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
+
+  if (
+    instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'true' ||
+      process.env.OTEL_SDK_DISABLED === '1')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.",
+    );
     process.env.OTEL_SDK_DISABLED = 'false';
     return;
   }
 
-  if (!instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'false'
-    || process.env.OTEL_SDK_DISABLED === '0'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.");
+  if (
+    !instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'false' ||
+      process.env.OTEL_SDK_DISABLED === '0')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.",
+    );
     process.env.OTEL_SDK_DISABLED = 'true';
     return;
   }
 }
 
-export const initInstrumentation = async(): Promise<void> => {
+export const initInstrumentation = async (): Promise<void> => {
   if (sdkInstance != null) {
     logger.warn('OpenTelemetry instrumentation already started');
     return;
@@ -48,7 +57,10 @@ export const initInstrumentation = async(): Promise<void> => {
 
   overwriteSdkDisabled();
 
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
   if (instrumentationEnabled) {
     logger.info(`GROWI now collects anonymous telemetry.
 
@@ -66,9 +78,14 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
 
     // instanciate NodeSDK
     const { NodeSDK } = await import('@opentelemetry/sdk-node');
-    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+    const { generateNodeSDKConfiguration } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
 
@@ -76,20 +93,30 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
   }
 };
 
-export const setupAdditionalResourceAttributes = async(): Promise<void> => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+export const setupAdditionalResourceAttributes = async (): Promise<void> => {
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled) {
     if (sdkInstance == null) {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
 
-    const { generateAdditionalResourceAttributes } = await import('./node-sdk-configuration');
+    const { generateAdditionalResourceAttributes } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     // generate additional resource attributes
-    const updatedResource = await generateAdditionalResourceAttributes({ enableAnonymization });
+    const updatedResource = await generateAdditionalResourceAttributes({
+      enableAnonymization,
+    });
 
     // set resource to sdk instance
     setResource(sdkInstance, updatedResource);
@@ -97,7 +124,10 @@ export const setupAdditionalResourceAttributes = async(): Promise<void> => {
 };
 
 export const startOpenTelemetry = (): void => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled && sdkInstance != null) {
     if (sdkInstance == null) {

+ 52 - 24
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -1,15 +1,14 @@
-import { useState, type JSX } from 'react';
-
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { type JSX, useState } from 'react';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import { PageBulkExportFormat } from '~/features/page-bulk-export/interfaces/page-bulk-export';
-import { useIsPdfBulkExportEnabled } from '~/stores-universal/context';
 import { useCurrentPagePath } from '~/stores/page';
+import { useIsPdfBulkExportEnabled } from '~/stores-universal/context';
 
 const PageBulkExportSelectModal = (): JSX.Element => {
   const { t } = useTranslation();
@@ -18,35 +17,40 @@ const PageBulkExportSelectModal = (): JSX.Element => {
   const { data: isPdfBulkExportEnabled } = useIsPdfBulkExportEnabled();
 
   const [isRestartModalOpened, setIsRestartModalOpened] = useState(false);
-  const [formatMemoForRestart, setFormatMemoForRestart] = useState<PageBulkExportFormat | undefined>(undefined);
-  const [duplicateJobInfo, setDuplicateJobInfo] = useState<{createdAt: string} | undefined>(undefined);
+  const [formatMemoForRestart, setFormatMemoForRestart] = useState<
+    PageBulkExportFormat | undefined
+  >(undefined);
+  const [duplicateJobInfo, setDuplicateJobInfo] = useState<
+    { createdAt: string } | undefined
+  >(undefined);
 
-  const startBulkExport = async(format: PageBulkExportFormat) => {
+  const startBulkExport = async (format: PageBulkExportFormat) => {
     try {
       setFormatMemoForRestart(format);
       await apiv3Post('/page-bulk-export', { path: currentPagePath, format });
       toastSuccess(t('page_export.bulk_export_started'));
-    }
-    catch (e) {
+    } catch (e) {
       const errorCode = e?.[0].code ?? 'page_export.failed_to_export';
       if (errorCode === 'page_export.duplicate_bulk_export_job_error') {
         setDuplicateJobInfo(e[0].args.duplicateJob);
         setIsRestartModalOpened(true);
-      }
-      else {
+      } else {
         toastError(t(errorCode));
       }
     }
     close();
   };
 
-  const restartBulkExport = async() => {
+  const restartBulkExport = async () => {
     if (formatMemoForRestart != null) {
       try {
-        await apiv3Post('/page-bulk-export', { path: currentPagePath, format: formatMemoForRestart, restartJob: true });
+        await apiv3Post('/page-bulk-export', {
+          path: currentPagePath,
+          format: formatMemoForRestart,
+          restartJob: true,
+        });
         toastSuccess(t('page_export.bulk_export_started'));
-      }
-      catch (e) {
+      } catch (e) {
         toastError(t('page_export.failed_to_export'));
       }
       setIsRestartModalOpened(false);
@@ -63,7 +67,10 @@ const PageBulkExportSelectModal = (): JSX.Element => {
           <ModalBody>
             <p className="card custom-card bg-warning-subtle pt-3 px-3">
               {t('page_export.bulk_export_download_explanation')}
-              <span className="mt-3"><span className="material-symbols-outlined me-1">warning</span>{t('Warning')}</span>
+              <span className="mt-3">
+                <span className="material-symbols-outlined me-1">warning</span>
+                {t('Warning')}
+              </span>
               <ul className="mt-2">
                 <li>{t('page_export.bulk_export_exec_time_warning')}</li>
                 <li>{t('page_export.large_bulk_export_warning')}</li>
@@ -71,11 +78,19 @@ const PageBulkExportSelectModal = (): JSX.Element => {
             </p>
             {t('page_export.choose_export_format')}:
             <div className="d-flex justify-content-center mt-3">
-              <button className="btn btn-primary" type="button" onClick={() => startBulkExport(PageBulkExportFormat.md)}>
+              <button
+                className="btn btn-primary"
+                type="button"
+                onClick={() => startBulkExport(PageBulkExportFormat.md)}
+              >
                 {t('page_export.markdown')}
               </button>
               {isPdfBulkExportEnabled && (
-                <button className="btn btn-primary ms-2" type="button" onClick={() => startBulkExport(PageBulkExportFormat.pdf)}>
+                <button
+                  className="btn btn-primary ms-2"
+                  type="button"
+                  onClick={() => startBulkExport(PageBulkExportFormat.pdf)}
+                >
                   PDF
                 </button>
               )}
@@ -84,7 +99,10 @@ const PageBulkExportSelectModal = (): JSX.Element => {
         </Modal>
       )}
 
-      <Modal isOpen={isRestartModalOpened} toggle={() => setIsRestartModalOpened(false)}>
+      <Modal
+        isOpen={isRestartModalOpened}
+        toggle={() => setIsRestartModalOpened(false)}
+      >
         <ModalHeader tag="h4" toggle={() => setIsRestartModalOpened(false)}>
           {t('page_export.export_in_progress')}
         </ModalHeader>
@@ -93,20 +111,30 @@ const PageBulkExportSelectModal = (): JSX.Element => {
           <div className="text-danger">
             {t('page_export.export_cancel_warning')}:
           </div>
-          { duplicateJobInfo && (
+          {duplicateJobInfo && (
             <div className="my-1">
               <ul>
-                { formatMemoForRestart && (
+                {formatMemoForRestart && (
                   <li>
-                    {t('page_export.format')}: {formatMemoForRestart === PageBulkExportFormat.md ? t('page_export.markdown') : 'PDF'}
+                    {t('page_export.format')}:{' '}
+                    {formatMemoForRestart === PageBulkExportFormat.md
+                      ? t('page_export.markdown')
+                      : 'PDF'}
                   </li>
                 )}
-                <li>{t('page_export.started_on')}: {format(new Date(duplicateJobInfo.createdAt), 'MM/dd HH:mm')}</li>
+                <li>
+                  {t('page_export.started_on')}:{' '}
+                  {format(new Date(duplicateJobInfo.createdAt), 'MM/dd HH:mm')}
+                </li>
               </ul>
             </div>
           )}
           <div className="d-flex justify-content-center mt-3">
-            <button className="btn btn-primary" type="button" onClick={() => restartBulkExport()}>
+            <button
+              className="btn btn-primary"
+              type="button"
+              onClick={() => restartBulkExport()}
+            >
               {t('page_export.restart')}
             </button>
           </div>

+ 15 - 7
apps/app/src/features/page-bulk-export/client/stores/modal.tsx

@@ -3,17 +3,25 @@ import type { SWRResponse } from 'swr';
 import { useStaticSWR } from '../../../../stores/use-static-swr';
 
 type PageBulkExportSelectModalStatus = {
-  isOpened: boolean,
-}
+  isOpened: boolean;
+};
 
 type PageBulkExportSelectModalUtils = {
-  open(): Promise<void>,
-  close(): Promise<void>,
-}
+  open(): Promise<void>;
+  close(): Promise<void>;
+};
 
-export const usePageBulkExportSelectModal = (): SWRResponse<PageBulkExportSelectModalStatus, Error> & PageBulkExportSelectModalUtils => {
+export const usePageBulkExportSelectModal = (): SWRResponse<
+  PageBulkExportSelectModalStatus,
+  Error
+> &
+  PageBulkExportSelectModalUtils => {
   const initialStatus: PageBulkExportSelectModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<PageBulkExportSelectModalStatus, Error>('pageBulkExportSelectModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useStaticSWR<PageBulkExportSelectModalStatus, Error>(
+    'pageBulkExportSelectModal',
+    undefined,
+    { fallbackData: initialStatus },
+  );
 
   return {
     ...swrResponse,

+ 27 - 19
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -1,6 +1,10 @@
 import type {
   HasObjectId,
-  IAttachment, IPage, IRevision, IUser, Ref,
+  IAttachment,
+  IPage,
+  IRevision,
+  IUser,
+  Ref,
 } from '@growi/core';
 
 export const PageBulkExportFormat = {
@@ -8,7 +12,8 @@ export const PageBulkExportFormat = {
   pdf: 'pdf',
 } as const;
 
-export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
+export type PageBulkExportFormat =
+  (typeof PageBulkExportFormat)[keyof typeof PageBulkExportFormat];
 
 export const PageBulkExportJobInProgressStatus = {
   initializing: 'initializing', // preparing for export
@@ -22,28 +27,31 @@ export const PageBulkExportJobStatus = {
   failed: 'failed',
 } as const;
 
-export type PageBulkExportJobStatus = typeof PageBulkExportJobStatus[keyof typeof PageBulkExportJobStatus]
+export type PageBulkExportJobStatus =
+  (typeof PageBulkExportJobStatus)[keyof typeof PageBulkExportJobStatus];
 
 export interface IPageBulkExportJob {
-  user: Ref<IUser>, // user that started export job
-  page: Ref<IPage>, // the root page of page tree to export
-  lastExportedPagePath?: string, // the path of page that was exported to the fs last
-  format: PageBulkExportFormat,
-  completedAt?: Date, // the date at which job was completed
-  attachment?: Ref<IAttachment>,
-  status: PageBulkExportJobStatus,
-  statusOnPreviousCronExec?: PageBulkExportJobStatus, // status on previous cron execution
-  revisionListHash?: string, // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
-  restartFlag: boolean, // flag to restart the job
-  createdAt?: Date,
-  updatedAt?: Date
+  user: Ref<IUser>; // user that started export job
+  page: Ref<IPage>; // the root page of page tree to export
+  lastExportedPagePath?: string; // the path of page that was exported to the fs last
+  format: PageBulkExportFormat;
+  completedAt?: Date; // the date at which job was completed
+  attachment?: Ref<IAttachment>;
+  status: PageBulkExportJobStatus;
+  statusOnPreviousCronExec?: PageBulkExportJobStatus; // status on previous cron execution
+  revisionListHash?: string; // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
+  restartFlag: boolean; // flag to restart the job
+  createdAt?: Date;
+  updatedAt?: Date;
 }
 
-export interface IPageBulkExportJobHasId extends IPageBulkExportJob, HasObjectId {}
+export interface IPageBulkExportJobHasId
+  extends IPageBulkExportJob,
+    HasObjectId {}
 
 // snapshot of page info to upload
 export interface IPageBulkExportPageSnapshot {
-  pageBulkExportJob: Ref<IPageBulkExportJob>,
-  path: string, // page path when export was stared
-  revision: Ref<IRevision>, // page revision when export was stared
+  pageBulkExportJob: Ref<IPageBulkExportJob>;
+  path: string; // page path when export was stared
+  revision: Ref<IRevision>; // page revision when export was stared
 }

+ 38 - 19
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts

@@ -3,27 +3,46 @@ import { type Document, type Model, Schema } from 'mongoose';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import type { IPageBulkExportJob } from '../../interfaces/page-bulk-export';
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 
-export interface PageBulkExportJobDocument extends IPageBulkExportJob, Document {}
+export interface PageBulkExportJobDocument
+  extends IPageBulkExportJob,
+    Document {}
 
-export type PageBulkExportJobModel = Model<PageBulkExportJobDocument>
+export type PageBulkExportJobModel = Model<PageBulkExportJobDocument>;
 
-const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
-  page: { type: Schema.Types.ObjectId, ref: 'Page', required: true },
-  lastExportedPagePath: { type: String },
-  format: { type: String, enum: Object.values(PageBulkExportFormat), required: true },
-  completedAt: { type: Date },
-  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
-  status: {
-    type: String, enum: Object.values(PageBulkExportJobStatus), required: true, default: PageBulkExportJobStatus.initializing,
+const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>(
+  {
+    user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+    page: { type: Schema.Types.ObjectId, ref: 'Page', required: true },
+    lastExportedPagePath: { type: String },
+    format: {
+      type: String,
+      enum: Object.values(PageBulkExportFormat),
+      required: true,
+    },
+    completedAt: { type: Date },
+    attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
+    status: {
+      type: String,
+      enum: Object.values(PageBulkExportJobStatus),
+      required: true,
+      default: PageBulkExportJobStatus.initializing,
+    },
+    statusOnPreviousCronExec: {
+      type: String,
+      enum: Object.values(PageBulkExportJobStatus),
+    },
+    restartFlag: { type: Boolean, required: true, default: false },
+    revisionListHash: { type: String },
   },
-  statusOnPreviousCronExec: {
-    type: String, enum: Object.values(PageBulkExportJobStatus),
-  },
-  restartFlag: { type: Boolean, required: true, default: false },
-  revisionListHash: { type: String },
-}, { timestamps: true });
+  { timestamps: true },
+);
 
-export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);
+export default getOrCreateModel<
+  PageBulkExportJobDocument,
+  PageBulkExportJobModel
+>('PageBulkExportJob', pageBulkExportJobSchema);

+ 26 - 10
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts

@@ -4,16 +4,32 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import type { IPageBulkExportPageSnapshot } from '../../interfaces/page-bulk-export';
 
-export interface PageBulkExportPageSnapshotDocument extends IPageBulkExportPageSnapshot, Document {}
+export interface PageBulkExportPageSnapshotDocument
+  extends IPageBulkExportPageSnapshot,
+    Document {}
 
-export type PageBulkExportPageSnapshotModel = Model<PageBulkExportPageSnapshotDocument>
+export type PageBulkExportPageSnapshotModel =
+  Model<PageBulkExportPageSnapshotDocument>;
 
-const pageBulkExportPageInfoSchema = new Schema<PageBulkExportPageSnapshotDocument>({
-  pageBulkExportJob: { type: Schema.Types.ObjectId, ref: 'PageBulkExportJob', required: true },
-  path: { type: String, required: true },
-  revision: { type: Schema.Types.ObjectId, ref: 'Revision', required: true },
-}, { timestamps: true });
+const pageBulkExportPageInfoSchema =
+  new Schema<PageBulkExportPageSnapshotDocument>(
+    {
+      pageBulkExportJob: {
+        type: Schema.Types.ObjectId,
+        ref: 'PageBulkExportJob',
+        required: true,
+      },
+      path: { type: String, required: true },
+      revision: {
+        type: Schema.Types.ObjectId,
+        ref: 'Revision',
+        required: true,
+      },
+    },
+    { timestamps: true },
+  );
 
-export default getOrCreateModel<PageBulkExportPageSnapshotDocument, PageBulkExportPageSnapshotModel>(
-  'PageBulkExportPageSnapshot', pageBulkExportPageInfoSchema,
-);
+export default getOrCreateModel<
+  PageBulkExportPageSnapshotDocument,
+  PageBulkExportPageSnapshotModel
+>('PageBulkExportPageSnapshot', pageBulkExportPageInfoSchema);

+ 38 - 16
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -8,19 +8,24 @@ import type Crowi from '~/server/crowi';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { pageBulkExportService, DuplicateBulkExportJobError } from '../../service/page-bulk-export';
+import {
+  DuplicateBulkExportJobError,
+  pageBulkExportService,
+} from '../../service/page-bulk-export';
 
 const logger = loggerFactory('growi:routes:apiv3:page-bulk-export');
 
 const router = Router();
 
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 
 module.exports = (crowi: Crowi): Router => {
   const accessTokenParser = crowi.accessTokenParser;
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
 
   const validators = {
     pageBulkExport: [
@@ -30,8 +35,12 @@ module.exports = (crowi: Crowi): Router => {
     ],
   };
 
-  router.post('/', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE_BULK_EXPORT]),
-    loginRequiredStrictly, validators.pageBulkExport, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE_BULK_EXPORT]),
+    loginRequiredStrictly,
+    validators.pageBulkExport,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
         return res.status(400).json({ errors: errors.array() });
@@ -40,22 +49,35 @@ module.exports = (crowi: Crowi): Router => {
       const { path, format, restartJob } = req.body;
 
       try {
-        await pageBulkExportService?.createOrResetBulkExportJob(path, format, req.user, restartJob);
+        await pageBulkExportService?.createOrResetBulkExportJob(
+          path,
+          format,
+          req.user,
+          restartJob,
+        );
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (err instanceof DuplicateBulkExportJobError) {
-          return res.apiv3Err(new ErrorV3(
-            'Duplicate bulk export job is in progress',
-            'page_export.duplicate_bulk_export_job_error', undefined,
-            { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
-          ), 409);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Duplicate bulk export job is in progress',
+              'page_export.duplicate_bulk_export_job_error',
+              undefined,
+              { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
+            ),
+            409,
+          );
         }
-        return res.apiv3Err(new ErrorV3('Failed to start bulk export', 'page_export.failed_to_export'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to start bulk export',
+            'page_export.failed_to_export',
+          ),
+        );
       }
-    });
+    },
+  );
 
   return router;
-
 };

+ 19 - 10
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -7,36 +7,45 @@ import PageBulkExportJob from '../models/page-bulk-export-job';
 
 import { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
 
-const logger = loggerFactory('growi:service:check-page-bulk-export-job-in-progress-cron');
+const logger = loggerFactory(
+  'growi:service:check-page-bulk-export-job-in-progress-cron',
+);
 
 /**
  * Manages cronjob which checks if PageBulkExportJob in progress exists.
  * If it does, and PageBulkExportJobCronService is not running, start PageBulkExportJobCronService
  */
 class CheckPageBulkExportJobInProgressCronService extends CronService {
-
   override getCronSchedule(): string {
-    return configManager.getConfig('app:checkPageBulkExportJobInProgressCronSchedule');
+    return configManager.getConfig(
+      'app:checkPageBulkExportJobInProgressCronSchedule',
+    );
   }
 
   override async executeJob(): Promise<void> {
     // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-    const isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null;
+    const isBulkExportPagesEnabled =
+      configManager.getConfig('app:isBulkExportPagesEnabled') &&
+      configManager.getConfig('app:growiCloudUri') == null;
     if (!isBulkExportPagesEnabled) return;
 
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
     });
     const pageBulkExportInProgressExists = pageBulkExportJobInProgress != null;
 
-    if (pageBulkExportInProgressExists && !pageBulkExportJobCronService?.isJobRunning()) {
+    if (
+      pageBulkExportInProgressExists &&
+      !pageBulkExportJobCronService?.isJobRunning()
+    ) {
       pageBulkExportJobCronService?.startCron();
-    }
-    else if (!pageBulkExportInProgressExists) {
+    } else if (!pageBulkExportInProgressExists) {
       pageBulkExportJobCronService?.stopCron();
     }
   }
-
 }
 
-export const checkPageBulkExportJobInProgressCronService = new CheckPageBulkExportJobInProgressCronService(); // singleton instance
+export const checkPageBulkExportJobInProgressCronService =
+  new CheckPageBulkExportJobInProgressCronService(); // singleton instance

+ 71 - 28
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -3,20 +3,28 @@ import mongoose from 'mongoose';
 import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 
-import instanciatePageBulkExportJobCleanUpCronService, { pageBulkExportJobCleanUpCronService } from './page-bulk-export-job-clean-up-cron';
+import instanciatePageBulkExportJobCleanUpCronService, {
+  pageBulkExportJobCleanUpCronService,
+} from './page-bulk-export-job-clean-up-cron';
 
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // ref: https://github.com/vitest-dev/vitest/issues/846
-const userSchema = new mongoose.Schema({
-  name: { type: String },
-  username: { type: String, required: true, unique: true },
-  email: { type: String, unique: true, sparse: true },
-}, {
-  timestamps: true,
-});
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 const User = mongoose.model('User', userSchema);
 
 vi.mock('./page-bulk-export-job-cron', () => {
@@ -29,9 +37,10 @@ vi.mock('./page-bulk-export-job-cron', () => {
 
 describe('PageBulkExportJobCleanUpCronService', () => {
   const crowi = {} as Crowi;
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
     user = await User.create({
       name: 'Example for PageBulkExportJobCleanUpCronService Test',
@@ -41,7 +50,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     instanciatePageBulkExportJobCleanUpCronService(crowi);
   });
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     await PageBulkExportJob.deleteMany();
   });
 
@@ -51,8 +60,11 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
-      await configManager.updateConfig('app:bulkExportJobExpirationSeconds', 86400); // 1 day
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportJobExpirationSeconds',
+        86400,
+      ); // 1 day
 
       await PageBulkExportJob.insertMany([
         {
@@ -80,12 +92,16 @@ describe('PageBulkExportJobCleanUpCronService', () => {
           createdAt: new Date(Date.now() - 86400 * 1000 - 2),
         },
         {
-          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId4,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete expired jobs', async() => {
+    test('should delete expired jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(4);
 
       // act
@@ -94,7 +110,9 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(2);
-      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId4].sort());
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId4].sort(),
+      );
     });
   });
 
@@ -104,8 +122,11 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
-      await configManager.updateConfig('app:bulkExportDownloadExpirationSeconds', 86400); // 1 day
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportDownloadExpirationSeconds',
+        86400,
+      ); // 1 day
 
       await PageBulkExportJob.insertMany([
         {
@@ -125,15 +146,23 @@ describe('PageBulkExportJobCleanUpCronService', () => {
           completedAt: new Date(Date.now() - 86400 * 1000 - 1),
         },
         {
-          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+          _id: jobId3,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.initializing,
         },
         {
-          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId4,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete download expired jobs', async() => {
+    test('should delete download expired jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(4);
 
       // act
@@ -142,7 +171,9 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(3);
-      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId3, jobId4].sort());
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId3, jobId4].sort(),
+      );
     });
   });
 
@@ -151,21 +182,33 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId1 = new mongoose.Types.ObjectId();
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
+    beforeEach(async () => {
       await PageBulkExportJob.insertMany([
         {
-          _id: jobId1, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId1,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
         {
-          _id: jobId2, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+          _id: jobId2,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.initializing,
         },
         {
-          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId3,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete failed export jobs', async() => {
+    test('should delete failed export jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(3);
 
       // act
@@ -174,7 +217,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(1);
-      expect(jobs.map(job => job._id)).toStrictEqual([jobId2]);
+      expect(jobs.map((job) => job._id)).toStrictEqual([jobId2]);
     });
   });
 });

+ 63 - 26
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -5,19 +5,23 @@ import { configManager } from '~/server/service/config-manager';
 import CronService from '~/server/service/cron';
 import loggerFactory from '~/utils/logger';
 
-import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportJobInProgressStatus,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 
 import { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
 
-const logger = loggerFactory('growi:service:page-bulk-export-job-clean-up-cron');
+const logger = loggerFactory(
+  'growi:service:page-bulk-export-job-clean-up-cron',
+);
 
 /**
  * Manages cronjob which deletes unnecessary bulk export jobs
  */
 class PageBulkExportJobCleanUpCronService extends CronService {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -41,14 +45,25 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which are on-going and has passed the limit time for execution
    */
   async deleteExpiredExportJobs() {
-    const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
+    const exportJobExpirationSeconds = configManager.getConfig(
+      'app:bulkExportJobExpirationSeconds',
+    );
     const expiredExportJobs = await PageBulkExportJob.find({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-      createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
+      createdAt: {
+        $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000),
+      },
     });
 
     if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+      await this.cleanUpAndDeleteBulkExportJobs(
+        expiredExportJobs,
+        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
+          pageBulkExportJobCronService,
+        ),
+      );
     }
   }
 
@@ -56,63 +71,85 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which have completed but the due time for downloading has passed
    */
   async deleteDownloadExpiredExportJobs() {
-    const downloadExpirationSeconds = configManager.getConfig('app:bulkExportDownloadExpirationSeconds');
-    const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
+    const downloadExpirationSeconds = configManager.getConfig(
+      'app:bulkExportDownloadExpirationSeconds',
+    );
+    const thresholdDate = new Date(
+      Date.now() - downloadExpirationSeconds * 1000,
+    );
     const downloadExpiredExportJobs = await PageBulkExportJob.find({
       status: PageBulkExportJobStatus.completed,
       completedAt: { $lt: thresholdDate },
     });
 
-    const cleanUp = async(job: PageBulkExportJobDocument) => {
+    const cleanUp = async (job: PageBulkExportJobDocument) => {
       await pageBulkExportJobCronService?.cleanUpExportJobResources(job);
 
-      const hasSameAttachmentAndDownloadNotExpired = await PageBulkExportJob.findOne({
-        attachment: job.attachment,
-        _id: { $ne: job._id },
-        completedAt: { $gte: thresholdDate },
-      });
+      const hasSameAttachmentAndDownloadNotExpired =
+        await PageBulkExportJob.findOne({
+          attachment: job.attachment,
+          _id: { $ne: job._id },
+          completedAt: { $gte: thresholdDate },
+        });
       if (hasSameAttachmentAndDownloadNotExpired == null) {
         // delete attachment if no other export job (which download has not expired) has re-used it
         await this.crowi.attachmentService?.removeAttachment(job.attachment);
       }
     };
 
-    await this.cleanUpAndDeleteBulkExportJobs(downloadExpiredExportJobs, cleanUp);
+    await this.cleanUpAndDeleteBulkExportJobs(
+      downloadExpiredExportJobs,
+      cleanUp,
+    );
   }
 
   /**
    * Delete bulk export jobs which have failed
    */
   async deleteFailedExportJobs() {
-    const failedExportJobs = await PageBulkExportJob.find({ status: PageBulkExportJobStatus.failed });
+    const failedExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.failed,
+    });
 
     if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(failedExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+      await this.cleanUpAndDeleteBulkExportJobs(
+        failedExportJobs,
+        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
+          pageBulkExportJobCronService,
+        ),
+      );
     }
   }
 
   async cleanUpAndDeleteBulkExportJobs(
-      pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
-      cleanUp: (job: PageBulkExportJobDocument) => Promise<void>,
+    pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
+    cleanUp: (job: PageBulkExportJobDocument) => Promise<void>,
   ): Promise<void> {
-    const results = await Promise.allSettled(pageBulkExportJobs.map(job => cleanUp(job)));
+    const results = await Promise.allSettled(
+      pageBulkExportJobs.map((job) => cleanUp(job)),
+    );
     results.forEach((result) => {
       if (result.status === 'rejected') logger.error(result.reason);
     });
 
     // Only batch delete jobs which have been successfully cleaned up
     // Clean up failed jobs will be retried in the next cron execution
-    const cleanedUpJobs = pageBulkExportJobs.filter((_, index) => results[index].status === 'fulfilled');
+    const cleanedUpJobs = pageBulkExportJobs.filter(
+      (_, index) => results[index].status === 'fulfilled',
+    );
     if (cleanedUpJobs.length > 0) {
-      const cleanedUpJobIds = cleanedUpJobs.map(job => job._id);
+      const cleanedUpJobIds = cleanedUpJobs.map((job) => job._id);
       await PageBulkExportJob.deleteMany({ _id: { $in: cleanedUpJobIds } });
     }
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports
-export let pageBulkExportJobCleanUpCronService: PageBulkExportJobCleanUpCronService | undefined; // singleton instance
+export let pageBulkExportJobCleanUpCronService:
+  | PageBulkExportJobCleanUpCronService
+  | undefined; // singleton instance
 export default function instanciate(crowi: Crowi): void {
-  pageBulkExportJobCleanUpCronService = new PageBulkExportJobCleanUpCronService(crowi);
+  pageBulkExportJobCleanUpCronService = new PageBulkExportJobCleanUpCronService(
+    crowi,
+  );
 }

+ 0 - 4
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts

@@ -1,15 +1,11 @@
 export class BulkExportJobExpiredError extends Error {
-
   constructor() {
     super('Bulk export job has expired');
   }
-
 }
 
 export class BulkExportJobRestartedError extends Error {
-
   constructor() {
     super('Bulk export job has restarted');
   }
-
 }

+ 111 - 52
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -1,12 +1,10 @@
+import type { IUser } from '@growi/core';
+import { getIdForRef, isPopulated } from '@growi/core';
 import fs from 'fs';
+import mongoose from 'mongoose';
 import path from 'path';
 import type { Readable } from 'stream';
 
-import type { IUser } from '@growi/core';
-import { isPopulated, getIdForRef } from '@growi/core';
-import mongoose from 'mongoose';
-
-
 import type { SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
@@ -17,19 +15,24 @@ import CronService from '~/server/service/cron';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { PageBulkExportFormat, PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../../interfaces/page-bulk-export';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobInProgressStatus,
+  PageBulkExportJobStatus,
+} from '../../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../../models/page-bulk-export-job';
 import PageBulkExportJob from '../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
 
-
-import { BulkExportJobExpiredError, BulkExportJobRestartedError } from './errors';
+import {
+  BulkExportJobExpiredError,
+  BulkExportJobRestartedError,
+} from './errors';
 import { requestPdfConverter } from './request-pdf-converter';
 import { compressAndUpload } from './steps/compress-and-upload';
 import { createPageSnapshotsAsync } from './steps/create-page-snapshots-async';
 import { exportPagesToFsAsync } from './steps/export-pages-to-fs-async';
 
-
 const logger = loggerFactory('growi:service:page-bulk-export-job-cron');
 
 export interface IPageBulkExportJobCronService {
@@ -39,17 +42,28 @@ export interface IPageBulkExportJobCronService {
   compressExtension: string;
   setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
   removeStreamInExecution(jobId: ObjectIdLike): void;
-  handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument): void;
-  notifyExportResultAndCleanUp(action: SupportedActionType, pageBulkExportJob: PageBulkExportJobDocument): Promise<void>;
-  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath?: boolean): string;
+  handleError(
+    err: Error | null,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ): void;
+  notifyExportResultAndCleanUp(
+    action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ): Promise<void>;
+  getTmpOutputDir(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    isHtmlPath?: boolean,
+  ): string;
 }
 
 /**
  * Manages cronjob which proceeds PageBulkExportJobs in progress.
  * If PageBulkExportJob finishes the current step, the next step will be started on the next cron execution.
  */
-class PageBulkExportJobCronService extends CronService implements IPageBulkExportJobCronService {
-
+class PageBulkExportJobCronService
+  extends CronService
+  implements IPageBulkExportJobCronService
+{
   crowi: Crowi;
 
   activityEvent: any;
@@ -76,7 +90,9 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
     super();
     this.crowi = crowi;
     this.activityEvent = crowi.event('activity');
-    this.parallelExecLimit = configManager.getConfig('app:pageBulkExportParallelExecLimit');
+    this.parallelExecLimit = configManager.getConfig(
+      'app:pageBulkExportParallelExecLimit',
+    );
   }
 
   override getCronSchedule(): string {
@@ -85,8 +101,12 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
   override async executeJob(): Promise<void> {
     const pageBulkExportJobsInProgress = await PageBulkExportJob.find({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-    }).sort({ createdAt: 1 }).limit(this.parallelExecLimit);
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
+    })
+      .sort({ createdAt: 1 })
+      .limit(this.parallelExecLimit);
 
     pageBulkExportJobsInProgress.forEach((pageBulkExportJob) => {
       this.proceedBulkExportJob(pageBulkExportJob);
@@ -102,9 +122,14 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param pageBulkExportJob page bulk export job in execution
    * @param isHtmlPath whether the tmp output path is for html files
    */
-  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
+  getTmpOutputDir(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    isHtmlPath = false,
+  ): string {
     const jobId = pageBulkExportJob._id.toString();
-    return isHtmlPath ? path.join(this.tmpOutputRootDir, 'html', jobId) : path.join(this.tmpOutputRootDir, jobId);
+    return isHtmlPath
+      ? path.join(this.tmpOutputRootDir, 'html', jobId)
+      : path.join(this.tmpOutputRootDir, jobId);
   }
 
   /**
@@ -143,12 +168,17 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
         await pageBulkExportJob.save();
       }
 
-      if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting && pageBulkExportJob.format === PageBulkExportFormat.pdf) {
+      if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.exporting &&
+        pageBulkExportJob.format === PageBulkExportFormat.pdf
+      ) {
         await requestPdfConverter(pageBulkExportJob);
       }
 
       // return if job is still the same status as the previous cron exec
-      if (pageBulkExportJob.status === pageBulkExportJob.statusOnPreviousCronExec) {
+      if (
+        pageBulkExportJob.status === pageBulkExportJob.statusOnPreviousCronExec
+      ) {
         return;
       }
 
@@ -161,17 +191,21 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
       if (pageBulkExportJob.status === PageBulkExportJobStatus.initializing) {
         await createPageSnapshotsAsync.bind(this)(user, pageBulkExportJob);
-      }
-      else if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting) {
+      } else if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.exporting
+      ) {
         await exportPagesToFsAsync.bind(this)(pageBulkExportJob);
-      }
-      else if (pageBulkExportJob.status === PageBulkExportJobStatus.uploading) {
+      } else if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.uploading
+      ) {
         compressAndUpload.bind(this)(user, pageBulkExportJob);
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED,
+        pageBulkExportJob,
+      );
     }
   }
 
@@ -180,20 +214,27 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param err error
    * @param pageBulkExportJob PageBulkExportJob executed in the pipeline
    */
-  async handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument) {
+  async handleError(
+    err: Error | null,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ) {
     if (err == null) return;
 
     if (err instanceof BulkExportJobExpiredError) {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED, pageBulkExportJob);
-    }
-    else if (err instanceof BulkExportJobRestartedError) {
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
+        pageBulkExportJob,
+      );
+    } else if (err instanceof BulkExportJobRestartedError) {
       logger.info(err.message);
       await this.cleanUpExportJobResources(pageBulkExportJob);
-    }
-    else {
+    } else {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED,
+        pageBulkExportJob,
+      );
     }
   }
 
@@ -203,17 +244,18 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param pageBulkExportJob the page bulk export job
    */
   async notifyExportResultAndCleanUp(
-      action: SupportedActionType,
-      pageBulkExportJob: PageBulkExportJobDocument,
+    action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
   ): Promise<void> {
-    pageBulkExportJob.status = action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
-      ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
+    pageBulkExportJob.status =
+      action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
+        ? PageBulkExportJobStatus.completed
+        : PageBulkExportJobStatus.failed;
 
     try {
       await pageBulkExportJob.save();
       await this.notifyExportResult(pageBulkExportJob, action);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
     }
     // execute independently of notif process resolve/reject
@@ -225,13 +267,15 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * - delete page snapshots
    * - remove the temporal output directory
    */
-  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument, restarted = false) {
+  async cleanUpExportJobResources(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    restarted = false,
+  ) {
     const streamInExecution = this.getStreamInExecution(pageBulkExportJob._id);
     if (streamInExecution != null) {
       if (restarted) {
         streamInExecution.destroy(new BulkExportJobRestartedError());
-      }
-      else {
+      } else {
         streamInExecution.destroy(new BulkExportJobExpiredError());
       }
       this.removeStreamInExecution(pageBulkExportJob._id);
@@ -239,13 +283,19 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
+      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), {
+        recursive: true,
+        force: true,
+      }),
     ];
 
     // clean up html files exported for PDF conversion
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
       promises.push(
-        fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
+        fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), {
+          recursive: true,
+          force: true,
+        }),
       );
     }
 
@@ -256,7 +306,8 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
   }
 
   private async notifyExportResult(
-      pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
+    action: SupportedActionType,
   ) {
     const activity = await this.crowi.activityService.createActivity({
       action,
@@ -264,18 +315,26 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
       target: pageBulkExportJob,
       user: pageBulkExportJob.user,
       snapshot: {
-        username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
+        username: isPopulated(pageBulkExportJob.user)
+          ? pageBulkExportJob.user.username
+          : '',
       },
     });
-    const getAdditionalTargetUsers = async(activity: ActivityDocument) => [activity.user];
-    const preNotify = preNotifyService.generatePreNotify(activity, getAdditionalTargetUsers);
+    const getAdditionalTargetUsers = async (activity: ActivityDocument) => [
+      activity.user,
+    ];
+    const preNotify = preNotifyService.generatePreNotify(
+      activity,
+      getAdditionalTargetUsers,
+    );
     this.activityEvent.emit('updated', activity, pageBulkExportJob, preNotify);
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports
-export let pageBulkExportJobCronService: PageBulkExportJobCronService | undefined; // singleton instance
+export let pageBulkExportJobCronService:
+  | PageBulkExportJobCronService
+  | undefined; // singleton instance
 export default function instanciate(crowi: Crowi): void {
   pageBulkExportJobCronService = new PageBulkExportJobCronService(crowi);
 }

+ 33 - 17
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -1,4 +1,8 @@
-import { PdfCtrlSyncJobStatus202Status, PdfCtrlSyncJobStatusBodyStatus, pdfCtrlSyncJobStatus } from '@growi/pdf-converter-client';
+import {
+  PdfCtrlSyncJobStatus202Status,
+  PdfCtrlSyncJobStatusBodyStatus,
+  pdfCtrlSyncJobStatus,
+} from '@growi/pdf-converter-client';
 
 import { configManager } from '~/server/service/config-manager';
 
@@ -12,7 +16,9 @@ import { BulkExportJobExpiredError } from './errors';
  * Request PDF converter and start pdf convert for the pageBulkExportJob,
  * or sync pdf convert status if already started.
  */
-export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function requestPdfConverter(
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const jobCreatedAt = pageBulkExportJob.createdAt;
   if (jobCreatedAt == null) {
     throw new Error('createdAt is not set');
@@ -20,15 +26,24 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
 
   const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
   const appId = configManager.getConfig('app:growiAppIdForCloud');
-  if (isGrowiCloud && (appId == null)) {
+  if (isGrowiCloud && appId == null) {
     throw new Error('appId is required for bulk export on GROWI.cloud');
   }
 
-  const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
-  const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
-  let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
+  const exportJobExpirationSeconds = configManager.getConfig(
+    'app:bulkExportJobExpirationSeconds',
+  );
+  const bulkExportJobExpirationDate = new Date(
+    jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000,
+  );
+  let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus =
+    PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
 
-  const lastExportPagePath = (await PageBulkExportPageSnapshot.findOne({ pageBulkExportJob }).sort({ path: -1 }))?.path;
+  const lastExportPagePath = (
+    await PageBulkExportPageSnapshot.findOne({ pageBulkExportJob }).sort({
+      path: -1,
+    })
+  )?.path;
   if (lastExportPagePath == null) {
     throw new Error('lastExportPagePath is missing');
   }
@@ -46,22 +61,23 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
       pdfConvertStatus = PdfCtrlSyncJobStatusBodyStatus.FAILED;
     }
 
-    const res = await pdfCtrlSyncJobStatus({
-      appId,
-      jobId: pageBulkExportJob._id.toString(),
-      expirationDate: bulkExportJobExpirationDate.toISOString(),
-      status: pdfConvertStatus,
-    }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
+    const res = await pdfCtrlSyncJobStatus(
+      {
+        appId,
+        jobId: pageBulkExportJob._id.toString(),
+        expirationDate: bulkExportJobExpirationDate.toISOString(),
+        status: pdfConvertStatus,
+      },
+      { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') },
+    );
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
       pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
       await pageBulkExportJob.save();
-    }
-    else if (res.data.status === PdfCtrlSyncJobStatus202Status.FAILED) {
+    } else if (res.data.status === PdfCtrlSyncJobStatus202Status.FAILED) {
       throw new Error('PDF export failed');
     }
-  }
-  catch (err) {
+  } catch (err) {
     // Only set as failure when host is ready but failed.
     // If host is not ready, the request should be retried on the next cron execution.
     if (!['ENOTFOUND', 'ECONNREFUSED'].includes(err.code)) {

+ 33 - 11
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -8,11 +8,12 @@ import type { IAttachmentDocument } from '~/server/models/attachment';
 import { Attachment } from '~/server/models/attachment';
 import type { FileUploader } from '~/server/service/file-uploader';
 import loggerFactory from '~/utils/logger';
-
-import type { IPageBulkExportJobCronService } from '..';
 import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
+import type { IPageBulkExportJobCronService } from '..';
 
-const logger = loggerFactory('growi:service:page-bulk-export-job-cron:compress-and-upload-async');
+const logger = loggerFactory(
+  'growi:service:page-bulk-export-job-cron:compress-and-upload-async',
+);
 
 function setUpPageArchiver(): Archiver {
   const pageArchiver = archiver('tar', {
@@ -29,7 +30,10 @@ function setUpPageArchiver(): Archiver {
 }
 
 async function postProcess(
-    this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument, attachment: IAttachmentDocument, fileSize: number,
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+  attachment: IAttachmentDocument,
+  fileSize: number,
 ): Promise<void> {
   attachment.fileSize = fileSize;
   await attachment.save();
@@ -40,18 +44,33 @@ async function postProcess(
   await pageBulkExportJob.save();
 
   this.removeStreamInExecution(pageBulkExportJob._id);
-  await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+  await this.notifyExportResultAndCleanUp(
+    SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED,
+    pageBulkExportJob,
+  );
 }
 
 /**
  * Execute a pipeline that reads the page files from the temporal fs directory, compresses them, and uploads to the cloud storage
  */
-export async function compressAndUpload(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function compressAndUpload(
+  this: IPageBulkExportJobCronService,
+  user,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const pageArchiver = setUpPageArchiver();
 
-  if (pageBulkExportJob.revisionListHash == null) throw new Error('revisionListHash is not set');
+  if (pageBulkExportJob.revisionListHash == null)
+    throw new Error('revisionListHash is not set');
   const originalName = `${pageBulkExportJob.revisionListHash}.${this.compressExtension}`;
-  const attachment = Attachment.createWithoutSave(null, user, originalName, this.compressExtension, 0, AttachmentType.PAGE_BULK_EXPORT);
+  const attachment = Attachment.createWithoutSave(
+    null,
+    user,
+    originalName,
+    this.compressExtension,
+    0,
+    AttachmentType.PAGE_BULK_EXPORT,
+  );
 
   const fileUploadService: FileUploader = this.crowi.fileUploadService;
 
@@ -61,10 +80,13 @@ export async function compressAndUpload(this: IPageBulkExportJobCronService, use
 
   try {
     await fileUploadService.uploadAttachment(pageArchiver, attachment);
-  }
-  catch (e) {
+  } catch (e) {
     logger.error(e);
     this.handleError(e, pageBulkExportJob);
   }
-  await postProcess.bind(this)(pageBulkExportJob, attachment, pageArchiver.pointer());
+  await postProcess.bind(this)(
+    pageBulkExportJob,
+    attachment,
+    pageArchiver.pointer(),
+  );
 }

+ 22 - 17
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -1,20 +1,21 @@
-import { createHash } from 'crypto';
-import { Writable, pipeline } from 'stream';
-
-import { getIdForRef, getIdStringForRef } from '@growi/core';
 import type { IPage } from '@growi/core';
+import { getIdForRef, getIdStringForRef } from '@growi/core';
+import { createHash } from 'crypto';
 import mongoose from 'mongoose';
+import { pipeline, Writable } from 'stream';
 
 import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedAction } from '~/interfaces/activity';
 import type { PageDocument, PageModel } from '~/server/models/page';
-
-import type { IPageBulkExportJobCronService } from '..';
 import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
 import PageBulkExportJob from '../../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
+import type { IPageBulkExportJobCronService } from '..';
 
-async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument) {
+async function reuseDuplicateExportIfExists(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+) {
   const duplicateExportJob = await PageBulkExportJob.findOne({
     user: pageBulkExportJob.user,
     page: pageBulkExportJob.page,
@@ -28,7 +29,10 @@ async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService,
     pageBulkExportJob.status = PageBulkExportJobStatus.completed;
     await pageBulkExportJob.save();
 
-    await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+    await this.notifyExportResultAndCleanUp(
+      SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED,
+      pageBulkExportJob,
+    );
   }
 }
 
@@ -36,7 +40,11 @@ async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService,
  * Start a pipeline that creates a snapshot for each page that is to be exported in the pageBulkExportJob.
  * 'revisionListHash' is calulated and saved to the pageBulkExportJob at the end of the pipeline.
  */
-export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function createPageSnapshotsAsync(
+  this: IPageBulkExportJobCronService,
+  user,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
   // if the process of creating snapshots was interrupted, delete the snapshots and create from the start
@@ -54,15 +62,14 @@ export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronServi
   const builder = await new PageQueryBuilder(Page.find())
     .addConditionToListWithDescendants(basePage.path)
     .addViewerCondition(user);
-  const pagesReadable = builder
-    .query
+  const pagesReadable = builder.query
     .lean()
     .cursor({ batchSize: this.pageBatchSize });
 
   // create a Writable that creates a snapshot for each page
   const pageSnapshotsWritable = new Writable({
     objectMode: true,
-    write: async(page: PageDocument, encoding, callback) => {
+    write: async (page: PageDocument, encoding, callback) => {
       try {
         if (page.revision != null) {
           revisionListHash.update(getIdStringForRef(page.revision));
@@ -72,22 +79,20 @@ export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronServi
           path: page.path,
           revision: page.revision,
         });
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
       callback();
     },
-    final: async(callback) => {
+    final: async (callback) => {
       try {
         pageBulkExportJob.revisionListHash = revisionListHash.digest('hex');
         pageBulkExportJob.status = PageBulkExportJobStatus.exporting;
         await pageBulkExportJob.save();
 
         await reuseDuplicateExportIfExists.bind(this)(pageBulkExportJob);
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }

+ 61 - 34
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -1,26 +1,31 @@
-import fs from 'fs';
-import path from 'path';
-import { Writable, pipeline } from 'stream';
-
 import { dynamicImport } from '@cspell/dynamic-import';
 import { isPopulated } from '@growi/core';
-import { getParentPath, normalizePath } from '@growi/core/dist/utils/path-utils';
+import {
+  getParentPath,
+  normalizePath,
+} from '@growi/core/dist/utils/path-utils';
+import fs from 'fs';
 import type { Root } from 'mdast';
+import path from 'path';
 import type * as RemarkHtml from 'remark-html';
 import type * as RemarkParse from 'remark-parse';
+import { pipeline, Writable } from 'stream';
 import type * as Unified from 'unified';
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
-
-import type { IPageBulkExportJobCronService } from '..';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
 import type { PageBulkExportPageSnapshotDocument } from '../../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
+import type { IPageBulkExportJobCronService } from '..';
 
-async function convertMdToHtml(md: string, htmlConverter: Unified.Processor<Root, undefined, undefined, Root, string>): Promise<string> {
-  const htmlString = (await htmlConverter
-    .process(md))
-    .toString();
+async function convertMdToHtml(
+  md: string,
+  htmlConverter: Unified.Processor<Root, undefined, undefined, Root, string>,
+): Promise<string> {
+  const htmlString = (await htmlConverter.process(md)).toString();
 
   return htmlString;
 }
@@ -28,13 +33,24 @@ async function convertMdToHtml(md: string, htmlConverter: Unified.Processor<Root
 /**
  * Get a Writable that writes the page body temporarily to fs
  */
-async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<Writable> {
-  const unified = (await dynamicImport<typeof Unified>('unified', __dirname)).unified;
-  const remarkParse = (await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)).default;
-  const remarkHtml = (await dynamicImport<typeof RemarkHtml>('remark-html', __dirname)).default;
+async function getPageWritable(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<Writable> {
+  const unified = (await dynamicImport<typeof Unified>('unified', __dirname))
+    .unified;
+  const remarkParse = (
+    await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)
+  ).default;
+  const remarkHtml = (
+    await dynamicImport<typeof RemarkHtml>('remark-html', __dirname)
+  ).default;
 
   const isHtmlPath = pageBulkExportJob.format === PageBulkExportFormat.pdf;
-  const format = pageBulkExportJob.format === PageBulkExportFormat.pdf ? 'html' : pageBulkExportJob.format;
+  const format =
+    pageBulkExportJob.format === PageBulkExportFormat.pdf
+      ? 'html'
+      : pageBulkExportJob.format;
   const outputDir = this.getTmpOutputDir(pageBulkExportJob, isHtmlPath);
   // define before the stream starts to avoid creating multiple instances
   const htmlConverter = unified()
@@ -43,7 +59,11 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
     .use(remarkHtml);
   return new Writable({
     objectMode: true,
-    write: async(page: PageBulkExportPageSnapshotDocument, encoding, callback) => {
+    write: async (
+      page: PageBulkExportPageSnapshotDocument,
+      encoding,
+      callback,
+    ) => {
       try {
         const revision = page.revision;
 
@@ -56,22 +76,23 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
           await fs.promises.mkdir(fileOutputParentPath, { recursive: true });
           if (pageBulkExportJob.format === PageBulkExportFormat.md) {
             await fs.promises.writeFile(fileOutputPath, markdownBody);
-          }
-          else {
-            const htmlString = await convertMdToHtml(markdownBody, htmlConverter);
+          } else {
+            const htmlString = await convertMdToHtml(
+              markdownBody,
+              htmlConverter,
+            );
             await fs.promises.writeFile(fileOutputPath, htmlString);
           }
           pageBulkExportJob.lastExportedPagePath = page.path;
           await pageBulkExportJob.save();
         }
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
       callback();
     },
-    final: async(callback) => {
+    final: async (callback) => {
       try {
         // If the format is md, the export process ends here.
         // If the format is pdf, pdf conversion in pdf-converter has to finish.
@@ -79,8 +100,7 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
           pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
           await pageBulkExportJob.save();
         }
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
@@ -93,14 +113,21 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
  * Export pages to the file system before compressing and uploading to the cloud storage.
  * The export will resume from the last exported page if the process was interrupted.
  */
-export async function exportPagesToFsAsync(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
-  const findQuery = pageBulkExportJob.lastExportedPagePath != null ? {
-    pageBulkExportJob,
-    path: { $gt: pageBulkExportJob.lastExportedPagePath },
-  } : { pageBulkExportJob };
-  const pageSnapshotsReadable = PageBulkExportPageSnapshot
-    .find(findQuery)
-    .populate('revision').sort({ path: 1 }).lean()
+export async function exportPagesToFsAsync(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
+  const findQuery =
+    pageBulkExportJob.lastExportedPagePath != null
+      ? {
+          pageBulkExportJob,
+          path: { $gt: pageBulkExportJob.lastExportedPagePath },
+        }
+      : { pageBulkExportJob };
+  const pageSnapshotsReadable = PageBulkExportPageSnapshot.find(findQuery)
+    .populate('revision')
+    .sort({ path: 1 })
+    .lean()
     .cursor({ batchSize: this.pageBatchSize });
 
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);

+ 53 - 26
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -1,78 +1,105 @@
-import {
-  type IPage, SubscriptionStatusType,
-} from '@growi/core';
+import { type IPage, SubscriptionStatusType } from '@growi/core';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { PageModel } from '~/server/models/page';
 import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import type { PageBulkExportFormat } from '../../interfaces/page-bulk-export';
-import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportJobInProgressStatus,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 
 const logger = loggerFactory('growi:services:PageBulkExportService');
 
 export class DuplicateBulkExportJobError extends Error {
-
   duplicateJob: HydratedDocument<PageBulkExportJobDocument>;
 
   constructor(duplicateJob: HydratedDocument<PageBulkExportJobDocument>) {
     super('Duplicate bulk export job is in progress');
     this.duplicateJob = duplicateJob;
   }
-
 }
 
 export interface IPageBulkExportService {
-  createOrResetBulkExportJob: (basePagePath: string, currentUser, restartJob?: boolean) => Promise<void>;
+  createOrResetBulkExportJob: (
+    basePagePath: string,
+    currentUser,
+    restartJob?: boolean,
+  ) => Promise<void>;
 }
 
 class PageBulkExportService implements IPageBulkExportService {
-
   /**
    * Create a new page bulk export job or reset the existing one
    */
-  async createOrResetBulkExportJob(basePagePath: string, format: PageBulkExportFormat, currentUser, restartJob = false): Promise<void> {
+  async createOrResetBulkExportJob(
+    basePagePath: string,
+    format: PageBulkExportFormat,
+    currentUser,
+    restartJob = false,
+  ): Promise<void> {
     const Page = mongoose.model<IPage, PageModel>('Page');
-    const basePage = await Page.findByPathAndViewer(basePagePath, currentUser, null, true);
+    const basePage = await Page.findByPathAndViewer(
+      basePagePath,
+      currentUser,
+      null,
+      true,
+    );
 
     if (basePage == null) {
       throw new Error('Base page not found or not accessible');
     }
 
-    const duplicatePageBulkExportJobInProgress: HydratedDocument<PageBulkExportJobDocument> | null = await PageBulkExportJob.findOne({
-      user: { $eq: currentUser },
-      page: basePage,
-      format: { $eq: format },
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-    });
+    const duplicatePageBulkExportJobInProgress: HydratedDocument<PageBulkExportJobDocument> | null =
+      await PageBulkExportJob.findOne({
+        user: { $eq: currentUser },
+        page: basePage,
+        format: { $eq: format },
+        $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+          status,
+        })),
+      });
     if (duplicatePageBulkExportJobInProgress != null) {
       if (restartJob) {
         this.resetBulkExportJob(duplicatePageBulkExportJobInProgress);
         return;
       }
-      throw new DuplicateBulkExportJobError(duplicatePageBulkExportJobInProgress);
+      throw new DuplicateBulkExportJobError(
+        duplicatePageBulkExportJobInProgress,
+      );
     }
-    const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> = await PageBulkExportJob.create({
-      user: currentUser, page: basePage, format, status: PageBulkExportJobStatus.initializing,
-    });
-
-    await Subscription.upsertSubscription(currentUser, SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB, pageBulkExportJob, SubscriptionStatusType.SUBSCRIBE);
+    const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> =
+      await PageBulkExportJob.create({
+        user: currentUser,
+        page: basePage,
+        format,
+        status: PageBulkExportJobStatus.initializing,
+      });
+
+    await Subscription.upsertSubscription(
+      currentUser,
+      SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB,
+      pageBulkExportJob,
+      SubscriptionStatusType.SUBSCRIBE,
+    );
   }
 
   /**
    * Reset page bulk export job in progress
    */
-  async resetBulkExportJob(pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>): Promise<void> {
+  async resetBulkExportJob(
+    pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>,
+  ): Promise<void> {
     pageBulkExportJob.restartFlag = true;
     await pageBulkExportJob.save();
   }
-
 }
 
-export const pageBulkExportService: PageBulkExportService = new PageBulkExportService(); // singleton instance
+export const pageBulkExportService: PageBulkExportService =
+  new PageBulkExportService(); // singleton instance

+ 9 - 9
apps/app/src/interfaces/access-token.ts

@@ -1,16 +1,16 @@
 import type { Scope } from '@growi/core/dist/interfaces';
 
 export type IAccessTokenInfo = {
-  expiredAt: Date,
-  description: string,
-  scopes: Scope[],
-}
+  expiredAt: Date;
+  description: string;
+  scopes: Scope[];
+};
 
 export type IResGenerateAccessToken = IAccessTokenInfo & {
-  token: string,
-  _id: string,
-}
+  token: string;
+  _id: string;
+};
 
 export type IResGetAccessToken = IAccessTokenInfo & {
-  _id: string,
-}
+  _id: string;
+};

+ 126 - 65
apps/app/src/interfaces/activity.ts

@@ -1,4 +1,4 @@
-import type { Ref, HasObjectId, IUser } from '@growi/core';
+import type { HasObjectId, IUser, Ref } from '@growi/core';
 
 // Model
 const MODEL_PAGE = 'Page';
@@ -8,7 +8,8 @@ const MODEL_PAGE_BULK_EXPORT_JOB = 'PageBulkExportJob';
 
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
-const ACTION_USER_REGISTRATION_APPROVAL_REQUEST = 'USER_REGISTRATION_APPROVAL_REQUEST';
+const ACTION_USER_REGISTRATION_APPROVAL_REQUEST =
+  'USER_REGISTRATION_APPROVAL_REQUEST';
 const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
 const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
 const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
@@ -28,7 +29,8 @@ const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
 const ACTION_USER_ACCESS_TOKEN_CREATE = 'USER_ACCESS_TOKEN_CREATE';
 const ACTION_USER_ACCESS_TOKEN_DELETE = 'USER_ACCESS_TOKEN_DELETE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
-const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE =
+  'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
 const ACTION_PAGE_USER_HOME_VIEW = 'PAGE_USER_HOME_VIEW';
 const ACTION_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
@@ -48,7 +50,8 @@ const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
 const ACTION_PAGE_RECURSIVELY_RENAME = 'PAGE_RECURSIVELY_RENAME';
 const ACTION_PAGE_RECURSIVELY_DELETE = 'PAGE_RECURSIVELY_DELETE';
-const ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY = 'PAGE_RECURSIVELY_DELETE_COMPLETELY';
+const ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY =
+  'PAGE_RECURSIVELY_DELETE_COMPLETELY';
 const ACTION_PAGE_RECURSIVELY_REVERT = 'PAGE_RECURSIVELY_REVERT';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
@@ -57,7 +60,8 @@ const ACTION_PAGE_BULK_EXPORT_COMPLETED = 'PAGE_BULK_EXPORT_COMPLETED';
 const ACTION_PAGE_BULK_EXPORT_FAILED = 'PAGE_BULK_EXPORT_FAILED';
 const ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED = 'PAGE_BULK_EXPORT_JOB_EXPIRED';
 const ACTION_TAG_UPDATE = 'TAG_UPDATE';
-const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
+const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN =
+  'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 const ACTION_COMMENT_REMOVE = 'COMMENT_REMOVE';
@@ -78,8 +82,10 @@ const ACTION_ADMIN_SITE_URL_UPDATE = 'ADMIN_SITE_URL_UPDATE';
 const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
 const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
-const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
-const ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE = 'ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE';
+const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE =
+  'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
+const ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE =
+  'ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
@@ -103,9 +109,11 @@ const ACTION_ADMIN_AUTH_GOOGLE_UPDATE = 'ADMIN_AUTH_GOOGLE_UPDATE';
 const ACTION_ADMIN_AUTH_GITHUB_ENABLED = 'ADMIN_AUTH_GITHUB_ENABLED';
 const ACTION_ADMIN_AUTH_GITHUB_DISABLED = 'ADMIN_AUTH_GITHUB_DISABLED';
 const ACTION_ADMIN_AUTH_GITHUB_UPDATE = 'ADMIN_AUTH_GITHUB_UPDATE';
-const ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE = 'ADMIN_MARKDOWN_LINE_BREAK_UPDATE';
+const ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE =
+  'ADMIN_MARKDOWN_LINE_BREAK_UPDATE';
 const ACTION_ADMIN_MARKDOWN_INDENT_UPDATE = 'ADMIN_MARKDOWN_INDENT_UPDATE';
-const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_UPDATE';
+const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE =
+  'ADMIN_MARKDOWN_PRESENTATION_UPDATE';
 const ACTION_ADMIN_MARKDOWN_XSS_UPDATE = 'ADMIN_MARKDOWN_XSS_UPDATE';
 const ACTION_ADMIN_LAYOUT_UPDATE = 'ADMIN_LAYOUT_UPDATE';
 const ACTION_ADMIN_THEME_UPDATE = 'ADMIN_THEME_UPDATE';
@@ -118,37 +126,52 @@ const ACTION_ADMIN_CUSTOM_CSS_UPDATE = 'ADMIN_CUSTOM_CSS_UPDATE';
 const ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE = 'ADMIN_CUSTOM_SCRIPT_UPDATE';
 const ACTION_ADMIN_ARCHIVE_DATA_UPLOAD = 'ADMIN_ARCHIVE_DATA_UPLOAD';
 const ACTION_ADMIN_GROWI_DATA_IMPORTED = 'ADMIN_GROWI_DATA_IMPORTED';
-const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED = 'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
+const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED =
+  'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
 const ACTION_ADMIN_ESA_DATA_IMPORTED = 'ADMIN_ESA_DATA_IMPORTED';
 const ACTION_ADMIN_ESA_DATA_UPDATED = 'ADMIN_ESA_DATA_UPDATED';
-const ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA = 'ADMIN_CONNECTION_TEST_OF_ESA_DATA';
+const ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA =
+  'ADMIN_CONNECTION_TEST_OF_ESA_DATA';
 const ACTION_ADMIN_QIITA_DATA_IMPORTED = 'ADMIN_QIITA_DATA_IMPORTED';
 const ACTION_ADMIN_QIITA_DATA_UPDATED = 'ADMIN_QIITA_DATA_UPDATED';
-const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA = 'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
+const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA =
+  'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
 const ACTION_ADMIN_ARCHIVE_DATA_CREATE = 'ADMIN_ARCHIVE_DATA_CREATE';
 const ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD = 'ADMIN_ARCHIVE_DATA_DOWNLOAD';
 const ACTION_ADMIN_ARCHIVE_DATA_DELETE = 'ADMIN_ARCHIVE_DATA_DELETE';
-const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD = 'ADMIN_USER_NOTIFICATION_SETTINGS_ADD';
-const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_USER_NOTIFICATION_SETTINGS_DELETE';
-const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD';
-const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE';
-const ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE = 'ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE';
-const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED';
-const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED';
-const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD =
+  'ADMIN_USER_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE =
+  'ADMIN_USER_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD =
+  'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE =
+  'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE =
+  'ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED =
+  'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED =
+  'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE =
+  'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE';
 const ACTION_ADMIN_SLACK_WORKSPACE_CREATE = 'ADMIN_SLACK_WORKSPACE_CREATE';
 const ACTION_ADMIN_SLACK_WORKSPACE_DELETE = 'ADMIN_SLACK_WORKSPACE_DELETE';
 const ACTION_ADMIN_SLACK_BOT_TYPE_UPDATE = 'ADMIN_SLACK_BOT_TYPE_UPDATE';
 const ACTION_ADMIN_SLACK_BOT_TYPE_DELETE = 'ADMIN_SLACK_BOT_TYPE_DELETE';
-const ACTION_ADMIN_SLACK_ACCESS_TOKEN_REGENERATE = 'ADMIN_SLACK_ACCESS_TOKEN_REGENERATE';
+const ACTION_ADMIN_SLACK_ACCESS_TOKEN_REGENERATE =
+  'ADMIN_SLACK_ACCESS_TOKEN_REGENERATE';
 const ACTION_ADMIN_SLACK_MAKE_APP_PRIMARY = 'ADMIN_SLACK_MAKE_APP_PRIMARY';
 const ACTION_ADMIN_SLACK_PERMISSION_UPDATE = 'ADMIN_SLACK_PERMISSION_UPDATE';
 const ACTION_ADMIN_SLACK_PROXY_URI_UPDATE = 'ADMIN_SLACK_PROXY_URI_UPDATE';
 const ACTION_ADMIN_SLACK_RELATION_TEST = 'ADMIN_SLACK_RELATION_TEST';
-const ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE = 'ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE';
-const ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE = 'ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE';
+const ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE =
+  'ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE';
+const ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE =
+  'ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE';
 const ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST = 'ADMIN_SLACK_WITHOUT_PROXY_TEST';
-const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
+const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE =
+  'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
@@ -157,7 +180,8 @@ const ACTION_ADMIN_USERS_REVOKE_ADMIN = 'ADMIN_USERS_REVOKE_ADMIN';
 const ACTION_ADMIN_USERS_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
-const ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL = 'ADMIN_USERS_SEND_INVITATION_EMAIL';
+const ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL =
+  'ADMIN_USERS_SEND_INVITATION_EMAIL';
 const ACTION_ADMIN_USERS_REMOVE = 'ADMIN_USERS_REMOVE';
 const ACTION_ADMIN_USER_GROUP_CREATE = 'ADMIN_USER_GROUP_CREATE';
 const ACTION_ADMIN_USER_GROUP_UPDATE = 'ADMIN_USER_GROUP_UPDATE';
@@ -167,7 +191,6 @@ const ACTION_ADMIN_SEARCH_CONNECTION = 'ADMIN_SEARCH_CONNECTION';
 const ACTION_ADMIN_SEARCH_INDICES_NORMALIZE = 'ADMIN_SEARCH_INDICES_NORMALIZE';
 const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 
-
 export const SupportedTargetModel = {
   MODEL_PAGE,
   MODEL_USER,
@@ -378,7 +401,8 @@ export const ActionGroupSize = {
   Medium: 'MEDIUM',
   Large: 'LARGE',
 } as const;
-export type ActionGroupSize = typeof ActionGroupSize[keyof typeof ActionGroupSize];
+export type ActionGroupSize =
+  (typeof ActionGroupSize)[keyof typeof ActionGroupSize];
 
 export const SmallActionGroup = {
   ACTION_USER_LOGIN_WITH_LOCAL,
@@ -544,7 +568,6 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
 
-
 /*
  * Array
  */
@@ -557,53 +580,91 @@ export const AllMediumGroupActions = Object.values(MediumActionGroup);
 export const AllLargeGroupActions = Object.values(LargeActionGroup);
 
 // Action categories(for SelectActionDropdown.tsx)
-const pageRegExp = new RegExp(`^${SupportedActionCategory.PAGE.toUpperCase()}_`);
-const commentRegExp = new RegExp(`^${SupportedActionCategory.COMMENT.toUpperCase()}_`);
+const pageRegExp = new RegExp(
+  `^${SupportedActionCategory.PAGE.toUpperCase()}_`,
+);
+const commentRegExp = new RegExp(
+  `^${SupportedActionCategory.COMMENT.toUpperCase()}_`,
+);
 const tagRegExp = new RegExp(`^${SupportedActionCategory.TAG.toUpperCase()}_`);
-const attachmentRegExp = RegExp(`^${SupportedActionCategory.ATTACHMENT.toUpperCase()}_`);
-const shareLinkRegExp = RegExp(`^${SupportedActionCategory.SHARE_LINK.toUpperCase()}_`);
-const inAppNotificationRegExp = RegExp(`^${SupportedActionCategory.IN_APP_NOTIFICATION.toUpperCase()}_`);
-const searchRegExp = RegExp(`^${SupportedActionCategory.SEARCH.toUpperCase()}_`);
-const userRegExp = new RegExp(`^${SupportedActionCategory.USER.toUpperCase()}_`);
-const adminRegExp = new RegExp(`^${SupportedActionCategory.ADMIN.toUpperCase()}_`);
+const attachmentRegExp = RegExp(
+  `^${SupportedActionCategory.ATTACHMENT.toUpperCase()}_`,
+);
+const shareLinkRegExp = RegExp(
+  `^${SupportedActionCategory.SHARE_LINK.toUpperCase()}_`,
+);
+const inAppNotificationRegExp = RegExp(
+  `^${SupportedActionCategory.IN_APP_NOTIFICATION.toUpperCase()}_`,
+);
+const searchRegExp = RegExp(
+  `^${SupportedActionCategory.SEARCH.toUpperCase()}_`,
+);
+const userRegExp = new RegExp(
+  `^${SupportedActionCategory.USER.toUpperCase()}_`,
+);
+const adminRegExp = new RegExp(
+  `^${SupportedActionCategory.ADMIN.toUpperCase()}_`,
+);
 
-export const PageActions = AllSupportedActions.filter(action => action.match(pageRegExp));
-export const CommentActions = AllSupportedActions.filter(action => action.match(commentRegExp));
-export const TagActions = AllSupportedActions.filter(action => action.match(tagRegExp));
-export const AttachmentActions = AllSupportedActions.filter(action => action.match(attachmentRegExp));
-export const ShareLinkActions = AllSupportedActions.filter(action => action.match(shareLinkRegExp));
-export const InAppNotificationActions = AllSupportedActions.filter(action => action.match(inAppNotificationRegExp));
-export const SearchActions = AllSupportedActions.filter(action => action.match(searchRegExp));
-export const UserActions = AllSupportedActions.filter(action => action.match(userRegExp));
-export const AdminActions = AllSupportedActions.filter(action => action.match(adminRegExp));
+export const PageActions = AllSupportedActions.filter((action) =>
+  action.match(pageRegExp),
+);
+export const CommentActions = AllSupportedActions.filter((action) =>
+  action.match(commentRegExp),
+);
+export const TagActions = AllSupportedActions.filter((action) =>
+  action.match(tagRegExp),
+);
+export const AttachmentActions = AllSupportedActions.filter((action) =>
+  action.match(attachmentRegExp),
+);
+export const ShareLinkActions = AllSupportedActions.filter((action) =>
+  action.match(shareLinkRegExp),
+);
+export const InAppNotificationActions = AllSupportedActions.filter((action) =>
+  action.match(inAppNotificationRegExp),
+);
+export const SearchActions = AllSupportedActions.filter((action) =>
+  action.match(searchRegExp),
+);
+export const UserActions = AllSupportedActions.filter((action) =>
+  action.match(userRegExp),
+);
+export const AdminActions = AllSupportedActions.filter((action) =>
+  action.match(adminRegExp),
+);
 
 /*
  * Type
  */
-export type SupportedTargetModelType = typeof SupportedTargetModel[keyof typeof SupportedTargetModel];
-export type SupportedEventModelType = typeof SupportedEventModel[keyof typeof SupportedEventModel];
-export type SupportedActionType = typeof SupportedAction[keyof typeof SupportedAction];
-export type SupportedActionCategoryType = typeof SupportedActionCategory[keyof typeof SupportedActionCategory]
+export type SupportedTargetModelType =
+  (typeof SupportedTargetModel)[keyof typeof SupportedTargetModel];
+export type SupportedEventModelType =
+  (typeof SupportedEventModel)[keyof typeof SupportedEventModel];
+export type SupportedActionType =
+  (typeof SupportedAction)[keyof typeof SupportedAction];
+export type SupportedActionCategoryType =
+  (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
 
-export type ISnapshot = Partial<Pick<IUser, 'username'>>
+export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 
 export type IActivity = {
-  user?: Ref<IUser>
-  ip?: string
-  endpoint?: string
-  targetModel?: SupportedTargetModelType
-  target?: string
-  eventModel?: SupportedEventModelType
-  event?: string
-  action: SupportedActionType
-  createdAt: Date
-  snapshot?: ISnapshot
-}
+  user?: Ref<IUser>;
+  ip?: string;
+  endpoint?: string;
+  targetModel?: SupportedTargetModelType;
+  target?: string;
+  eventModel?: SupportedEventModelType;
+  event?: string;
+  action: SupportedActionType;
+  createdAt: Date;
+  snapshot?: ISnapshot;
+};
 
 export type IActivityHasId = IActivity & HasObjectId;
 
 export type ISearchFilter = {
-  usernames?: string[]
-  dates?: {startDate: string | null, endDate: string | null}
-  actions?: SupportedActionType[]
-}
+  usernames?: string[];
+  dates?: { startDate: string | null; endDate: string | null };
+  actions?: SupportedActionType[];
+};

+ 1 - 2
apps/app/src/interfaces/admin.ts

@@ -1,4 +1,3 @@
-
 export interface updateConfigMethodForAdmin<T> {
-  update: (arg: T) => void
+  update: (arg: T) => void;
 }

+ 5 - 5
apps/app/src/interfaces/apiv3/attachment.ts

@@ -3,13 +3,13 @@ import type { IAttachment, IPage, IRevision } from '@growi/core';
 import type { ICheckLimitResult } from '../attachment';
 
 export type IApiv3GetAttachmentLimitParams = {
-  fileSize: number,
+  fileSize: number;
 };
 
 export type IApiv3GetAttachmentLimitResponse = ICheckLimitResult;
 
 export type IApiv3PostAttachmentResponse = {
-  page: IPage,
-  revision: IRevision,
-  attachment: IAttachment,
-}
+  page: IPage;
+  revision: IRevision;
+  attachment: IAttachment;
+};

+ 22 - 24
apps/app/src/interfaces/apiv3/page.ts

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

+ 6 - 5
apps/app/src/interfaces/attachment.ts

@@ -13,13 +13,14 @@ export const AttachmentMethodType = {
   local: 'local',
   none: 'none',
 } as const;
-export type AttachmentMethodType = typeof AttachmentMethodType[keyof typeof AttachmentMethodType]
+export type AttachmentMethodType =
+  (typeof AttachmentMethodType)[keyof typeof AttachmentMethodType];
 
 export type IResAttachmentList = {
-  paginateResult: PaginateResult<IAttachmentHasId>
+  paginateResult: PaginateResult<IAttachmentHasId>;
 };
 
 export type ICheckLimitResult = {
-  isUploadable: boolean,
-  errorMessage?: string,
-}
+  isUploadable: boolean;
+  errorMessage?: string;
+};

+ 18 - 18
apps/app/src/interfaces/bookmark-info.ts

@@ -1,25 +1,25 @@
-import type { Ref, IPageHasId, IUser } from '@growi/core';
+import type { IPageHasId, IUser, Ref } from '@growi/core';
 
 export interface IBookmarkInfo {
-  sumOfBookmarks: number,
-  isBookmarked: boolean,
-  bookmarkedUsers: IUser[],
-  pageId: string,
+  sumOfBookmarks: number;
+  isBookmarked: boolean;
+  bookmarkedUsers: IUser[];
+  pageId: string;
 }
 
 export interface BookmarkedPage {
-  _id: string,
-  page: IPageHasId | null,
-  user: Ref<IUser>,
-  createdAt: Date,
+  _id: string;
+  page: IPageHasId | null;
+  user: Ref<IUser>;
+  createdAt: Date;
 }
 
-export type MyBookmarkList = BookmarkedPage[]
+export type MyBookmarkList = BookmarkedPage[];
 
 export interface IBookmarkFolder {
-  name: string
-  owner: Ref<IUser>
-  parent?: Ref<this>
+  name: string;
+  owner: Ref<IUser>;
+  parent?: Ref<this>;
 }
 
 export interface BookmarkFolderItems extends IBookmarkFolder {
@@ -34,13 +34,13 @@ export const DRAG_ITEM_TYPE = {
 } as const;
 
 interface BookmarkDragItem {
-  bookmarkFolder: BookmarkFolderItems
-  level: number
-  root: string
+  bookmarkFolder: BookmarkFolderItems;
+  level: number;
+  root: string;
 }
 
 export interface DragItemDataType extends BookmarkDragItem, IPageHasId {
-  parentFolder: BookmarkFolderItems | null
+  parentFolder: BookmarkFolderItems | null;
 }
 
-export type DragItemType = typeof DRAG_ITEM_TYPE[keyof typeof DRAG_ITEM_TYPE];
+export type DragItemType = (typeof DRAG_ITEM_TYPE)[keyof typeof DRAG_ITEM_TYPE];

+ 9 - 9
apps/app/src/interfaces/cdn.ts

@@ -1,17 +1,17 @@
 export type CdnManifestArgs = {
-  integrity?: string,
-  async?: boolean,
-  defer?: boolean,
+  integrity?: string;
+  async?: boolean;
+  defer?: boolean;
 };
 
 export type CdnManifest = {
-  name: string,
-  url: string,
-  groups?: string[],
-  args?: CdnManifestArgs,
+  name: string;
+  url: string;
+  groups?: string[];
+  args?: CdnManifestArgs;
 };
 
 export type CdnResource = {
-  manifest: CdnManifest,
-  outDir: string,
+  manifest: CdnManifest;
+  outDir: string;
 };

+ 15 - 18
apps/app/src/interfaces/comment.ts

@@ -1,29 +1,26 @@
-import type {
-  Ref, HasObjectId,
-  IPage, IRevision, IUser,
-} from '@growi/core';
+import type { HasObjectId, IPage, IRevision, IUser, Ref } from '@growi/core';
 
 export type IComment = {
-  page: Ref<IPage>,
-  creator: Ref<IUser>,
-  revision: Ref<IRevision>,
+  page: Ref<IPage>;
+  creator: Ref<IUser>;
+  revision: Ref<IRevision>;
   comment: string;
-  commentPosition: number,
-  replyTo?: string,
-  createdAt: Date,
-  updatedAt: Date,
+  commentPosition: number;
+  replyTo?: string;
+  createdAt: Date;
+  updatedAt: Date;
 };
 
 export interface ICommentPostArgs {
   commentForm: {
-    comment: string,
-    revisionId: string,
-    replyTo: string|undefined
-  },
+    comment: string;
+    revisionId: string;
+    replyTo: string | undefined;
+  };
   slackNotificationForm: {
-    isSlackEnabled: boolean|undefined,
-    slackChannels: string|undefined,
-  },
+    isSlackEnabled: boolean | undefined;
+    slackChannels: string | undefined;
+  };
 }
 
 export type ICommentHasId = IComment & HasObjectId;

+ 2 - 2
apps/app/src/interfaces/common.ts

@@ -5,5 +5,5 @@
 import type { ReactNode } from 'react';
 
 export type HasChildren<T = ReactNode> = {
-  children?: T
-}
+  children?: T;
+};

+ 4 - 7
apps/app/src/interfaces/crowi-request.ts

@@ -4,18 +4,15 @@ import type { HydratedDocument } from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 
-
 export interface CrowiProperties {
+  user?: HydratedDocument<IUser>;
 
-  user?: HydratedDocument<IUser>,
-
-  crowi: Crowi,
+  crowi: Crowi;
 
-  session: any,
+  session: any;
 
   // provided by csurf
-  csrfToken: () => string,
-
+  csrfToken: () => string;
 }
 
 export interface CrowiRequest extends CrowiProperties, Request {}

+ 4 - 4
apps/app/src/interfaces/customize.ts

@@ -1,10 +1,10 @@
 import type { GrowiThemeMetadata } from '@growi/core';
 
 export type IResLayoutSetting = {
-  isContainerFluid: boolean,
+  isContainerFluid: boolean;
 };
 
 export type IResGrowiTheme = {
-  currentTheme: string,
-  pluginThemesMetadatas: GrowiThemeMetadata[],
-}
+  currentTheme: string;
+  pluginThemesMetadatas: GrowiThemeMetadata[];
+};

+ 15 - 15
apps/app/src/interfaces/editor-methods.ts

@@ -1,22 +1,22 @@
 import type { JSX } from 'react';
 
 export interface IEditorMethods {
-  forceToFocus: () => void,
-  setValue: (newValue: string) => void,
-  setCaretLine: (line: number) => void,
-  setScrollTopByLine: (line: number) => void,
-  insertText: (text: string) => void,
-  terminateUploadingState: () => void,
+  forceToFocus: () => void;
+  setValue: (newValue: string) => void;
+  setCaretLine: (line: number) => void;
+  setScrollTopByLine: (line: number) => void;
+  insertText: (text: string) => void;
+  terminateUploadingState: () => void;
 }
 
 export interface IEditorInnerMethods {
-  getStrFromBol(): void,
-  getStrToEol: () => void,
-  getStrFromBolToSelectedUpperPos: () => void,
-  replaceBolToCurrentPos: (text: string) => void,
-  replaceLine: (text: string) => void,
-  insertLinebreak: () => void,
-  dispatchSave: () => void,
-  dispatchPasteFiles: (event: Event) => void,
-  getNavbarItems: () => JSX.Element[],
+  getStrFromBol(): void;
+  getStrToEol: () => void;
+  getStrFromBolToSelectedUpperPos: () => void;
+  replaceBolToCurrentPos: (text: string) => void;
+  replaceLine: (text: string) => void;
+  insertLinebreak: () => void;
+  dispatchSave: () => void;
+  dispatchPasteFiles: (event: Event) => void;
+  getNavbarItems: () => JSX.Element[];
 }

+ 3 - 1
apps/app/src/interfaces/errors/external-account-login-error.ts

@@ -3,6 +3,8 @@ import type { ExternalAccountLoginError } from '~/models/vo/external-account-log
 export type IExternalAccountLoginError = ExternalAccountLoginError;
 
 // type guard
-export const isExternalAccountLoginError = (args: any): args is IExternalAccountLoginError => {
+export const isExternalAccountLoginError = (
+  args: any,
+): args is IExternalAccountLoginError => {
   return (args as IExternalAccountLoginError).message != null;
 };

+ 4 - 2
apps/app/src/interfaces/errors/forgot-password.ts

@@ -1,7 +1,9 @@
 export const forgotPasswordErrorCode = {
   PASSWORD_RESET_IS_UNAVAILABLE: 'password-reset-is-unavailable',
   TOKEN_NOT_FOUND: 'token-not-found',
-  PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE: 'password-reset-order-is-not-appropriate',
+  PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE:
+    'password-reset-order-is-not-appropriate',
 } as const;
 
-export type forgotPasswordErrorCode = typeof forgotPasswordErrorCode[keyof typeof forgotPasswordErrorCode]
+export type forgotPasswordErrorCode =
+  (typeof forgotPasswordErrorCode)[keyof typeof forgotPasswordErrorCode];

+ 4 - 2
apps/app/src/interfaces/errors/login-error.ts

@@ -1,5 +1,7 @@
 export const LoginErrorCode = {
-  PROVIDER_DUPLICATED_USERNAME_EXCEPTION: 'provider-duplicated-username-exception',
+  PROVIDER_DUPLICATED_USERNAME_EXCEPTION:
+    'provider-duplicated-username-exception',
 } as const;
 
-export type LoginErrorCode = typeof LoginErrorCode[keyof typeof LoginErrorCode];
+export type LoginErrorCode =
+  (typeof LoginErrorCode)[keyof typeof LoginErrorCode];

+ 4 - 2
apps/app/src/interfaces/errors/user-activation.ts

@@ -1,7 +1,9 @@
 export const UserActivationErrorCode = {
   TOKEN_NOT_FOUND: 'token-not-found',
   INVALID_TOKEN: 'token-is-invalid',
-  USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE: 'user-registration-order-is-not-appropriate',
+  USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE:
+    'user-registration-order-is-not-appropriate',
 } as const;
 
-export type UserActivationErrorCode = typeof UserActivationErrorCode[keyof typeof UserActivationErrorCode];
+export type UserActivationErrorCode =
+  (typeof UserActivationErrorCode)[keyof typeof UserActivationErrorCode];

+ 1 - 1
apps/app/src/interfaces/errors/v3-error.ts

@@ -1,3 +1,3 @@
 import type { ErrorV3 } from '@growi/core/dist/models';
 
-export type IErrorV3 = ErrorV3
+export type IErrorV3 = ErrorV3;

+ 2 - 1
apps/app/src/interfaces/errors/v5-conversion-error.ts

@@ -5,4 +5,5 @@ export const V5ConversionErrCode = {
   FORBIDDEN: 'Forbidden',
 } as const;
 
-export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];
+export type V5ConversionErrCode =
+  (typeof V5ConversionErrCode)[keyof typeof V5ConversionErrCode];

+ 2 - 1
apps/app/src/interfaces/external-auth-provider.ts

@@ -6,4 +6,5 @@ export const IExternalAuthProviderType = {
   github: 'github',
 } as const;
 
-export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]
+export type IExternalAuthProviderType =
+  (typeof IExternalAuthProviderType)[keyof typeof IExternalAuthProviderType];

+ 8 - 6
apps/app/src/interfaces/file-uploader.ts

@@ -7,22 +7,24 @@ export const FileUploadType = {
   local: 'local',
 } as const;
 
-export type FileUploadType = typeof FileUploadType[keyof typeof FileUploadType]
+export type FileUploadType =
+  (typeof FileUploadType)[keyof typeof FileUploadType];
 
 // file upload type strings you can specify in the env variable
 export const FileUploadTypeForEnvVar = {
   ...FileUploadType,
-  mongo:   'mongo',
+  mongo: 'mongo',
   mongodb: 'mongodb',
-  gcp:     'gcp',
+  gcp: 'gcp',
 } as const;
 
-export type FileUploadTypeForEnvVar = typeof FileUploadTypeForEnvVar[keyof typeof FileUploadTypeForEnvVar]
+export type FileUploadTypeForEnvVar =
+  (typeof FileUploadTypeForEnvVar)[keyof typeof FileUploadTypeForEnvVar];
 
 // mapping from env variable to actual module name
 export const EnvToModuleMappings = {
   ...FileUploadTypeForEnvVar,
-  mongo:   'gridfs',
+  mongo: 'gridfs',
   mongodb: 'gridfs',
-  gcp:     'gcs',
+  gcp: 'gcs',
 } as const;

+ 4 - 3
apps/app/src/interfaces/g2g-transfer.ts

@@ -12,12 +12,13 @@ export const G2G_PROGRESS_STATUS = {
 /**
  * G2G transfer progress status
  */
-export type G2GProgressStatus = typeof G2G_PROGRESS_STATUS[keyof typeof G2G_PROGRESS_STATUS];
+export type G2GProgressStatus =
+  (typeof G2G_PROGRESS_STATUS)[keyof typeof G2G_PROGRESS_STATUS];
 
 /**
  * G2G transfer progress
  */
 export interface G2GProgress {
- mongo: G2GProgressStatus;
- attachments: G2GProgressStatus;
+  mongo: G2GProgressStatus;
+  attachments: G2GProgressStatus;
 }

+ 16 - 16
apps/app/src/interfaces/github-api.ts

@@ -1,21 +1,21 @@
 export type SearchResult = {
-  total_count: number,
-  imcomplete_results: boolean,
+  total_count: number;
+  imcomplete_results: boolean;
   items: SearchResultItem[];
-}
+};
 
 export type SearchResultItem = {
-  id: number,
-  name: string,
+  id: number;
+  name: string;
   owner: {
-    login: string,
-    html_url: string,
-    avatar_url: string,
-  },
-  fullName: string,
-  htmlUrl: string,
-  description: string,
-  topics: string[],
-  homepage: string,
-  stargazersCount: number,
-}
+    login: string;
+    html_url: string;
+    avatar_url: string;
+  };
+  fullName: string;
+  htmlUrl: string;
+  description: string;
+  topics: string[];
+  homepage: string;
+  stargazersCount: number;
+};

+ 17 - 17
apps/app/src/interfaces/in-app-notification.ts

@@ -1,6 +1,6 @@
 import type { IUser } from '@growi/core';
 
-import type { SupportedTargetModelType, SupportedActionType } from './activity';
+import type { SupportedActionType, SupportedTargetModelType } from './activity';
 
 export enum InAppNotificationStatuses {
   STATUS_UNOPENED = 'UNOPENED',
@@ -8,22 +8,22 @@ export enum InAppNotificationStatuses {
 }
 
 export interface IInAppNotification<T = unknown> {
-  user: IUser
-  targetModel: SupportedTargetModelType
-  target: T
-  action: SupportedActionType
-  status: InAppNotificationStatuses
-  actionUsers: IUser[]
-  createdAt: Date
-  snapshot: string
-  parsedSnapshot?: any
+  user: IUser;
+  targetModel: SupportedTargetModelType;
+  target: T;
+  action: SupportedActionType;
+  status: InAppNotificationStatuses;
+  actionUsers: IUser[];
+  createdAt: Date;
+  snapshot: string;
+  parsedSnapshot?: any;
 }
 
 /*
-* Note:
-* Need to use mongoose PaginateResult as a type after upgrading mongoose v6.0.0.
-* Until then, use the original "PaginateResult".
-*/
+ * Note:
+ * Need to use mongoose PaginateResult as a type after upgrading mongoose v6.0.0.
+ * Until then, use the original "PaginateResult".
+ */
 export interface PaginateResult<T> {
   docs: T[];
   hasNextPage: boolean;
@@ -39,11 +39,11 @@ export interface PaginateResult<T> {
 }
 
 /*
-* In App Notification Settings
-*/
+ * In App Notification Settings
+ */
 
 export enum subscribeRuleNames {
-  PAGE_CREATE = 'PAGE_CREATE'
+  PAGE_CREATE = 'PAGE_CREATE',
 }
 
 export enum SubscribeRuleDescriptions {

+ 1 - 1
apps/app/src/interfaces/indeterminate-input-elm.ts

@@ -1,3 +1,3 @@
 export interface IndeterminateInputElement extends HTMLInputElement {
-  indeterminate:boolean
+  indeterminate: boolean;
 }

+ 5 - 5
apps/app/src/interfaces/ldap.ts

@@ -1,7 +1,7 @@
 export interface IResTestLdap {
-  err?: any,
-  message: string,
-  status: string,
-  ldapConfiguration?: any,
-  ldapAccountInfo?: any,
+  err?: any;
+  message: string;
+  status: string;
+  ldapConfiguration?: any;
+  ldapAccountInfo?: any;
 }

+ 4 - 5
apps/app/src/interfaces/named-query.ts

@@ -1,13 +1,12 @@
 import type { IUser } from '@growi/core';
 
-
 export enum SearchDelegatorName {
   DEFAULT = 'FullTextSearch',
   PRIVATE_LEGACY_PAGES = 'PrivateLegacyPages',
 }
 export interface INamedQuery {
-  name: string
-  aliasOf?: string
-  delegatorName?: SearchDelegatorName
-  creator?: IUser
+  name: string;
+  aliasOf?: string;
+  delegatorName?: SearchDelegatorName;
+  creator?: IUser;
 }

+ 22 - 6
apps/app/src/interfaces/page-delete-config.ts

@@ -4,34 +4,50 @@ export const PageDeleteConfigValue = {
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type IPageDeleteConfigValue = typeof PageDeleteConfigValue[keyof typeof PageDeleteConfigValue];
+export type IPageDeleteConfigValue =
+  (typeof PageDeleteConfigValue)[keyof typeof PageDeleteConfigValue];
 
-export type IPageDeleteConfigValueToProcessValidation = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type IPageDeleteConfigValueToProcessValidation = Exclude<
+  IPageDeleteConfigValue,
+  typeof PageDeleteConfigValue.Inherit
+>;
 
 export const PageSingleDeleteConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteConfigValue = Exclude<
+  IPageDeleteConfigValue,
+  typeof PageDeleteConfigValue.Inherit
+>;
 
 export const PageSingleDeleteCompConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteCompConfigValue = Exclude<
+  IPageDeleteConfigValue,
+  typeof PageDeleteConfigValue.Inherit
+>;
 
 export const PageRecursiveDeleteConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteConfigValue = Exclude<
+  IPageDeleteConfigValue,
+  typeof PageDeleteConfigValue.Anyone
+>;
 
 export const PageRecursiveDeleteCompConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteCompConfigValue = Exclude<
+  IPageDeleteConfigValue,
+  typeof PageDeleteConfigValue.Anyone
+>;

+ 17 - 14
apps/app/src/interfaces/page-grant.ts

@@ -1,29 +1,32 @@
-import type { PageGrant, GroupType } from '@growi/core';
+import type { GroupType, PageGrant } from '@growi/core';
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import type { UserGroupDocument } from '~/server/models/user-group';
 
 import type { IPageGrantData } from './page';
 
-
 type UserGroupType = typeof GroupType.userGroup;
 type ExternalUserGroupType = typeof GroupType.externalUserGroup;
-export type PopulatedGrantedGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
+export type PopulatedGrantedGroup =
+  | { type: UserGroupType; item: UserGroupDocument }
+  | { type: ExternalUserGroupType; item: ExternalUserGroupDocument };
 export type IDataApplicableGroup = {
-  applicableGroups?: PopulatedGrantedGroup[]
-}
+  applicableGroups?: PopulatedGrantedGroup[];
+};
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;
-export type IRecordApplicableGrant = Partial<Record<PageGrant, IDataApplicableGrant>>
+export type IRecordApplicableGrant = Partial<
+  Record<PageGrant, IDataApplicableGrant>
+>;
 export type IResApplicableGrant = {
-  data?: IRecordApplicableGrant
-}
+  data?: IRecordApplicableGrant;
+};
 export type IResGrantData = {
-  isForbidden: boolean,
-  currentPageGrant: IPageGrantData,
-  parentPageGrant?: IPageGrantData
-}
+  isForbidden: boolean;
+  currentPageGrant: IPageGrantData;
+  parentPageGrant?: IPageGrantData;
+};
 export type IResCurrentGrantData = {
-  isGrantNormalized: boolean,
-  grantData: IResGrantData
+  isGrantNormalized: boolean;
+  grantData: IResGrantData;
 };

+ 5 - 7
apps/app/src/interfaces/page-listing-results.ts

@@ -2,23 +2,21 @@ import type { IPageHasId } from '@growi/core';
 
 import type { IPageForItem } from './page';
 
-
 type ParentPath = string;
 
 export interface RootPageResult {
-  rootPage: IPageHasId
+  rootPage: IPageHasId;
 }
 
 export interface AncestorsChildrenResult {
-  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
+  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>;
 }
 
-
 export interface ChildrenResult {
-  children: Partial<IPageForItem>[]
+  children: Partial<IPageForItem>[];
 }
 
 export interface V5MigrationStatus {
-  isV5Compatible : boolean,
-  migratablePagesCount: number
+  isV5Compatible: boolean;
+  migratablePagesCount: number;
 }

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

@@ -8,21 +8,23 @@ export const PageActionType = {
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+export type PageActionType =
+  (typeof PageActionType)[keyof typeof PageActionType];
 
 export const PageActionStage = {
   Main: 'Main',
   Sub: 'Sub',
 } as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+export type PageActionStage =
+  (typeof PageActionStage)[keyof typeof PageActionStage];
 
 export type IPageOperationProcessData = {
   [key in PageActionType]?: {
-    [PageActionStage.Main]?: { isProcessable: boolean },
-    [PageActionStage.Sub]?: { isProcessable: boolean },
-  }
-}
+    [PageActionStage.Main]?: { isProcessable: boolean };
+    [PageActionStage.Sub]?: { isProcessable: boolean };
+  };
+};
 
 export type IPageOperationProcessInfo = {
-  [pageId: string]: IPageOperationProcessData,
-}
+  [pageId: string]: IPageOperationProcessData;
+};

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

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

+ 53 - 43
apps/app/src/interfaces/page.ts

@@ -1,5 +1,10 @@
 import type {
-  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant, Origin,
+  GroupType,
+  IGrantedGroup,
+  IPageHasId,
+  Nullable,
+  Origin,
+  PageGrant,
 } from '@growi/core';
 
 import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
@@ -7,77 +12,82 @@ import type { ExternalGroupProviderType } from '~/features/external-user-group/i
 import type { IPageOperationProcessData } from './page-operation';
 
 export {
-  isIPageInfoForEntity, isIPageInfoForOperation, isIPageInfoForListing,
+  isIPageInfoForEntity,
+  isIPageInfoForListing,
+  isIPageInfoForOperation,
 } from '@growi/core';
 
-export type IPageForItem = Partial<IPageHasId & {processData?: IPageOperationProcessData}>;
+export type IPageForItem = Partial<
+  IPageHasId & { processData?: IPageOperationProcessData }
+>;
 
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',
   notGranted: 'notGranted',
   cannotGrant: 'cannotGrant',
 };
-type UserGroupPageGrantStatus = typeof UserGroupPageGrantStatus[keyof typeof UserGroupPageGrantStatus];
+type UserGroupPageGrantStatus =
+  (typeof UserGroupPageGrantStatus)[keyof typeof UserGroupPageGrantStatus];
 export type UserRelatedGroupsData = {
-  id: string,
-  name: string,
-  type: GroupType,
-  provider?: ExternalGroupProviderType,
-  status: UserGroupPageGrantStatus,
-}
+  id: string;
+  name: string;
+  type: GroupType;
+  provider?: ExternalGroupProviderType;
+  status: UserGroupPageGrantStatus;
+};
 export type GroupGrantData = {
-  userRelatedGroups: UserRelatedGroupsData[],
+  userRelatedGroups: UserRelatedGroupsData[];
   nonUserRelatedGrantedGroups: {
-    id: string,
-    name: string,
-    type: GroupType,
-    provider?: ExternalGroupProviderType,
-  }[],
-}
+    id: string;
+    name: string;
+    type: GroupType;
+    provider?: ExternalGroupProviderType;
+  }[];
+};
 // current grant data of page
 export type IPageGrantData = {
-  grant: PageGrant,
-  groupGrantData?: GroupGrantData,
-}
+  grant: PageGrant;
+  groupGrantData?: GroupGrantData;
+};
 // grant selected by user which is not yet applied
 export type IPageSelectedGrant = {
-  grant: PageGrant,
-  userRelatedGrantedGroups?: IGrantedGroup[]
-}
+  grant: PageGrant;
+  userRelatedGrantedGroups?: IGrantedGroup[];
+};
 
 export type IDeleteSinglePageApiv1Result = {
-  ok: boolean
-  path: string,
-  isRecursively: Nullable<true>,
-  isCompletely: Nullable<true>,
+  ok: boolean;
+  path: string;
+  isRecursively: Nullable<true>;
+  isCompletely: Nullable<true>;
 };
 
 export type IDeleteManyPageApiv3Result = {
-  paths: string[],
-  isRecursively: Nullable<true>,
-  isCompletely: Nullable<true>,
+  paths: string[];
+  isRecursively: Nullable<true>;
+  isCompletely: Nullable<true>;
 };
 
 export type IOptionsForUpdate = {
-  origin?: Origin
-  wip?: boolean,
-  grant?: PageGrant,
-  userRelatedGrantUserGroupIds?: IGrantedGroup[],
+  origin?: Origin;
+  wip?: boolean;
+  grant?: PageGrant;
+  userRelatedGrantUserGroupIds?: IGrantedGroup[];
   // isSyncRevisionToHackmd?: boolean,
-  overwriteScopesOfDescendants?: boolean,
+  overwriteScopesOfDescendants?: boolean;
 };
 
 export type IOptionsForCreate = {
-  grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
-  onlyInheritUserRelatedGrantedGroups?: boolean,
-  overwriteScopesOfDescendants?: boolean,
+  grant?: PageGrant;
+  grantUserGroupIds?: IGrantedGroup[];
+  onlyInheritUserRelatedGrantedGroups?: boolean;
+  overwriteScopesOfDescendants?: boolean;
 
-  origin?: Origin
-  wip?: boolean,
+  origin?: Origin;
+  wip?: boolean;
 };
 
 export type IPagePathWithDescendantCount = {
-  path: string,
-  descendantCount: number,
+  path: string;
+  descendantCount: number;
 };

+ 4 - 4
apps/app/src/interfaces/paging-result.ts

@@ -1,5 +1,5 @@
 export type IPagingResult<T> = {
-  items: T[],
-  totalCount: number,
-  limit: number,
-}
+  items: T[];
+  totalCount: number;
+  limit: number;
+};

+ 2 - 1
apps/app/src/interfaces/registration-mode.ts

@@ -4,4 +4,5 @@ export const RegistrationMode = {
   CLOSED: 'Closed',
 } as const;
 
-export type RegistrationMode = typeof RegistrationMode[keyof typeof RegistrationMode];
+export type RegistrationMode =
+  (typeof RegistrationMode)[keyof typeof RegistrationMode];

+ 13 - 8
apps/app/src/interfaces/renderer-options.ts

@@ -1,17 +1,22 @@
 import type { ComponentType } from 'react';
 
-import type { Options as ReactMarkdownOptions, Components } from 'react-markdown';
+import type {
+  Components,
+  Options as ReactMarkdownOptions,
+} from 'react-markdown';
 import type { PluggableList } from 'unified';
 
-export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
-  remarkPlugins: PluggableList,
-  rehypePlugins: PluggableList,
+export type RendererOptions = Omit<
+  ReactMarkdownOptions,
+  'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'
+> & {
+  remarkPlugins: PluggableList;
+  rehypePlugins: PluggableList;
   components?:
     | Partial<
-        Components
-        & {
-          [elem: string]: ComponentType<any>,
+        Components & {
+          [elem: string]: ComponentType<any>;
         }
       >
-    | undefined
+    | undefined;
 };

+ 54 - 54
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -1,66 +1,66 @@
 export type IResAppSettings = {
-  title: string,
-  confidential: string,
-  globalLang: string,
-  isEmailPublishedForNewUser: boolean,
-  fileUpload: string,
-  isV5Compatible: boolean,
-  siteUrl: string,
-  envSiteUrl: string,
-  isMailerSetup: boolean,
-  fromAddress: string,
+  title: string;
+  confidential: string;
+  globalLang: string;
+  isEmailPublishedForNewUser: boolean;
+  fileUpload: string;
+  isV5Compatible: boolean;
+  siteUrl: string;
+  envSiteUrl: string;
+  isMailerSetup: boolean;
+  fromAddress: string;
 
-  transmissionMethod: string,
-  smtpHost: string,
-  smtpPort: string | number, // TODO: check
-  smtpUser: string,
-  smtpPassword: string,
-  sesAccessKeyId: string,
-  sesSecretAccessKey: string,
+  transmissionMethod: string;
+  smtpHost: string;
+  smtpPort: string | number; // TODO: check
+  smtpUser: string;
+  smtpPassword: string;
+  sesAccessKeyId: string;
+  sesSecretAccessKey: string;
 
-  fileUploadType: string,
-  envFileUploadType: string,
-  useOnlyEnvVarForFileUploadType: boolean,
+  fileUploadType: string;
+  envFileUploadType: string;
+  useOnlyEnvVarForFileUploadType: boolean;
 
-  s3Region: string,
-  s3CustomEndpoint: string,
-  s3Bucket:string,
-  s3AccessKeyId: string,
-  s3SecretAccessKey: string,
-  s3ReferenceFileWithRelayMode: string,
+  s3Region: string;
+  s3CustomEndpoint: string;
+  s3Bucket: string;
+  s3AccessKeyId: string;
+  s3SecretAccessKey: string;
+  s3ReferenceFileWithRelayMode: string;
 
-  gcsUseOnlyEnvVars: boolean,
-  gcsApiKeyJsonPath: string,
-  gcsBucket: string,
-  gcsUploadNamespace: string,
-  gcsReferenceFileWithRelayMode: string,
+  gcsUseOnlyEnvVars: boolean;
+  gcsApiKeyJsonPath: string;
+  gcsBucket: string;
+  gcsUploadNamespace: string;
+  gcsReferenceFileWithRelayMode: string;
 
-  envGcsApiKeyJsonPath: string,
-  envGcsBucket: string,
-  envGcsUploadNamespace: string,
+  envGcsApiKeyJsonPath: string;
+  envGcsBucket: string;
+  envGcsUploadNamespace: string;
 
-  azureUseOnlyEnvVars: boolean,
-  azureTenantId: string,
-  azureClientId: string,
-  azureClientSecret: string,
-  azureStorageAccountName: string,
-  azureStorageContainerName: string,
-  azureReferenceFileWithRelayMode: string,
+  azureUseOnlyEnvVars: boolean;
+  azureTenantId: string;
+  azureClientId: string;
+  azureClientSecret: string;
+  azureStorageAccountName: string;
+  azureStorageContainerName: string;
+  azureReferenceFileWithRelayMode: string;
 
-  envAzureTenantId: string,
-  envAzureClientId: string,
-  envAzureClientSecret: string,
-  envAzureStorageAccountName: string,
-  envAzureStorageContainerName: string,
+  envAzureTenantId: string;
+  envAzureClientId: string;
+  envAzureClientSecret: string;
+  envAzureStorageAccountName: string;
+  envAzureStorageContainerName: string;
 
-  isEnabledPlugins: boolean,
+  isEnabledPlugins: boolean;
 
-  isAppSiteUrlHashed: boolean,
+  isAppSiteUrlHashed: boolean;
 
-  isMaintenanceMode: boolean,
+  isMaintenanceMode: boolean;
 
-  isBulkExportPagesEnabled: boolean,
-  envIsBulkExportPagesEnabled: boolean,
-  bulkExportDownloadExpirationSeconds: number,
-  useOnlyEnvVarsForIsBulkExportPagesEnabled: boolean,
-}
+  isBulkExportPagesEnabled: boolean;
+  envIsBulkExportPagesEnabled: boolean;
+  bulkExportDownloadExpirationSeconds: number;
+  useOnlyEnvVarsForIsBulkExportPagesEnabled: boolean;
+};

+ 18 - 18
apps/app/src/interfaces/search.ts

@@ -1,12 +1,12 @@
 import type { IDataWithMeta, IPageHasId } from '@growi/core';
 
 export type IPageSearchMeta = {
-  bookmarkCount?: number,
+  bookmarkCount?: number;
   elasticSearchResult?: {
     snippet?: string | null;
     highlightedPath?: string | null;
   };
-}
+};
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
 export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
@@ -15,38 +15,38 @@ export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
 
 export type ISearchResultMeta = {
   meta: {
-    took?: number
-    total: number
-    hitsCount: number
-  },
-}
+    took?: number;
+    total: number;
+    hitsCount: number;
+  };
+};
 
 export type ISearchResultData = {
-  _id: string
-  _score: number
-  _source: any
-  _highlight: any
-}
+  _id: string;
+  _score: number;
+  _source: any;
+  _highlight: any;
+};
 
 export type ISearchResult<T> = ISearchResultMeta & {
-  data: T[],
-}
+  data: T[];
+};
 
 export type IPageWithSearchMeta = IDataWithMeta<IPageHasId, IPageSearchMeta>;
 
 export type IFormattedSearchResult = ISearchResultMeta & {
-  data: IPageWithSearchMeta[],
-}
+  data: IPageWithSearchMeta[];
+};
 
 export const SORT_AXIS = {
   RELATION_SCORE: 'relationScore',
   CREATED_AT: 'createdAt',
   UPDATED_AT: 'updatedAt',
 } as const;
-export type SORT_AXIS = typeof SORT_AXIS[keyof typeof SORT_AXIS];
+export type SORT_AXIS = (typeof SORT_AXIS)[keyof typeof SORT_AXIS];
 
 export const SORT_ORDER = {
   DESC: 'desc',
   ASC: 'asc',
 } as const;
-export type SORT_ORDER = typeof SORT_ORDER[keyof typeof SORT_ORDER];
+export type SORT_ORDER = (typeof SORT_ORDER)[keyof typeof SORT_ORDER];

+ 7 - 6
apps/app/src/interfaces/services/rehype-sanitize.ts

@@ -7,11 +7,12 @@ export const RehypeSanitizeType = {
   CUSTOM: 'Custom',
 } as const;
 
-export type RehypeSanitizeType = typeof RehypeSanitizeType[keyof typeof RehypeSanitizeType];
+export type RehypeSanitizeType =
+  (typeof RehypeSanitizeType)[keyof typeof RehypeSanitizeType];
 
 export type RehypeSanitizeConfiguration = {
-  isEnabledXssPrevention: boolean,
-  sanitizeType: RehypeSanitizeType,
-  customTagWhitelist?: Array<string> | null,
-  customAttrWhitelist?: Attributes | null,
-}
+  isEnabledXssPrevention: boolean;
+  sanitizeType: RehypeSanitizeType;
+  customTagWhitelist?: Array<string> | null;
+  customAttrWhitelist?: Attributes | null;
+};

+ 10 - 10
apps/app/src/interfaces/services/renderer.ts

@@ -1,18 +1,18 @@
 import type { RehypeSanitizeConfiguration } from './rehype-sanitize';
 
 export type RendererConfig = {
-  isSharedPage?: boolean
-  isEnabledLinebreaks: boolean,
-  isEnabledLinebreaksInComments: boolean,
-  adminPreferredIndentSize: number,
-  isIndentSizeForced: boolean,
-  highlightJsStyleBorder: boolean,
-  isEnabledMarp: boolean,
+  isSharedPage?: boolean;
+  isEnabledLinebreaks: boolean;
+  isEnabledLinebreaksInComments: boolean;
+  adminPreferredIndentSize: number;
+  isIndentSizeForced: boolean;
+  highlightJsStyleBorder: boolean;
+  isEnabledMarp: boolean;
 
-  drawioUri: string,
-  plantumlUri: string,
+  drawioUri: string;
+  plantumlUri: string;
 } & RehypeSanitizeConfiguration;
 
 export type RendererConfigExt = RendererConfig & {
-  isDarkMode?: boolean,
+  isDarkMode?: boolean;
 };

+ 6 - 6
apps/app/src/interfaces/share-link.ts

@@ -1,15 +1,15 @@
-import type { IPageHasId, HasObjectId } from '@growi/core';
+import type { HasObjectId, IPageHasId } from '@growi/core';
 
 // Todo: specify more detailed Type
 export type IResShareLinkList = {
-  shareLinksResult: any[],
+  shareLinksResult: any[];
 };
 
 export type IShareLink = {
-  relatedPage: IPageHasId,
-  createdAt: Date,
-  expiredAt?: Date,
-  description: string,
+  relatedPage: IPageHasId;
+  createdAt: Date;
+  expiredAt?: Date;
+  description: string;
 };
 
 export type IShareLinkHasId = IShareLink & HasObjectId;

+ 2 - 3
apps/app/src/interfaces/sidebar-config.ts

@@ -1,5 +1,4 @@
-
 export interface ISidebarConfig {
-  isSidebarCollapsedMode: boolean,
-  isSidebarClosedAtDockMode?: boolean,
+  isSidebarCollapsedMode: boolean;
+  isSidebarClosedAtDockMode?: boolean;
 }

+ 18 - 18
apps/app/src/interfaces/tag.ts

@@ -1,31 +1,31 @@
-import type { ITag, IPageHasId } from '@growi/core';
+import type { IPageHasId, ITag } from '@growi/core';
 
-export type IDataTagCount = ITag & {count: number}
+export type IDataTagCount = ITag & { count: number };
 
 export type IPageTagsInfo = {
-  tags : string[],
-}
+  tags: string[];
+};
 
 export type IListTagNamesByPage = string[];
 
 export type IResTagsUpdateApiv1 = {
-  ok: boolean,
-  savedPage: IPageHasId,
-  tags: string[],
-}
+  ok: boolean;
+  savedPage: IPageHasId;
+  tags: string[];
+};
 
 export type IResTagsSearchApiv1 = {
-  ok: boolean,
-  tags: string[],
-}
+  ok: boolean;
+  tags: string[];
+};
 
 export type IResGetPageTags = {
-  ok: boolean,
-  tags: string[],
-}
+  ok: boolean;
+  tags: string[];
+};
 
 export type IResTagsListApiv1 = {
-  ok: boolean,
-  data: IDataTagCount[],
-  totalCount: number,
-}
+  ok: boolean;
+  data: IDataTagCount[];
+  totalCount: number;
+};

+ 1 - 1
apps/app/src/interfaces/theme.ts

@@ -1,4 +1,4 @@
 export const PrismThemes = {
   OneLight: 'one-light',
 } as const;
-export type PrismThemes = typeof PrismThemes[keyof typeof PrismThemes];
+export type PrismThemes = (typeof PrismThemes)[keyof typeof PrismThemes];

+ 4 - 4
apps/app/src/interfaces/transfer-key.ts

@@ -1,6 +1,6 @@
 export interface ITransferKey<ID = string> {
-  _id: ID
-  expireAt: Date
-  keyString: string,
-  key: string,
+  _id: ID;
+  expireAt: Date;
+  keyString: string;
+  key: string;
 }

+ 20 - 14
apps/app/src/interfaces/ui.ts

@@ -1,16 +1,14 @@
-import type { JSX } from 'react';
-
 import type { Nullable } from '@growi/core';
+import type { JSX } from 'react';
 
 import type { IPageForItem } from '~/interfaces/page';
 
-
 export const SidebarMode = {
   DRAWER: 'drawer',
   COLLAPSED: 'collapsed',
   DOCK: 'dock',
 } as const;
-export type SidebarMode = typeof SidebarMode[keyof typeof SidebarMode];
+export type SidebarMode = (typeof SidebarMode)[keyof typeof SidebarMode];
 
 export const SidebarContentsType = {
   CUSTOM: 'custom',
@@ -22,22 +20,30 @@ export const SidebarContentsType = {
   AI_ASSISTANT: 'aiAssistant',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
-export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];
-
+export type SidebarContentsType =
+  (typeof SidebarContentsType)[keyof typeof SidebarContentsType];
 
 export type ICustomTabContent = {
-  Content?: () => JSX.Element,
-  i18n?: string,
-  Icon?: () => JSX.Element,
-  isLinkEnabled?: boolean | ((content: ICustomTabContent) => boolean),
+  Content?: () => JSX.Element;
+  i18n?: string;
+  Icon?: () => JSX.Element;
+  isLinkEnabled?: boolean | ((content: ICustomTabContent) => boolean);
 };
 
 export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };
 
-
-export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
+export type OnDeletedFunction = (
+  idOrPaths: string | string[],
+  isRecursively: Nullable<true>,
+  isCompletely: Nullable<true>,
+) => void;
 export type OnRenamedFunction = (path: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
 export type OnPutBackedFunction = (path: string) => void;
-export type onDeletedBookmarkFolderFunction = (bookmarkFolderId: string) => void;
-export type OnSelectedFunction = (page: IPageForItem, isIncludeSubPage: boolean) => void;
+export type onDeletedBookmarkFolderFunction = (
+  bookmarkFolderId: string,
+) => void;
+export type OnSelectedFunction = (
+  page: IPageForItem,
+  isIncludeSubPage: boolean,
+) => void;

+ 36 - 25
apps/app/src/interfaces/user-group-response.ts

@@ -1,49 +1,60 @@
 import type {
-  HasObjectId, Ref,
+  HasObjectId,
   IPageHasId,
-  IUserGroup, IUserGroupHasId, IUserGroupRelationHasId, IUserHasId,
+  IUserGroup,
+  IUserGroupHasId,
+  IUserGroupRelationHasId,
+  IUserHasId,
+  Ref,
 } from '@growi/core';
 
-
 export type UserGroupResult = {
-  userGroup: IUserGroupHasId,
-}
+  userGroup: IUserGroupHasId;
+};
 
-export type UserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
-  userGroups: TUSERGROUP[],
+export type UserGroupListResult<
+  TUSERGROUP extends IUserGroupHasId = IUserGroupHasId,
+> = {
+  userGroups: TUSERGROUP[];
 };
 
-export type ChildUserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
-  childUserGroups: TUSERGROUP[],
-  grandChildUserGroups: TUSERGROUP[],
+export type ChildUserGroupListResult<
+  TUSERGROUP extends IUserGroupHasId = IUserGroupHasId,
+> = {
+  childUserGroups: TUSERGROUP[];
+  grandChildUserGroups: TUSERGROUP[];
 };
 
-export type UserGroupRelationListResult<TUSERGROUPRELATION extends IUserGroupRelationHasId = IUserGroupRelationHasId> = {
-  userGroupRelations: TUSERGROUPRELATION[],
+export type UserGroupRelationListResult<
+  TUSERGROUPRELATION extends IUserGroupRelationHasId = IUserGroupRelationHasId,
+> = {
+  userGroupRelations: TUSERGROUPRELATION[];
 };
 
-export type IUserGroupRelationHasIdPopulatedUser<TUSERGROUP extends IUserGroup = IUserGroup> = {
-  relatedGroup: Ref<TUSERGROUP>,
-  relatedUser: IUserHasId,
-  createdAt: Date,
+export type IUserGroupRelationHasIdPopulatedUser<
+  TUSERGROUP extends IUserGroup = IUserGroup,
+> = {
+  relatedGroup: Ref<TUSERGROUP>;
+  relatedUser: IUserHasId;
+  createdAt: Date;
 } & HasObjectId;
 
 export type UserGroupRelationsResult = {
-  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[],
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[];
 };
 
 export type UserGroupPagesResult = {
-  pages: IPageHasId[],
-}
+  pages: IPageHasId[];
+};
 
 export type SelectableParentUserGroupsResult = {
-  selectableParentGroups: IUserGroupHasId[],
-}
+  selectableParentGroups: IUserGroupHasId[];
+};
 
 export type SelectableUserChildGroupsResult = {
-  selectableChildGroups: IUserGroupHasId[],
-}
+  selectableChildGroups: IUserGroupHasId[];
+};
 
 export type AncestorUserGroupsResult = {
-  ancestorUserGroups: IUserGroupHasId[],
-}
+  ancestorUserGroups: IUserGroupHasId[];
+};

+ 8 - 3
apps/app/src/interfaces/user-group.ts

@@ -4,7 +4,12 @@ export const SearchTypes = {
   BACKWORD: 'backword',
 } as const;
 
-export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];
+export type SearchType = (typeof SearchTypes)[keyof typeof SearchTypes];
 
-export const PageActionOnGroupDelete = { publicize: 'publicize', delete: 'delete', transfer: 'transfer' } as const;
-export type PageActionOnGroupDelete = typeof PageActionOnGroupDelete[keyof typeof PageActionOnGroupDelete];
+export const PageActionOnGroupDelete = {
+  publicize: 'publicize',
+  delete: 'delete',
+  transfer: 'transfer',
+} as const;
+export type PageActionOnGroupDelete =
+  (typeof PageActionOnGroupDelete)[keyof typeof PageActionOnGroupDelete];

+ 1 - 2
apps/app/src/interfaces/user-trigger-notification.ts

@@ -1,3 +1,2 @@
-
 type SlackChannel = string;
-export type SlackChannels = {[updatePost: string]: SlackChannel[]}
+export type SlackChannels = { [updatePost: string]: SlackChannel[] };

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio