Jelajahi Sumber

Merge pull request #10468 from growilabs/master

Release v7.3.5
mergify[bot] 4 bulan lalu
induk
melakukan
3b6c8dcd0e
89 mengubah file dengan 3668 tambahan dan 2024 penghapusan
  1. 3 1
      .github/workflows/release-pdf-converter.yml
  2. 2 0
      apps/app/.eslintrc.js
  3. 2 2
      apps/app/package.json
  4. 13 5
      apps/app/playwright/10-installer/install.spec.ts
  5. 101 73
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  6. 17 9
      apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts
  7. 10 7
      apps/app/playwright/20-basic-features/click-page-icons.spec.ts
  8. 13 9
      apps/app/playwright/20-basic-features/comments.spec.ts
  9. 16 6
      apps/app/playwright/20-basic-features/create-page-button.spec.ts
  10. 9 5
      apps/app/playwright/20-basic-features/presentation.spec.ts
  11. 35 13
      apps/app/playwright/20-basic-features/sticky-features.spec.ts
  12. 25 15
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  13. 9 9
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  14. 10 5
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  15. 38 33
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  16. 13 9
      apps/app/playwright/23-editor/saving.spec.ts
  17. 8 4
      apps/app/playwright/23-editor/template-modal.spec.ts
  18. 31 17
      apps/app/playwright/23-editor/with-navigation.spec.ts
  19. 82 68
      apps/app/playwright/30-search/search.spect.ts
  20. 29 19
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  21. 20 16
      apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts
  22. 1 2
      apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts
  23. 26 18
      apps/app/playwright/60-home/home.spec.ts
  24. 1 1
      apps/app/playwright/auth.setup.ts
  25. 8 4
      apps/app/playwright/utils/CollapseSidebar.ts
  26. 1 2
      apps/app/playwright/utils/Login.ts
  27. 12 1
      apps/app/public/static/locales/en_US/translation.json
  28. 12 1
      apps/app/public/static/locales/fr_FR/translation.json
  29. 12 1
      apps/app/public/static/locales/ja_JP/translation.json
  30. 12 1
      apps/app/public/static/locales/ko_KR/translation.json
  31. 12 1
      apps/app/public/static/locales/zh_CN/translation.json
  32. 81 0
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  33. 83 0
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  34. 9 0
      apps/app/src/client/components/UsersHomepageFooter.tsx
  35. 32 2
      apps/app/src/interfaces/activity.ts
  36. 5 2
      apps/app/src/server/models/GlobalNotificationSetting.ts
  37. 15 8
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  38. 15 8
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  39. 12 9
      apps/app/src/server/models/GlobalNotificationSetting/index.js
  40. 90 45
      apps/app/src/server/models/access-token.ts
  41. 115 89
      apps/app/src/server/models/activity.ts
  42. 60 38
      apps/app/src/server/models/attachment.ts
  43. 127 62
      apps/app/src/server/models/bookmark-folder.ts
  44. 24 24
      apps/app/src/server/models/bookmark.js
  45. 14 10
      apps/app/src/server/models/config.ts
  46. 12 10
      apps/app/src/server/models/editor-settings.ts
  47. 2 5
      apps/app/src/server/models/errors.ts
  48. 2 1
      apps/app/src/server/models/eslint-rules-dir/no-populate.js
  49. 7 4
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  50. 97 66
      apps/app/src/server/models/external-account.ts
  51. 15 8
      apps/app/src/server/models/in-app-notification-settings.ts
  52. 80 67
      apps/app/src/server/models/in-app-notification.ts
  53. 12 9
      apps/app/src/server/models/named-query.ts
  54. 298 148
      apps/app/src/server/models/obsolete-page.js
  55. 123 91
      apps/app/src/server/models/page-operation.ts
  56. 45 30
      apps/app/src/server/models/page-redirect.ts
  57. 91 53
      apps/app/src/server/models/page-tag-relation.ts
  58. 416 295
      apps/app/src/server/models/page.ts
  59. 41 36
      apps/app/src/server/models/password-reset-order.ts
  60. 59 35
      apps/app/src/server/models/revision.ts
  61. 5 1
      apps/app/src/server/models/serializers/page-serializer.js
  62. 7 2
      apps/app/src/server/models/serializers/user-group-relation-serializer.js
  63. 21 19
      apps/app/src/server/models/share-link.ts
  64. 24 14
      apps/app/src/server/models/slack-app-integration.js
  65. 103 56
      apps/app/src/server/models/subscription.ts
  66. 18 14
      apps/app/src/server/models/tag.ts
  67. 19 12
      apps/app/src/server/models/transfer-key.ts
  68. 48 35
      apps/app/src/server/models/update-post.ts
  69. 135 100
      apps/app/src/server/models/user-group-relation.ts
  70. 50 30
      apps/app/src/server/models/user-group.ts
  71. 40 32
      apps/app/src/server/models/user-registration-order.ts
  72. 9 11
      apps/app/src/server/models/user-ui-settings.ts
  73. 233 143
      apps/app/src/server/models/user.js
  74. 0 2
      apps/app/src/server/models/vo/collection-progress.ts
  75. 1 6
      apps/app/src/server/models/vo/collection-progressing-status.ts
  76. 2 3
      apps/app/src/server/models/vo/g2g-transfer-error.ts
  77. 1 6
      apps/app/src/server/models/vo/s2c-message.js
  78. 1 3
      apps/app/src/server/models/vo/s2s-message.js
  79. 0 2
      apps/app/src/server/models/vo/search-error.ts
  80. 11 14
      apps/app/src/server/models/vo/slack-command-handler-error.ts
  81. 0 2
      apps/app/src/server/models/vo/v5-conversion-error.ts
  82. 1 0
      apps/app/src/server/routes/apiv3/index.js
  83. 300 0
      apps/app/src/server/routes/apiv3/user-activities.ts
  84. 39 0
      apps/app/src/server/util/locale-utils.ts
  85. 32 0
      apps/app/src/stores/recent-activity.ts
  86. 1 1
      apps/pdf-converter/package.json
  87. 1 1
      apps/slackbot-proxy/package.json
  88. 0 2
      biome.json
  89. 1 1
      package.json

+ 3 - 1
.github/workflows/release-pdf-converter.yml

@@ -28,7 +28,9 @@ jobs:
         images: growilabs/pdf-converter
         tags: |
           type=raw,value=latest
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}.{{patch}}
 
     - name: Login to docker.io registry
       run: |

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

@@ -23,6 +23,7 @@ module.exports = {
     'test/integration/models/**',
     'test/integration/service/**',
     'test/integration/setup.js',
+    'playwright/**',
     'test-with-vite/**',
     'public/**',
     'bin/**',
@@ -53,6 +54,7 @@ module.exports = {
     'src/server/crowi/**',
     'src/server/events/**',
     'src/server/interfaces/**',
+    'src/server/models/**',
     'src/server/util/**',
     'src/server/app.ts',
     'src/server/repl.ts',

+ 2 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.4",
+  "version": "7.3.5-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -28,7 +28,7 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
-    "lint:biome": "biome check",
+    "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",

+ 13 - 5
apps/app/playwright/10-installer/install.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-test('Installer', async({ page }) => {
+test('Installer', async ({ page }) => {
   await page.goto('/');
   await page.waitForURL('/installer');
 
@@ -11,18 +11,26 @@ test('Installer', async({ page }) => {
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toHaveAttribute('placeholder', 'ユーザーID');
+  await expect(
+    page.getByRole('textbox', { name: 'ユーザーID' }),
+  ).toHaveAttribute('placeholder', 'ユーザーID');
 
   // choose Chinese
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute('placeholder', '用户ID');
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute(
+    'placeholder',
+    '用户ID',
+  );
   // // choose English
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-en_US').click();
   await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute('placeholder', 'User ID');
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute(
+    'placeholder',
+    'User ID',
+  );
 
   await page.getByRole('textbox', { name: 'User ID' }).focus();
 

+ 101 - 73
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -1,32 +1,36 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 
-test('has title', async({ page }) => {
+test('has title', async ({ page }) => {
   await page.goto('/Sandbox');
 
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
 });
 
-test('get h1', async({ page }) => {
+test('get h1', async ({ page }) => {
   await page.goto('/Sandbox');
 
   // Expects page to have a heading with the name of Installation.
-  await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
+  await expect(
+    page.getByRole('heading').filter({ hasText: /\/Sandbox/ }),
+  ).toBeVisible();
 });
 
-test('/Sandbox/Math is successfully loaded', async({ page }) => {
+test('/Sandbox/Math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Expect the Math-specific elements to be present
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 
-test('Sandbox with edit is successfully loaded', async({ page }) => {
+test('Sandbox with edit is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox#edit');
 
   // Expect the Editor-specific elements to be present
@@ -35,116 +39,140 @@ test('Sandbox with edit is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
 });
 
-test.describe.serial('PageEditor', () => {
-  const body1 = 'hello';
-  const body2 = ' world!';
-  const targetPath = '/Sandbox/testForUseEditingMarkdown';
+test.describe
+  .serial('PageEditor', () => {
+    const body1 = 'hello';
+    const body2 = ' world!';
+    const targetPath = '/Sandbox/testForUseEditingMarkdown';
 
-  test('Edit and save with save-page-btn', async({ page }) => {
-    await page.goto(targetPath);
+    test('Edit and save with save-page-btn', async ({ page }) => {
+      await page.goto(targetPath);
 
-    await page.getByTestId('editor-button').click();
-    await appendTextToEditorUntilContains(page, body1);
-    await page.getByTestId('save-page-btn').click();
+      await page.getByTestId('editor-button').click();
+      await appendTextToEditorUntilContains(page, body1);
+      await page.getByTestId('save-page-btn').click();
 
-    await expect(page.locator('.wiki').first()).toContainText(body1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(body1);
+    });
 
-  test('Edit and save with shortcut key', async({ page }) => {
-    const savePageShortcutKey = 'Control+s';
+    test('Edit and save with shortcut key', async ({ page }) => {
+      const savePageShortcutKey = 'Control+s';
 
-    await page.goto(targetPath);
+      await page.goto(targetPath);
 
-    await page.getByTestId('editor-button').click();
+      await page.getByTestId('editor-button').click();
 
-    await expect(page.locator('.cm-content')).toContainText(body1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(body1);
+      await expect(page.locator('.cm-content')).toContainText(body1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        body1,
+      );
 
-    await appendTextToEditorUntilContains(page, body1 + body2);
-    await page.keyboard.press(savePageShortcutKey);
-    await page.getByTestId('view-button').click();
+      await appendTextToEditorUntilContains(page, body1 + body2);
+      await page.keyboard.press(savePageShortcutKey);
+      await page.getByTestId('view-button').click();
 
-    await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+      await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+    });
   });
-});
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 
-test('All In-App Notification list is successfully loaded', async({ page }) => {
+test('All In-App Notification list is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/me/all-in-app-notifications');
 
   // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
   await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
 });
 
-test('/trash is successfully loaded', async({ page }) => {
+test('/trash is successfully loaded', async ({ page }) => {
   await page.goto('/trash');
 
-  await expect(page.getByTestId('trash-page-list')).toContainText('There are no pages under this page.');
+  await expect(page.getByTestId('trash-page-list')).toContainText(
+    'There are no pages under this page.',
+  );
 });
 
-test('/tags is successfully loaded', async({ page }) => {
+test('/tags is successfully loaded', async ({ page }) => {
   await page.goto('/tags');
 
-  await expect(page.getByTestId('grw-tags-list')).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list')).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 });
 
-test.describe.serial('Access to Template Editing Mode', () => {
-  const templateBody1 = 'Template for children';
-  const templateBody2 = 'Template for descendants';
+test.describe
+  .serial('Access to Template Editing Mode', () => {
+    const templateBody1 = 'Template for children';
+    const templateBody2 = 'Template for descendants';
 
-  test('Successfully created template for children', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for children', async ({ page }) => {
+      await page.goto('/Sandbox');
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
-    await page.getByTestId('template-button-children').click();
+      await page.getByTestId('template-button-children').click();
 
-    await appendTextToEditorUntilContains(page, templateBody1);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody1);
+      await page.getByTestId('save-page-btn').click();
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody1);
+    });
 
-  test('Template is applied to pages created (template for children)', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Template is applied to pages created (template for children)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox');
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody1);
-  });
+      await expect(page.locator('.cm-content')).toContainText(templateBody1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody1,
+      );
+    });
 
-  test('Successfully created template for descendants', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for descendants', async ({ page }) => {
+      await page.goto('/Sandbox');
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
-    await page.getByTestId('template-button-descendants').click();
+      await page.getByTestId('template-button-descendants').click();
 
-    await appendTextToEditorUntilContains(page, templateBody2);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody2);
+      await page.getByTestId('save-page-btn').click();
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody2);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody2);
+    });
 
-  test('Template is applied to pages created (template for descendants)', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
+    test('Template is applied to pages created (template for descendants)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox/Bootstrap5');
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody2);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody2);
+      await expect(page.locator('.cm-content')).toContainText(templateBody2);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody2,
+      );
+    });
   });
-});

+ 17 - 9
apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts

@@ -1,29 +1,37 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
-const openPageAccessoriesModal = async(page: Page): Promise<void> => {
+const openPageAccessoriesModal = async (page: Page): Promise<void> => {
   await page.goto('/');
   await page.getByTestId('pageListButton').click();
   await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
 };
 
-test('Page list modal is successfully opened', async({ page }) => {
+test('Page list modal is successfully opened', async ({ page }) => {
   await openPageAccessoriesModal(page);
-  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText('You cannot see this page');
+  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText(
+    'You cannot see this page',
+  );
 });
 
-test('Successfully open PageItemControl', async({ page }) => {
+test('Successfully open PageItemControl', async ({ page }) => {
   await openPageAccessoriesModal(page);
-  await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('page-list-item-L')
+    .first()
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await expect(page.locator('.dropdown-menu.show')).toBeVisible();
 });
 
-test('Successfully close modal', async({ page }) => {
+test('Successfully close modal', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await page.locator('.btn-close').click();
-  await expect(page.getByTestId('descendants-page-list-modal')).not.toBeVisible();
+  await expect(
+    page.getByTestId('descendants-page-list-modal'),
+  ).not.toBeVisible();
 });
 
-test('Timeline list successfully openend', async({ page }) => {
+test('Timeline list successfully openend', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await page.getByTestId('timeline-tab-button').click();
   await expect(page.locator('.card-timeline').first()).toBeVisible();

+ 10 - 7
apps/app/playwright/20-basic-features/click-page-icons.spec.ts

@@ -1,11 +1,11 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 test.describe('Click page icons', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/Sandbox');
   });
 
-  test('Successfully Subscribe/Unsubscribe a page', async({ page }) => {
+  test('Successfully Subscribe/Unsubscribe a page', async ({ page }) => {
     const subscribeButton = page.locator('.btn-subscribe');
 
     // Subscribe
@@ -17,7 +17,7 @@ test.describe('Click page icons', () => {
     await expect(subscribeButton).not.toHaveClass(/active/);
   });
 
-  test('Successfully Like/Unlike a page', async({ page }) => {
+  test('Successfully Like/Unlike a page', async ({ page }) => {
     const likeButton = page.locator('.btn-like').first();
 
     // Like
@@ -29,7 +29,7 @@ test.describe('Click page icons', () => {
     await expect(likeButton).not.toHaveClass(/active/);
   });
 
-  test('Successfully Bookmark / Unbookmark a page', async({ page }) => {
+  test('Successfully Bookmark / Unbookmark a page', async ({ page }) => {
     const bookmarkButton = page.locator('.btn-bookmark').first();
 
     // Bookmark
@@ -41,10 +41,13 @@ test.describe('Click page icons', () => {
     await expect(bookmarkButton).not.toHaveClass(/active/);
   });
 
-  test('Successfully display list of "seen by user"', async({ page }) => {
+  test('Successfully display list of "seen by user"', async ({ page }) => {
     await page.locator('.btn-seen-user').click();
 
-    const imgCount = await page.locator('.user-list-content').locator('img').count();
+    const imgCount = await page
+      .locator('.user-list-content')
+      .locator('img')
+      .count();
     expect(imgCount).toBe(1);
   });
 });

+ 13 - 9
apps/app/playwright/20-basic-features/comments.spec.ts

@@ -1,18 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 test.describe('Comment', () => {
-
   // make tests run in serial
   test.describe.configure({ mode: 'serial' });
 
-  test('Create comment page', async({ page }) => {
+  test('Create comment page', async ({ page }) => {
     await page.goto('/comment');
     await page.getByTestId('editor-button').click();
     await page.getByTestId('save-page-btn').click();
     await expect(page.locator('.page-meta')).toBeVisible();
   });
 
-  test('Successfully add comments', async({ page }) => {
+  test('Successfully add comments', async ({ page }) => {
     const commentText = 'add comment';
     await page.goto('/comment');
 
@@ -23,10 +22,12 @@ test.describe('Comment', () => {
     await page.getByTestId('comment-submit-button').first().click();
 
     await expect(page.locator('.page-comment-body')).toHaveText(commentText);
-    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
+    await expect(
+      page.getByTestId('page-comment-button').locator('.grw-count-badge'),
+    ).toHaveText('1');
   });
 
-  test('Successfully reply comments', async({ page }) => {
+  test('Successfully reply comments', async ({ page }) => {
     const commentText = 'reply comment';
     await page.goto('/comment');
 
@@ -35,8 +36,12 @@ test.describe('Comment', () => {
     await page.locator('.cm-content').fill(commentText);
     await page.getByTestId('comment-submit-button').first().click();
 
-    await expect(page.locator('.page-comment-body').nth(1)).toHaveText(commentText);
-    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('2');
+    await expect(page.locator('.page-comment-body').nth(1)).toHaveText(
+      commentText,
+    );
+    await expect(
+      page.getByTestId('page-comment-button').locator('.grw-count-badge'),
+    ).toHaveText('2');
   });
 
   // test('Successfully delete comments', async({ page }) => {
@@ -51,5 +56,4 @@ test.describe('Comment', () => {
   // });
 
   // TODO: https://redmine.weseek.co.jp/issues/139520
-
 });

+ 16 - 6
apps/app/playwright/20-basic-features/create-page-button.spec.ts

@@ -1,10 +1,13 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 test.describe('Create page button', () => {
-  test('click and autofocus to title text input', async({ page }) => {
+  test('click and autofocus to title text input', async ({ page }) => {
     await page.goto('/');
 
-    await page.getByTestId('grw-page-create-button').getByRole('button', { name: 'Create' }).click();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByRole('button', { name: 'Create' })
+      .click();
 
     // should be focused
     await expect(page.getByPlaceholder('Input page name')).toBeFocused();
@@ -12,13 +15,20 @@ test.describe('Create page button', () => {
 });
 
 test.describe('Create page button dropdown menu', () => {
-  test('open and create today page', async({ page }) => {
+  test('open and create today page', async ({ page }) => {
     await page.goto('/');
 
     // open dropdown menu
     await page.getByTestId('grw-page-create-button').hover();
-    await expect(page.getByTestId('grw-page-create-button').getByLabel('Open create page menu')).toBeVisible();
-    await page.getByTestId('grw-page-create-button').getByLabel('Open create page menu').dispatchEvent('click'); // simulate the click
+    await expect(
+      page
+        .getByTestId('grw-page-create-button')
+        .getByLabel('Open create page menu'),
+    ).toBeVisible();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByLabel('Open create page menu')
+      .dispatchEvent('click'); // simulate the click
     await page.getByRole('menuitem', { name: 'Create today page' }).click();
 
     // should not be visible

+ 9 - 5
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -1,13 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-test('Presentation', async({ page }) => {
+test('Presentation', async ({ page }) => {
   await page.goto('/');
 
   // show presentation modal
-  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('grw-contextual-sub-nav')
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await page.getByTestId('open-presentation-modal-btn').click();
 
   // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/Welcome to GROWI/);
+  await expect(
+    page.getByRole('application').getByRole('heading', { level: 1 }),
+  ).toHaveText(/Welcome to GROWI/);
 });

+ 35 - 13
apps/app/playwright/20-basic-features/sticky-features.spec.ts

@@ -1,47 +1,69 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 test.describe('Sticky features', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
   });
 
-  test('Subnavigation displays changes on scroll down and up', async({ page }) => {
+  test('Subnavigation displays changes on scroll down and up', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
     // Scroll back to top
     await page.evaluate(() => window.scrollTo(0, 0));
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
 
-  test('Subnavigation is not displayed when move to other pages', async({ page }) => {
+  test('Subnavigation is not displayed when move to other pages', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
     // Move to /Sandbox page
     await page.goto('/Sandbox');
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
 
-  test('Able to click buttons on subnavigation switcher when sticky', async({ page }) => {
+  test('Able to click buttons on subnavigation switcher when sticky', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
     // Click editor button
     await page.getByTestId('editor-button').click();
     await expect(page.locator('.layout-root')).toHaveClass(/editing/);
   });
 
-  test('Subnavigation is sticky when on small window', async({ page }) => {
+  test('Subnavigation is sticky when on small window', async ({ page }) => {
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 500));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
     // Set viewport to small size
     await page.setViewportSize({ width: 600, height: 1024 });
-    await expect(page.getByTestId('grw-contextual-sub-nav').getByTestId('grw-page-editor-mode-manager')).toBeVisible();
+    await expect(
+      page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('grw-page-editor-mode-manager'),
+    ).toBeVisible();
   });
 });

+ 25 - 15
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
-const openPageItemControl = async(page: Page): Promise<void> => {
+const openPageItemControl = async (page: Page): Promise<void> => {
   const nav = page.getByTestId('grw-contextual-sub-nav');
   const button = nav.getByTestId('open-page-item-control-btn');
 
@@ -19,7 +19,7 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
 };
 
-test('PageDeleteModal is shown successfully', async({ page }) => {
+test('PageDeleteModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
 
   await openPageItemControl(page);
@@ -28,7 +28,7 @@ test('PageDeleteModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
 });
 
-test('PageDuplicateModal is shown successfully', async({ page }) => {
+test('PageDuplicateModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
 
   await openPageItemControl(page);
@@ -37,7 +37,7 @@ test('PageDuplicateModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
 });
 
-test('PageMoveRenameModal is shown successfully', async({ page }) => {
+test('PageMoveRenameModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
 
   await openPageItemControl(page);
@@ -57,35 +57,45 @@ test('PageMoveRenameModal is shown successfully', async({ page }) => {
 // });
 
 test.describe('Page Accessories Modal', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await openPageItemControl(page);
   });
 
-  test('Page History is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-history-tab').click();
-    await expect(page.getByTestId(('page-history'))).toBeVisible();
+  test('Page History is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-history-tab')
+      .click();
+    await expect(page.getByTestId('page-history')).toBeVisible();
   });
 
-  test('Page Attachment Data is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab').click();
+  test('Page Attachment Data is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab')
+      .click();
     await expect(page.getByTestId('page-attachment')).toBeVisible();
   });
 
-  test('Share Link Management is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+  test('Share Link Management is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId(
+        'open-page-accessories-modal-btn-with-share-link-management-data-tab',
+      )
+      .click();
     await expect(page.getByTestId('share-link-management')).toBeVisible();
   });
 });
 
-test('Successfully add new tag', async({ page }) => {
+test('Successfully add new tag', async ({ page }) => {
   const tag = 'we';
   await page.goto('/Sandbox/Bootstrap5');
 
   await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
   await expect(page.locator('#edit-tag-modal')).toBeVisible();
   await page.locator('.rbt-input-main').fill(tag);
-  await expect(page.locator('#tag-typeahead-asynctypeahead-item-0')).toBeVisible();
+  await expect(
+    page.locator('#tag-typeahead-asynctypeahead-item-0'),
+  ).toBeVisible();
   await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
   await page.getByTestId('tag-edit-done-btn').click();
   await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);

+ 9 - 9
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -1,45 +1,45 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 import { collapseSidebar } from '../utils';
 
-test('/Sandbox is successfully loaded', async({ page }) => {
-
+test('/Sandbox is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox');
 
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
 });
 
-test('/Sandbox/math is successfully loaded', async({ page }) => {
-
+test('/Sandbox/math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Check if the math elements are visible
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
 
   // Expect to be redirected to /login when accessing /me
   await expect(page.getByTestId('login-form')).toBeVisible();
 });
 
-test('Access to /trash page', async({ page }) => {
+test('Access to /trash page', async ({ page }) => {
   await page.goto('/trash');
 
   // Expect the trash page specific elements to be present when accessing /trash
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
 });
 
-test('Access to /tags page', async({ page }) => {
+test('Access to /tags page', async ({ page }) => {
   await page.goto('/');
 
   await collapseSidebar(page, false);
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
-  await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list').first()).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 
   await page.getByTestId('check-all-tags-button').click();
   await expect(page.getByTestId('tags-page')).toBeVisible();

+ 10 - 5
apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts

@@ -1,14 +1,19 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-
-test('Sub navigation sticky changes when scrolling down and up', async({ page }) => {
+test('Sub navigation sticky changes when scrolling down and up', async ({
+  page,
+}) => {
   await page.goto('/Sandbox');
 
   // Sticky
   await page.evaluate(() => window.scrollTo(0, 250));
-  await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+  await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+    /active/,
+  );
 
   // Not sticky
   await page.evaluate(() => window.scrollTo(0, 0));
-  await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+  await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+    /active/,
+  );
 });

+ 38 - 33
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -1,37 +1,42 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 import { login } from '../utils/Login';
 
-test.describe.serial('Access to sharelink by guest', () => {
-  let createdSharelink: string | null;
-
-  test('Prepare sharelink', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
-
-    // Create Sharelink
-    await page.getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
-    await page.getByTestId('btn-sharelink-toggleform').click();
-    await page.getByTestId('btn-sharelink-issue').click();
-
-    // Get ShareLink
-    createdSharelink = await page.getByTestId('share-link').textContent();
-    expect(createdSharelink).toHaveLength(24);
-  });
-
-  test('The sharelink page is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Logout
-    await page.getByTestId('personal-dropdown-button').click();
-    await expect(page.getByTestId('logout-button')).toBeVisible();
-    await page.getByTestId('logout-button').click();
-    await page.waitForURL('http://localhost:3000/login');
-
-    // Access sharelink
-    await page.goto(`/share/${createdSharelink}`);
-    await expect(page.locator('.page-meta')).toBeVisible();
-
-    await login(page);
+test.describe
+  .serial('Access to sharelink by guest', () => {
+    let createdSharelink: string | null;
+
+    test('Prepare sharelink', async ({ page }) => {
+      await page.goto('/Sandbox/Bootstrap5');
+
+      // Create Sharelink
+      await page.getByTestId('open-page-item-control-btn').click();
+      await page
+        .getByTestId(
+          'open-page-accessories-modal-btn-with-share-link-management-data-tab',
+        )
+        .click();
+      await page.getByTestId('btn-sharelink-toggleform').click();
+      await page.getByTestId('btn-sharelink-issue').click();
+
+      // Get ShareLink
+      createdSharelink = await page.getByTestId('share-link').textContent();
+      expect(createdSharelink).toHaveLength(24);
+    });
+
+    test('The sharelink page is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Logout
+      await page.getByTestId('personal-dropdown-button').click();
+      await expect(page.getByTestId('logout-button')).toBeVisible();
+      await page.getByTestId('logout-button').click();
+      await page.waitForURL('http://localhost:3000/login');
+
+      // Access sharelink
+      await page.goto(`/share/${createdSharelink}`);
+      await expect(page.locator('.page-meta')).toBeVisible();
+
+      await login(page);
+    });
   });
-});

+ 13 - 9
apps/app/playwright/23-editor/saving.spec.ts

@@ -1,14 +1,14 @@
+import { expect, type Page, test } from '@playwright/test';
 import path from 'path';
 
-import { test, expect, type Page } from '@playwright/test';
-
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 
-
-test('Successfully create page under specific path', async({ page }) => {
+test('Successfully create page under specific path', async ({ page }) => {
   const newPagePath = '/child';
   const openPageCreateModalShortcutKey = 'c';
 
@@ -16,7 +16,10 @@ test('Successfully create page under specific path', async({ page }) => {
 
   await page.keyboard.press(openPageCreateModalShortcutKey);
   await expect(page.getByTestId('page-create-modal')).toBeVisible();
-  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  page
+    .getByTestId('page-create-modal')
+    .locator('.rbt-input-main')
+    .fill(newPagePath);
   page.getByTestId('btn-create-page-under-below').click();
   await page.getByTestId('view-button').click();
 
@@ -24,8 +27,9 @@ test('Successfully create page under specific path', async({ page }) => {
   expect(createdPageId.length).toBe(24);
 });
 
-
-test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+test('Successfully updating a page using a shortcut on a previously created page', async ({
+  page,
+}) => {
   const body1 = 'hello';
   const body2 = ' world!';
   const savePageShortcutKey = 'Control+s';

+ 8 - 4
apps/app/playwright/23-editor/template-modal.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-test('Successfully select template and template locale', async({ page }) => {
+test('Successfully select template and template locale', async ({ page }) => {
   const jaText = '今日の目標';
   const enText = "TODAY'S GOALS";
   await page.goto('/Sandbox/TemplateModal');
@@ -16,10 +16,14 @@ test('Successfully select template and template locale', async({ page }) => {
 
   // select template and template locale
   await templateModal.locator('.list-group-item').nth(0).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(enText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(enText);
   await templateModal.getByTestId('select-locale-dropdown-toggle').click();
   await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(jaText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(jaText);
 
   // insert
   await templateModal.locator('.btn-primary').click();

+ 31 - 17
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -1,14 +1,15 @@
+import { expect, type Page, test } from '@playwright/test';
 import { readFileSync } from 'fs';
 import path from 'path';
 
-import { test, expect, type Page } from '@playwright/test';
-
 /**
  * for the issues:
  * @see https://redmine.weseek.co.jp/issues/122040
  * @see https://redmine.weseek.co.jp/issues/124281
  */
-test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+test('should not be cleared and should prevent GrantSelector from modified', async ({
+  page,
+}) => {
   await page.goto('/Sandbox/for-122040');
 
   // Open Editor
@@ -26,10 +27,10 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
   const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
   const buffer = readFileSync(filePath).toString('base64');
   const dataTransfer = await page.evaluateHandle(
-    async({ bufferData, localFileName, localFileType }) => {
+    async ({ bufferData, localFileName, localFileType }) => {
       const dt = new DataTransfer();
 
-      const blobData = await fetch(bufferData).then(res => res.blob());
+      const blobData = await fetch(bufferData).then((res) => res.blob());
 
       const file = new File([blobData], localFileName, {
         type: localFileType,
@@ -43,33 +44,41 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
       localFileType: 'application/octet-stream',
     },
   );
-  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
-  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+  await page
+    .locator('.dropzone')
+    .first()
+    .dispatchEvent('drop', { dataTransfer });
+  await expect(
+    page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment'),
+  ).toBeVisible();
 
   // Save page
   await page.getByTestId('save-page-btn').click();
 
   // Expect grant not to be reset after uploading an attachment
-  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+  await expect(page.getByTestId('page-grant-alert')).toContainText(
+    'Browsing of this page is restricted',
+  );
 });
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 
 /**
  * for the issue:
  * @see https://redmine.weseek.co.jp/issues/115285
  */
-test('Successfully updating the page body', async({ page }) => {
+test('Successfully updating the page body', async ({ page }) => {
   const page1Path = '/Sandbox/for-115285/page1';
   const page2Path = '/Sandbox/for-115285/page2';
 
   const page1Body = 'Hello';
   const page2Body = 'World';
 
-
   await page.goto(page1Path);
 
   // Open Editor (page1)
@@ -85,7 +94,10 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.locator('.main')).toContainText(page1Body);
 
   // Duplicate page1
-  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('grw-contextual-sub-nav')
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await page.getByTestId('open-page-duplicate-modal-btn').click();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await page.locator('.form-control').fill(page2Path);
@@ -96,18 +108,20 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
   // Expect to see the text from which you are duplicating
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 
   // Append text
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
 
-
   await page.goto(page1Path);
 
   // Open Editor (page1)
   await page.getByTestId('editor-button').click();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
-
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 });

+ 82 - 68
apps/app/playwright/30-search/search.spect.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-test('Search page with "q" param is successfully loaded', async({ page }) => {
+test('Search page with "q" param is successfully loaded', async ({ page }) => {
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
 
@@ -11,7 +11,7 @@ test('Search page with "q" param is successfully loaded', async({ page }) => {
   await expect(page.locator('.wiki')).toBeVisible();
 });
 
-test('checkboxes behaviors', async({ page }) => {
+test('checkboxes behaviors', async ({ page }) => {
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
 
@@ -28,7 +28,10 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
 
   // Click the select all checkbox
-  await page.getByTestId('delete-control-button').first().click({ force: true });
+  await page
+    .getByTestId('delete-control-button')
+    .first()
+    .click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
 
   // Unclick the first checkbox after selecting all
@@ -41,16 +44,19 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
 });
 
-
-test('successfully loads /_private-legacy-pages', async({ page }) => {
+test('successfully loads /_private-legacy-pages', async ({ page }) => {
   await page.goto('/_private-legacy-pages');
 
   // Confirm search result elements are visible
-  await expect(page.locator('[data-testid="search-result-base"]')).toBeVisible();
-  await expect(page.locator('[data-testid="search-result-private-legacy-pages"]')).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-base"]'),
+  ).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-private-legacy-pages"]'),
+  ).toBeVisible();
 });
 
-test('Search all pages by word', async({ page }) => {
+test('Search all pages by word', async ({ page }) => {
   await page.goto('/');
   await page.getByTestId('open-search-modal-button').click();
   await expect(page.getByTestId('search-modal')).toBeVisible();
@@ -58,51 +64,51 @@ test('Search all pages by word', async({ page }) => {
   await expect(page.locator('.search-menu-item').first()).toBeVisible();
 });
 
-test.describe.serial('Search all pages', () => {
-  const tag = 'help';
-  const searchText = `tag:${tag}`;
-
-  test('Successfully created tags', async({ page }) => {
-    await page.goto('/');
-
-    // open Edit Tags Modal to add tag
-    await page.locator('.grw-side-contents-sticky-container').isVisible();
-    await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
-    await expect(page.locator('#edit-tag-modal')).toBeVisible();
-    await page.locator('.rbt-input-main').fill(tag);
-    await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
-    await page.getByTestId('tag-edit-done-btn').click();
-
-  });
-
-  test('Search all pages by tag is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Search
-    await page.getByTestId('open-search-modal-button').click();
-    await expect(page.getByTestId('search-modal')).toBeVisible();
-    await page.locator('.form-control').fill(searchText);
-    await page.getByTestId('search-all-menu-item').click();
-
-    // Confirm search result elements are visible
-    const searchResultList = page.getByTestId('search-result-list');
-    await expect(searchResultList).toBeVisible();
-    await expect(searchResultList.locator('li')).toHaveCount(1);
+test.describe
+  .serial('Search all pages', () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+
+    test('Successfully created tags', async ({ page }) => {
+      await page.goto('/');
+
+      // open Edit Tags Modal to add tag
+      await page.locator('.grw-side-contents-sticky-container').isVisible();
+      await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+      await expect(page.locator('#edit-tag-modal')).toBeVisible();
+      await page.locator('.rbt-input-main').fill(tag);
+      await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+      await page.getByTestId('tag-edit-done-btn').click();
+    });
+
+    test('Search all pages by tag is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Search
+      await page.getByTestId('open-search-modal-button').click();
+      await expect(page.getByTestId('search-modal')).toBeVisible();
+      await page.locator('.form-control').fill(searchText);
+      await page.getByTestId('search-all-menu-item').click();
+
+      // Confirm search result elements are visible
+      const searchResultList = page.getByTestId('search-result-list');
+      await expect(searchResultList).toBeVisible();
+      await expect(searchResultList.locator('li')).toHaveCount(1);
+    });
+
+    test('Successfully order page search results by tag', async ({ page }) => {
+      await page.goto('/');
+
+      await page.locator('.grw-tag-simple-bar').locator('a').click();
+
+      expect(page.getByTestId('search-result-base')).toBeVisible();
+      expect(page.getByTestId('search-result-list')).toBeVisible();
+      expect(page.getByTestId('search-result-content')).toBeVisible();
+    });
   });
 
-  test('Successfully order page search results by tag', async({ page }) => {
-    await page.goto('/');
-
-    await page.locator('.grw-tag-simple-bar').locator('a').click();
-
-    expect(page.getByTestId('search-result-base')).toBeVisible();
-    expect(page.getByTestId('search-result-list')).toBeVisible();
-    expect(page.getByTestId('search-result-content')).toBeVisible();
-  });
-});
-
 test.describe('Sort with dropdown', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=sand');
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
@@ -113,41 +119,40 @@ test.describe('Sort with dropdown', () => {
     await page.locator('.search-control').locator('button').first().click();
   });
 
-  test('Open sort dropdown', async({ page }) => {
-    await expect(page.locator('.search-control .dropdown-menu.show')).toBeVisible();
+  test('Open sort dropdown', async ({ page }) => {
+    await expect(
+      page.locator('.search-control .dropdown-menu.show'),
+    ).toBeVisible();
   });
 
-  test('Sort by relevance', async({ page }) => {
+  test('Sort by relevance', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
 
-  test('Sort by creation date', async({ page }) => {
+  test('Sort by creation date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
 
-  test('Sort by last update date', async({ page }) => {
+  test('Sort by last update date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
@@ -155,22 +160,26 @@ test.describe('Sort with dropdown', () => {
 });
 
 test.describe('Search and use', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=alerts');
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
 
-    await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+    await page
+      .getByTestId('page-list-item-L')
+      .first()
+      .getByTestId('open-page-item-control-btn')
+      .click();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
 
-  test('Successfully the dropdown is opened', async({ page }) => {
+  test('Successfully the dropdown is opened', async ({ page }) => {
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
 
-  test('Successfully add bookmark', async({ page }) => {
+  test('Successfully add bookmark', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -178,10 +187,15 @@ test.describe('Search and use', () => {
     // Add bookmark
     await dropdonwMenu.getByTestId('add-bookmark-btn').click();
 
-    await expect(page.getByTestId('search-result-content').locator('.btn-bookmark.active').first()).toBeVisible();
+    await expect(
+      page
+        .getByTestId('search-result-content')
+        .locator('.btn-bookmark.active')
+        .first(),
+    ).toBeVisible();
   });
 
-  test('Successfully open duplicate modal', async({ page }) => {
+  test('Successfully open duplicate modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -191,7 +205,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   });
 
-  test('Successfully open move/rename modal', async({ page }) => {
+  test('Successfully open move/rename modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -201,7 +215,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-rename-modal')).toBeVisible();
   });
 
-  test('Successfully open delete modal', async({ page }) => {
+  test('Successfully open delete modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -212,7 +226,7 @@ test.describe('Search and use', () => {
   });
 });
 
-test('Search current tree by word is successfully loaded', async({ page }) => {
+test('Search current tree by word is successfully loaded', async ({ page }) => {
   await page.goto('/');
   const searchText = 'GROWI';
 

+ 29 - 19
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -1,13 +1,15 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-test('admin is successfully loaded', async({ page }) => {
+test('admin is successfully loaded', async ({ page }) => {
   await page.goto('/admin');
 
   await expect(page.getByTestId('admin-home')).toBeVisible();
-  await expect(page.getByTestId('admin-system-information-table')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-system-information-table'),
+  ).toBeVisible();
 });
 
-test('admin/app is successfully loaded', async({ page }) => {
+test('admin/app is successfully loaded', async ({ page }) => {
   await page.goto('/admin/app');
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
@@ -15,46 +17,50 @@ test('admin/app is successfully loaded', async({ page }) => {
   await expect(page.locator('#cbFileUpload')).toBeChecked();
 });
 
-test('admin/security is successfully loaded', async({ page }) => {
+test('admin/security is successfully loaded', async ({ page }) => {
   await page.goto('/admin/security');
 
   await expect(page.getByTestId('admin-security')).toBeVisible();
-  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText('Always displayed');
-  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText('Always displayed');
+  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText(
+    'Always displayed',
+  );
+  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText(
+    'Always displayed',
+  );
 });
 
-test('admin/markdown is successfully loaded', async({ page }) => {
+test('admin/markdown is successfully loaded', async ({ page }) => {
   await page.goto('/admin/markdown');
 
   await expect(page.getByTestId('admin-markdown')).toBeVisible();
   await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
 });
 
-test('admin/customize is successfully loaded', async({ page }) => {
+test('admin/customize is successfully loaded', async ({ page }) => {
   await page.goto('/admin/customize');
 
   await expect(page.getByTestId('admin-customize')).toBeVisible();
 });
 
-test('admin/importer is successfully loaded', async({ page }) => {
+test('admin/importer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/importer');
 
   await expect(page.getByTestId('admin-import-data')).toBeVisible();
 });
 
-test('admin/export is successfully loaded', async({ page }) => {
+test('admin/export is successfully loaded', async ({ page }) => {
   await page.goto('/admin/export');
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 
-test('admin/data-transfer is successfully loaded', async({ page }) => {
+test('admin/data-transfer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/data-transfer');
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 
-test('admin/notification is successfully loaded', async({ page }) => {
+test('admin/notification is successfully loaded', async ({ page }) => {
   await page.goto('/admin/notification');
 
   await expect(page.getByTestId('admin-notification')).toBeVisible();
@@ -62,7 +68,7 @@ test('admin/notification is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
 });
 
-test('admin/slack-integration is successfully loaded', async({ page }) => {
+test('admin/slack-integration is successfully loaded', async ({ page }) => {
   await page.goto('/admin/slack-integration');
 
   await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
@@ -70,27 +76,31 @@ test('admin/slack-integration is successfully loaded', async({ page }) => {
   await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
 });
 
-test('admin/slack-integration-legacy is successfully loaded', async({ page }) => {
+test('admin/slack-integration-legacy is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/admin/slack-integration-legacy');
 
-  await expect(page.getByTestId('admin-slack-integration-legacy')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-slack-integration-legacy'),
+  ).toBeVisible();
 });
 
-test('admin/users is successfully loaded', async({ page }) => {
+test('admin/users is successfully loaded', async ({ page }) => {
   await page.goto('/admin/users');
 
   await expect(page.getByTestId('admin-users')).toBeVisible();
   await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
 });
 
-test('admin/user-groups is successfully loaded', async({ page }) => {
+test('admin/user-groups is successfully loaded', async ({ page }) => {
   await page.goto('/admin/user-groups');
 
   await expect(page.getByTestId('admin-user-groups')).toBeVisible();
   await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
 });
 
-test('admin/search is successfully loaded', async({ page }) => {
+test('admin/search is successfully loaded', async ({ page }) => {
   await page.goto('/admin/search');
 
   await expect(page.getByTestId('admin-full-text-search')).toBeVisible();

+ 20 - 16
apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts

@@ -1,39 +1,43 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 import { collapseSidebar } from '../utils';
 
-
 test.describe('Access to sidebar', () => {
-
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await collapseSidebar(page, false);
   });
 
-  test('Successfully show sidebar', async({ page }) => {
+  test('Successfully show sidebar', async ({ page }) => {
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
   });
 
-  test('Successfully access to page tree', async({ page }) => {
+  test('Successfully access to page tree', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.getByTestId('grw-pagetree-item-container').first()).toBeVisible();
+    await expect(
+      page.getByTestId('grw-pagetree-item-container').first(),
+    ).toBeVisible();
   });
 
-  test('Successfully access to recent changes', async({ page }) => {
+  test('Successfully access to recent changes', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
     await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
     await expect(page.locator('.list-group-item').first()).toBeVisible();
   });
 
-  test('Successfully access to custom sidebar', async({ page }) => {
+  test('Successfully access to custom sidebar', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.locator('.grw-sidebar-content-header > h3').locator('a')).toBeVisible();
+    await expect(
+      page.locator('.grw-sidebar-content-header > h3').locator('a'),
+    ).toBeVisible();
   });
 
-  test('Successfully access to GROWI Docs page', async({ page }) => {
-    const linkElement = page.locator('.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]');
+  test('Successfully access to GROWI Docs page', async ({ page }) => {
+    const linkElement = page.locator(
+      '.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]',
+    );
     const docsUrl = await linkElement.getAttribute('href');
     if (docsUrl == null) {
       throw new Error('url is null');
@@ -43,12 +47,13 @@ test.describe('Access to sidebar', () => {
     expect(body).toContain('</html>');
   });
 
-  test('Successfully access to trash page', async({ page }) => {
-    await page.locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]').click();
+  test('Successfully access to trash page', async ({ page }) => {
+    await page
+      .locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]')
+      .click();
     await expect(page.getByTestId('trash-page-list')).toBeVisible();
   });
 
-
   //
   // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
   //
@@ -166,5 +171,4 @@ test.describe('Access to sidebar', () => {
   //     cy.get('.modal-header > button').click();
   //   });
   // });
-
 });

+ 1 - 2
apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts

@@ -2,8 +2,7 @@ import { test } from '@playwright/test';
 
 import { collapseSidebar } from '../utils';
 
-
-test('Switch sidebar mode', async({ page }) => {
+test('Switch sidebar mode', async ({ page }) => {
   await page.goto('/');
   await collapseSidebar(page, false);
   await collapseSidebar(page, true);

+ 26 - 18
apps/app/playwright/60-home/home.spec.ts

@@ -1,31 +1,34 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
-
-test('Visit User home', async({ page }) => {
+test('Visit User home', async ({ page }) => {
   await page.goto('dummy');
 
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
   // Click UserHomeMenu
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
 });
 
-test('Vist User settings', async({ page }) => {
+test('Vist User settings', async ({ page }) => {
   await page.goto('dummy');
 
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
   // Click UserSettingsMenu
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 
-test('Access User information', async({ page }) => {
+test('Access User information', async ({ page }) => {
   await page.goto('/me');
 
   // Click BasicInfoSettingUpdateButton
@@ -36,7 +39,7 @@ test('Access User information', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 
-test('Access External account', async({ page }) => {
+test('Access External account', async ({ page }) => {
   await page.goto('/me');
 
   // Click ExternalAccountsTabButton
@@ -52,7 +55,7 @@ test('Access External account', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 
-test('Access Password setting', async({ page }) => {
+test('Access Password setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click PasswordSettingTabButton
@@ -72,8 +75,7 @@ test('Access Password setting', async({ page }) => {
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
 });
 
-
-test('Access API setting', async({ page }) => {
+test('Access API setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click ApiSettingTabButton
@@ -85,7 +87,7 @@ test('Access API setting', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 
-test('Access Access Token setting', async({ page }) => {
+test('Access Access Token setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click ApiSettingTabButton
@@ -98,7 +100,9 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await expect(page.getByTestId('grw-accesstoken-new-token-display')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-accesstoken-new-token-display'),
+  ).toBeVisible();
 
   // Expect a success toaster to be displayed when the Access Token is deleted
   await page.getByTestId('grw-accesstoken-delete-button').click();
@@ -106,22 +110,26 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-
 });
 
-test('Access In-App Notification setting', async({ page }) => {
+test('Access In-App Notification setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click InAppNotificationSettingTabButton
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
-  await page.getByTestId('in-app-notification-settings-tab-button').first().click();
+  await page
+    .getByTestId('in-app-notification-settings-tab-button')
+    .first()
+    .click();
 
   // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
-  await page.getByTestId('grw-in-app-notification-settings-update-button').click();
+  await page
+    .getByTestId('grw-in-app-notification-settings-update-button')
+    .click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 
-test('Acccess Other setting', async({ page }) => {
+test('Acccess Other setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click OtherSettingTabButton

+ 1 - 1
apps/app/playwright/auth.setup.ts

@@ -4,6 +4,6 @@ import { login } from './utils/Login';
 
 // Commonised login process for use elsewhere
 // see: https://github.com/microsoft/playwright/issues/22114
-setup('Authenticate as the "admin" user', async({ page }) => {
+setup('Authenticate as the "admin" user', async ({ page }) => {
   await login(page);
 });

+ 8 - 4
apps/app/playwright/utils/CollapseSidebar.ts

@@ -1,7 +1,12 @@
 import { expect, type Page } from '@playwright/test';
 
-export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<void> => {
-  const isSidebarContentsHidden = !(await page.getByTestId('grw-sidebar-contents').isVisible());
+export const collapseSidebar = async (
+  page: Page,
+  isCollapsed: boolean,
+): Promise<void> => {
+  const isSidebarContentsHidden = !(await page
+    .getByTestId('grw-sidebar-contents')
+    .isVisible());
   if (isSidebarContentsHidden === isCollapsed) {
     return;
   }
@@ -12,8 +17,7 @@ export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<
 
   if (isCollapsed) {
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
-  }
-  else {
+  } else {
     await expect(page.locator('.grw-sidebar-dock')).toBeVisible();
   }
 };

+ 1 - 2
apps/app/playwright/utils/Login.ts

@@ -1,10 +1,9 @@
 import path from 'node:path';
-
 import { expect, type Page } from '@playwright/test';
 
 const authFile = path.resolve(__dirname, '../.auth/admin.json');
 
-export const login = async(page: Page): Promise<void> => {
+export const login = async (page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
 

+ 12 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -999,7 +999,18 @@
   },
   "user_home_page": {
     "bookmarks": "Bookmarks",
-    "recently_created": "Recently Created"
+    "recently_created": "Recently Created",
+    "recent_activity": "Recent Activity",
+    "unknown_action": "made an unspecified change",
+    "page_create": "created a page",
+    "page_update": "updated a page",
+    "page_delete": "deleted a page",
+    "page_delete_completely": "deleted a page",
+    "page_rename": "renamed a page",
+    "page_revert": "reverted a page",
+    "page_like": "liked a page",
+    "page_duplicate": "duplicated a page",
+    "comment_create": "posted a comment"
   },
   "bookmark_folder": {
     "bookmark_folder": "bookmark folder",

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

@@ -993,7 +993,18 @@
   },
   "user_home_page": {
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes"
+    "recently_created": "Page récentes",
+    "recent_activity": "Activité récente",
+    "unknown_action": "a effectué une modification non spécifiée",
+    "page_create": "a créé une page",
+    "page_update": "a mis à jour une page",
+    "page_delete": "a supprimé une page",
+    "page_delete_completely": "a supprimé complètement une page",
+    "page_rename": "a renommé une page",
+    "page_revert": "a restauré une page",
+    "page_duplicate": "a dupliqué une page",
+    "page_like": "a aimé une page",
+    "comment_create": "a publié un commentaire"
   },
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",

+ 12 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -1032,7 +1032,18 @@
   },
   "user_home_page": {
     "bookmarks": "ブックマーク",
-    "recently_created": "最近作成したページ"
+    "recently_created": "最近作成したページ",
+    "recent_activity": "最近のアクティビティ",
+    "unknown_action": "未指定の変更を加えました",
+    "page_create": "ページを作成しました",
+    "page_update": "ページを更新しました",
+    "page_delete": "ページを削除しました",
+    "page_delete_completely": "ページを完全に削除しました",
+    "page_rename": "ページの名前を変更しました",
+    "page_revert": "ページを元に戻しました",
+    "page_duplicate": "ページを複製しました",
+    "page_like": "ページをいいねしました",
+    "comment_create": "コメントを投稿しました"
   },
   "bookmark_folder": {
     "bookmark_folder": "ブックマークフォルダ",

+ 12 - 1
apps/app/public/static/locales/ko_KR/translation.json

@@ -959,7 +959,18 @@
   },
   "user_home_page": {
     "bookmarks": "북마크",
-    "recently_created": "최근 생성됨"
+    "recently_created": "최근 생성됨",
+    "recent_activity": "최근 활동",
+    "unknown_action": "지정되지 않은 변경 사항을 적용했습니다",
+    "page_create": "페이지를 생성했습니다",
+    "page_update": "페이지를 업데이트했습니다",
+    "page_delete": "페이지를 삭제했습니다",
+    "page_delete_completely": "페이지를 완전히 삭제했습니다",
+    "page_rename": "페이지 이름을 변경했습니다",
+    "page_revert": "페이지를 되돌렸습니다",
+    "page_duplicate": "페이지를 복제했습니다",
+    "page_like": "페이지에 좋아요를 눌렀습니다",
+    "comment_create": "댓글을 게시했습니다"
   },
   "bookmark_folder": {
     "bookmark_folder": "북마크 폴더",

+ 12 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -1004,7 +1004,18 @@
   },
   "user_home_page": {
     "bookmarks": "书签",
-    "recently_created": "最近创建页面"
+    "recently_created": "最近创建页面",
+    "recent_activity": "最近动态",
+    "unknown_action": "进行了未指明的更改",
+    "page_create": "创建了页面",
+    "page_update": "更新了页面",
+    "page_delete": "删除了页面",
+    "page_delete_completely": "彻底删除了页面",
+    "page_rename": "重命名了页面",
+    "page_revert": "还原了页面",
+    "page_duplicate": "复制了页面",
+    "page_like": "赞了页面",
+    "comment_create": "发布了评论"
   },
   "bookmark_folder": {
     "bookmark_folder": "书签文件夹",

+ 81 - 0
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -0,0 +1,81 @@
+import { formatDistanceToNow } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+import { type Locale } from 'date-fns/locale';
+import { getLocale } from '~/server/util/locale-utils';
+import type { ActivityHasUserId, SupportedActivityActionType } from '~/interfaces/activity';
+import { ActivityLogActions } from '~/interfaces/activity';
+
+
+export const ActivityActionTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'page_create',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'page_update',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'page_delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'page_delete_completely',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'page_rename',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'page_revert',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'page_duplicate',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'page_like',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment_create',
+};
+
+export const IconActivityTranslationMap: Record<
+  SupportedActivityActionType,
+  string
+> = {
+  [ActivityLogActions.ACTION_PAGE_CREATE]: 'add_box',
+  [ActivityLogActions.ACTION_PAGE_UPDATE]: 'edit',
+  [ActivityLogActions.ACTION_PAGE_DELETE]: 'delete',
+  [ActivityLogActions.ACTION_PAGE_DELETE_COMPLETELY]: 'delete_forever',
+  [ActivityLogActions.ACTION_PAGE_RENAME]: 'label',
+  [ActivityLogActions.ACTION_PAGE_REVERT]: 'undo',
+  [ActivityLogActions.ACTION_PAGE_DUPLICATE]: 'content_copy',
+  [ActivityLogActions.ACTION_PAGE_LIKE]: 'favorite',
+  [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
+};
+
+const translateAction = (action: SupportedActivityActionType): string => {
+  return ActivityActionTranslationMap[action] || 'unknown_action';
+};
+
+const setIcon = (action: SupportedActivityActionType): string => {
+  return IconActivityTranslationMap[action] || 'question_mark';
+};
+
+const calculateTimePassed = (date: Date, locale: Locale): string => {
+  const timePassed = formatDistanceToNow(date, {
+    addSuffix: true,
+    locale,
+  });
+
+  return timePassed;
+};
+
+
+export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  const { t, i18n } = useTranslation();
+  const currentLangCode = i18n.language;
+  const dateFnsLocale = getLocale(currentLangCode);
+
+  const action = activity.action as SupportedActivityActionType;
+  const keyToTranslate = translateAction(action);
+  const fullKeyPath = `user_home_page.${keyToTranslate}`;
+
+  return (
+    <div className="activity-row">
+      <p className="mb-1">
+        <span className="material-symbols-outlined me-2">{setIcon(action)}</span>
+
+        <span className="dark:text-white">
+          {' '}{t(fullKeyPath)}
+        </span>
+
+        <span className="text-secondary small ms-3">
+          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+        </span>
+      </p>
+    </div>
+  );
+};

+ 83 - 0
apps/app/src/client/components/RecentActivity/RecentActivity.tsx

@@ -0,0 +1,83 @@
+import React, {
+  useState, useCallback, useEffect, type JSX,
+} from 'react';
+
+import { toastError } from '~/client/util/toastr';
+import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import { useSWRxRecentActivity } from '~/stores/recent-activity';
+import loggerFactory from '~/utils/logger';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityListItem } from './ActivityListItem';
+
+
+const logger = loggerFactory('growi:RecentActivity');
+
+type RecentActivityProps = {
+  userId: string,
+}
+
+const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+  return activity.user != null
+        && typeof activity.user === 'object';
+};
+
+export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
+  const { userId } = props;
+
+  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [limit] = useState(10);
+  const [offset, setOffset] = useState(0);
+
+  const { data: paginatedData, error } = useSWRxRecentActivity(limit, offset, userId);
+
+  const handlePage = useCallback(async(selectedPage: number) => {
+    const newOffset = (selectedPage - 1) * limit;
+
+    setOffset(newOffset);
+    setActivePage(selectedPage);
+  }, [limit]);
+
+  useEffect(() => {
+    if (error) {
+      logger.error('Failed to fetch recent activity data', error);
+      toastError(error);
+      return;
+    }
+
+    if (paginatedData) {
+      const activitiesWithPages = paginatedData.docs
+        .filter(hasUser);
+
+      setActivities(activitiesWithPages);
+    }
+  }, [paginatedData, error]);
+
+  const totalItemsCount = paginatedData?.totalDocs || 0;
+  const needsPagination = totalItemsCount > limit;
+
+  return (
+    <div className="page-list-container-activity">
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {activities.map(activity => (
+          <li key={`recent-activity-view:${activity._id}`} className="mt-4">
+            <ActivityListItem activity={activity} />
+          </li>
+        ))}
+      </ul>
+
+      {needsPagination && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePage}
+          totalItemsCount={totalItemsCount}
+          pagingLimit={limit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </div>
+  );
+};

+ 9 - 0
apps/app/src/client/components/UsersHomepageFooter.tsx

@@ -2,6 +2,7 @@ import React, { useState, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { useCurrentUser } from '~/stores-universal/context';
 
@@ -45,6 +46,14 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />
         </div>
+
+        <h2 id="user-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
+          <span className="growi-custom-icons me-1">recently_created</span>
+          {t('user_home_page.recent_activity')}
+        </h2>
+        <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
+          <RecentActivity userId={creatorId} />
+        </div>
       </div>
     </div>
   );

+ 32 - 2
apps/app/src/interfaces/activity.ts

@@ -1,4 +1,12 @@
-import type { HasObjectId, IUser, Ref } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageHasId,
+  IUser,
+  IUserHasId,
+  Ref,
+} from '@growi/core';
+
+import type { PaginateResult } from './mongoose-utils';
 
 // Model
 const MODEL_PAGE = 'Page';
@@ -377,6 +385,7 @@ export const SupportedAction = {
 
 // Action required for notification
 export const EssentialActionGroup = {
+  ACTION_PAGE_CREATE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_UPDATE,
@@ -568,6 +577,18 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
 
+export const ActivityLogActions = {
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_PAGE_LIKE,
+  ACTION_COMMENT_CREATE,
+} as const;
+
 /*
  * Array
  */
@@ -645,7 +666,8 @@ export type SupportedActionType =
   (typeof SupportedAction)[keyof typeof SupportedAction];
 export type SupportedActionCategoryType =
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
-
+export type SupportedActivityActionType =
+  (typeof ActivityLogActions)[keyof typeof ActivityLogActions];
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 
 export type IActivity = {
@@ -661,6 +683,10 @@ export type IActivity = {
   snapshot?: ISnapshot;
 };
 
+export type ActivityHasUserId = IActivityHasId & {
+  user: IUserHasId;
+};
+
 export type IActivityHasId = IActivity & HasObjectId;
 
 export type ISearchFilter = {
@@ -668,3 +694,7 @@ export type ISearchFilter = {
   dates?: { startDate: string | null; endDate: string | null };
   actions?: SupportedActionType[];
 };
+
+export interface UserActivitiesResult {
+  serializedPaginationResult: PaginateResult<IActivityHasId>;
+}

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

@@ -29,10 +29,13 @@ export const GlobalNotificationSettingType = {
 };
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = function(crowi) {
+const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
-  return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  return mongoose.model(
+    'GlobalNotificationSetting',
+    GlobalNotificationSettingSchema,
+  );
 };
 
 export default factory;

+ 15 - 8
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -12,15 +12,22 @@ const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
-  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSettingType.MAIL,
-    new mongoose.Schema({
-      toEmail: String,
-    }, {
-      discriminatorKey: 'type',
-    }),
+  const GlobalNotificationSettingModel = mongoose.model(
+    'GlobalNotificationSetting',
+    GlobalNotificationSettingSchema,
   );
+  const GlobalNotificationMailSettingModel =
+    GlobalNotificationSettingModel.discriminator(
+      GlobalNotificationSettingType.MAIL,
+      new mongoose.Schema(
+        {
+          toEmail: String,
+        },
+        {
+          discriminatorKey: 'type',
+        },
+      ),
+    );
 
   return GlobalNotificationMailSettingModel;
 };

+ 15 - 8
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -12,15 +12,22 @@ const factory = (crowi) => {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
 
-  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
-  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSettingType.SLACK,
-    new mongoose.Schema({
-      slackChannels: String,
-    }, {
-      discriminatorKey: 'type',
-    }),
+  const GlobalNotificationSettingModel = mongoose.model(
+    'GlobalNotificationSetting',
+    GlobalNotificationSettingSchema,
   );
+  const GlobalNotificationSlackSettingModel =
+    GlobalNotificationSettingModel.discriminator(
+      GlobalNotificationSettingType.SLACK,
+      new mongoose.Schema(
+        {
+          slackChannels: String,
+        },
+        {
+          discriminatorKey: 'type',
+        },
+      ),
+    );
 
   return GlobalNotificationSlackSettingModel;
 };

+ 12 - 9
apps/app/src/server/models/GlobalNotificationSetting/index.js

@@ -13,8 +13,8 @@ const globalNotificationSettingSchema = new mongoose.Schema({
 });
 
 /*
-* e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
-*/
+ * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+ */
 const generatePathsOnTree = (path, pathList) => {
   pathList.push(path);
 
@@ -28,8 +28,8 @@ const generatePathsOnTree = (path, pathList) => {
 };
 
 /*
-* e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
-*/
+ * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+ */
 const generatePathsToMatch = (originalPath) => {
   const pathList = generatePathsOnTree(originalPath, []);
   return pathList.map((path) => {
@@ -48,7 +48,6 @@ const generatePathsToMatch = (originalPath) => {
  * @class GlobalNotificationSetting
  */
 class GlobalNotificationSetting {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
 
@@ -62,6 +61,7 @@ class GlobalNotificationSetting {
    * @param {string} id
    */
   static async enable(id) {
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
     const setting = await this.findOne({ _id: id });
 
     setting.isEnabled = true;
@@ -75,6 +75,7 @@ class GlobalNotificationSetting {
    * @param {string} id
    */
   static async disable(id) {
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
     const setting = await this.findOne({ _id: id });
 
     setting.isEnabled = false;
@@ -87,7 +88,10 @@ class GlobalNotificationSetting {
    * find all notification settings
    */
   static async findAll() {
-    const settings = await this.find().sort({ triggerPath: 1 });
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+    const settings = await this.find().sort({
+      triggerPath: 1,
+    });
 
     return settings;
   }
@@ -100,17 +104,16 @@ class GlobalNotificationSetting {
   static async findSettingByPathAndEvent(event, path, type) {
     const pathsToMatch = generatePathsToMatch(path);
 
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
     const settings = await this.find({
       triggerPath: { $in: pathsToMatch },
       triggerEvents: event,
       __t: type,
       isEnabled: true,
-    })
-      .sort({ triggerPath: 1 });
+    }).sort({ triggerPath: 1 });
 
     return settings;
   }
-
 }
 
 module.exports = {

+ 90 - 45
apps/app/src/server/models/access-token.ts

@@ -1,9 +1,6 @@
+import type { IUserHasId, Ref, Scope } from '@growi/core/dist/interfaces';
 import crypto from 'crypto';
-
-import type { Ref, IUserHasId, Scope } from '@growi/core/dist/interfaces';
-import type {
-  Document, Model, Types, HydratedDocument,
-} from 'mongoose';
+import type { Document, HydratedDocument, Model, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -15,42 +12,58 @@ import { extractScopes } from '../util/scope-utils';
 
 const logger = loggerFactory('growi:models:access-token');
 
-const generateTokenHash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
+const generateTokenHash = (token: string) =>
+  crypto.createHash('sha256').update(token).digest('hex');
 
 type GenerateTokenResult = {
-  token: string,
-  _id: Types.ObjectId,
-  expiredAt: Date,
-  scopes?: Scope[],
-  description?: string,
-}
+  token: string;
+  _id: Types.ObjectId;
+  expiredAt: Date;
+  scopes?: Scope[];
+  description?: string;
+};
 
 export type IAccessToken = {
-  user: Ref<IUserHasId>,
-  tokenHash: string,
-  expiredAt: Date,
-  scopes?: Scope[],
-  description?: string,
-}
+  user: Ref<IUserHasId>;
+  tokenHash: string;
+  expiredAt: Date;
+  scopes?: Scope[];
+  description?: string;
+};
 
 export interface IAccessTokenDocument extends IAccessToken, Document {
-  isExpired: () => boolean
+  isExpired: () => boolean;
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string,) => Promise<GenerateTokenResult>
-  deleteToken: (token: string) => Promise<void>
-  deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
-  deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
-  deleteExpiredToken: () => Promise<void>
-  findUserIdByToken: (token: string, requiredScopes: Scope[]) => Promise<HydratedDocument<IAccessTokenDocument> | null>
-  findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
-  validateTokenScopes: (token: string, requiredScopes: Scope[]) => Promise<boolean>
+  generateToken: (
+    userId: Types.ObjectId | string,
+    expiredAt: Date,
+    scopes?: Scope[],
+    description?: string,
+  ) => Promise<GenerateTokenResult>;
+  deleteToken: (token: string) => Promise<void>;
+  deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>;
+  deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>;
+  deleteExpiredToken: () => Promise<void>;
+  findUserIdByToken: (
+    token: string,
+    requiredScopes: Scope[],
+  ) => Promise<HydratedDocument<IAccessTokenDocument> | null>;
+  findTokenByUserId: (
+    userId: Types.ObjectId | string,
+  ) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>;
+  validateTokenScopes: (
+    token: string,
+    requiredScopes: Scope[],
+  ) => Promise<boolean>;
 }
 
 const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
   user: {
-    type: Schema.Types.ObjectId, ref: 'User', required: true,
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    required: true,
   },
   tokenHash: { type: String, required: true, unique: true },
   expiredAt: { type: Date, required: true, index: true },
@@ -61,67 +74,99 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string) {
-
+accessTokenSchema.statics.generateToken = async function (
+  userId: Types.ObjectId | string,
+  expiredAt: Date,
+  scopes?: Scope[],
+  description?: string,
+) {
   const extractedScopes = extractScopes(scopes ?? []);
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
 
   try {
     const { _id } = await this.create({
-      user: userId, tokenHash, expiredAt, scopes: extractedScopes, description,
+      user: userId,
+      tokenHash,
+      expiredAt,
+      scopes: extractedScopes,
+      description,
     });
 
     logger.debug('Token generated');
     return {
-      token, _id, expiredAt, scopes: extractedScopes, description,
+      token,
+      _id,
+      expiredAt,
+      scopes: extractedScopes,
+      description,
     };
-  }
-  catch (err) {
+  } catch (err) {
     logger.debug('Failed to generate token');
     throw err;
   }
 };
 
-accessTokenSchema.statics.deleteToken = async function(token: string) {
+accessTokenSchema.statics.deleteToken = async function (token: string) {
   const tokenHash = generateTokenHash(token);
   await this.deleteOne({ tokenHash });
 };
 
-accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId | string) {
+accessTokenSchema.statics.deleteTokenById = async function (
+  tokenId: Types.ObjectId | string,
+) {
   await this.deleteOne({ _id: tokenId });
 };
 
-accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId | string) {
+accessTokenSchema.statics.deleteAllTokensByUserId = async function (
+  userId: Types.ObjectId | string,
+) {
   await this.deleteMany({ user: userId });
 };
 
-accessTokenSchema.statics.deleteExpiredToken = async function() {
+accessTokenSchema.statics.deleteExpiredToken = async function () {
   const now = new Date();
   await this.deleteMany({ expiredAt: { $lt: now } });
 };
 
-accessTokenSchema.statics.findUserIdByToken = async function(token: string, requiredScopes: Scope[]) {
+accessTokenSchema.statics.findUserIdByToken = async function (
+  token: string,
+  requiredScopes: Scope[],
+) {
   const tokenHash = generateTokenHash(token);
   const now = new Date();
   if (requiredScopes.length === 0) {
     return;
   }
   const extractedScopes = extractScopes(requiredScopes);
-  return this.findOne({ tokenHash, expiredAt: { $gte: now }, scopes: { $all: extractedScopes } }).select('user');
+  return this.findOne({
+    tokenHash,
+    expiredAt: { $gte: now },
+    scopes: { $all: extractedScopes },
+  }).select('user');
 };
 
-accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId | string) {
+accessTokenSchema.statics.findTokenByUserId = async function (
+  userId: Types.ObjectId | string,
+) {
   const now = new Date();
-  return this.find({ user: userId, expiredAt: { $gte: now } }).select('_id expiredAt scopes description');
+  return this.find({ user: userId, expiredAt: { $gte: now } }).select(
+    '_id expiredAt scopes description',
+  );
 };
 
-accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: Scope[]) {
+accessTokenSchema.statics.validateTokenScopes = async function (
+  token: string,
+  requiredScopes: Scope[],
+) {
   return this.findUserIdByToken(token, requiredScopes) != null;
 };
 
-accessTokenSchema.methods.isExpired = function() {
+accessTokenSchema.methods.isExpired = function () {
   return this.expiredAt < new Date();
 };
 
-export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessTokenModel>('AccessToken', accessTokenSchema);
+export const AccessToken = getOrCreateModel<
+  IAccessTokenDocument,
+  IAccessTokenModel
+>('AccessToken', accessTokenSchema);

+ 115 - 89
apps/app/src/server/models/activity.ts

@@ -1,41 +1,42 @@
-import type { Ref, IUser } from '@growi/core';
-import type {
-  Types, Document, Model, SortOrder,
-} from 'mongoose';
+import type { IUser, Ref } from '@growi/core';
+import type { Document, Model, SortOrder, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import type {
-  IActivity, ISnapshot, SupportedActionType, SupportedTargetModelType, SupportedEventModelType,
+  IActivity,
+  ISnapshot,
+  SupportedActionType,
+  SupportedEventModelType,
+  SupportedTargetModelType,
 } from '~/interfaces/activity';
 import {
   AllSupportedActions,
-  AllSupportedTargetModels,
   AllSupportedEventModels,
+  AllSupportedTargetModels,
 } from '~/interfaces/activity';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 const logger = loggerFactory('growi:models:activity');
 
 export interface ActivityDocument extends Document {
-  _id: Types.ObjectId
-  user: Ref<IUser>
-  ip: string
-  endpoint: string
-  targetModel: SupportedTargetModelType
-  target: Types.ObjectId
-  eventModel: SupportedEventModelType
-  event: Types.ObjectId
-  action: SupportedActionType
-  snapshot: ISnapshot
+  _id: Types.ObjectId;
+  user: Ref<IUser>;
+  ip: string;
+  endpoint: string;
+  targetModel: SupportedTargetModelType;
+  target: Types.ObjectId;
+  eventModel: SupportedEventModelType;
+  event: Types.ObjectId;
+  action: SupportedActionType;
+  snapshot: ISnapshot;
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {
-  [x:string]: any
-  getActionUsersFromActivities(activities: ActivityDocument[]): any[]
+  [x: string]: any;
+  getActionUsersFromActivities(activities: ActivityDocument[]): any[];
 }
 
 const snapshotSchema = new Schema<ISnapshot>({
@@ -43,91 +44,116 @@ const snapshotSchema = new Schema<ISnapshot>({
 });
 
 // TODO: add revision id
-const activitySchema = new Schema<ActivityDocument, ActivityModel>({
-  user: {
-    type: Schema.Types.ObjectId,
-    ref: 'User',
-    index: true,
-  },
-  ip: {
-    type: String,
-  },
-  endpoint: {
-    type: String,
-  },
-  targetModel: {
-    type: String,
-    enum: AllSupportedTargetModels,
-  },
-  target: {
-    type: Schema.Types.ObjectId,
-    refPath: 'targetModel',
-  },
-  eventModel: {
-    type: String,
-    enum: AllSupportedEventModels,
-  },
-  event: {
-    type: Schema.Types.ObjectId,
+const activitySchema = new Schema<ActivityDocument, ActivityModel>(
+  {
+    user: {
+      type: Schema.Types.ObjectId,
+      ref: 'User',
+      index: true,
+    },
+    ip: {
+      type: String,
+    },
+    endpoint: {
+      type: String,
+    },
+    targetModel: {
+      type: String,
+      enum: AllSupportedTargetModels,
+    },
+    target: {
+      type: Schema.Types.ObjectId,
+      refPath: 'targetModel',
+    },
+    eventModel: {
+      type: String,
+      enum: AllSupportedEventModels,
+    },
+    event: {
+      type: Schema.Types.ObjectId,
+    },
+    action: {
+      type: String,
+      enum: AllSupportedActions,
+      required: true,
+    },
+    snapshot: snapshotSchema,
   },
-  action: {
-    type: String,
-    enum: AllSupportedActions,
-    required: true,
+  {
+    timestamps: {
+      createdAt: true,
+      updatedAt: false,
+    },
   },
-  snapshot: snapshotSchema,
-}, {
-  timestamps: {
-    createdAt: true,
-    updatedAt: false,
-  },
-});
+);
 // activitySchema.index({ createdAt: 1 }); // Do not create index here because it is created by ActivityService as TTL index
 activitySchema.index({ target: 1, action: 1 });
-activitySchema.index({
-  user: 1, target: 1, action: 1, createdAt: 1,
-}, { unique: true });
+activitySchema.index(
+  {
+    user: 1,
+    target: 1,
+    action: 1,
+    createdAt: 1,
+  },
+  { unique: true },
+);
 activitySchema.plugin(mongoosePaginate);
 
-activitySchema.post('save', function() {
+activitySchema.post('save', function () {
   logger.debug('activity has been created', this);
 });
 
-activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
-  const activity = await this.create(parameters) as unknown as IActivity;
+activitySchema.statics.createByParameters = async function (
+  parameters,
+): Promise<IActivity> {
+  const activity = (await this.create(parameters)) as unknown as IActivity;
 
   return activity;
 };
 
 // When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
-activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<ActivityDocument | null> {
-  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }).exec();
+activitySchema.statics.updateByParameters = async function (
+  activityId: string,
+  parameters,
+): Promise<ActivityDocument | null> {
+  const activity = await this.findOneAndUpdate(
+    { _id: activityId },
+    parameters,
+    { new: true },
+  ).exec();
 
   return activity;
 };
 
-activitySchema.statics.findSnapshotUsernamesByUsernameRegexWithTotalCount = async function(
-    q: string, option: { sortOpt: SortOrder, offset: number, limit: number},
-): Promise<{usernames: string[], totalCount: number}> {
-  const opt = option || {};
-  const sortOpt = opt.sortOpt || 1;
-  const offset = opt.offset || 0;
-  const limit = opt.limit || 10;
-
-  const conditions = { 'snapshot.username': { $regex: q, $options: 'i' } };
-
-  const usernames = await this.aggregate()
-    .skip(0)
-    .limit(10000) // Narrow down the search target
-    .match(conditions)
-    .group({ _id: '$snapshot.username' })
-    .sort({ _id: sortOpt }) // Sort "snapshot.username" in ascending order
-    .skip(offset)
-    .limit(limit);
-
-  const totalCount = (await this.find(conditions).distinct('snapshot.username')).length;
-
-  return { usernames: usernames.map(r => r._id), totalCount };
-};
-
-export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);
+activitySchema.statics.findSnapshotUsernamesByUsernameRegexWithTotalCount =
+  async function (
+    q: string,
+    option: { sortOpt: SortOrder; offset: number; limit: number },
+  ): Promise<{ usernames: string[]; totalCount: number }> {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || 1;
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 10;
+
+    const conditions = { 'snapshot.username': { $regex: q, $options: 'i' } };
+
+    const usernames = await this.aggregate()
+      .skip(0)
+      .limit(10000) // Narrow down the search target
+      .match(conditions)
+      .group({ _id: '$snapshot.username' })
+      .sort({ _id: sortOpt }) // Sort "snapshot.username" in ascending order
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (
+      await this.find(conditions).distinct('snapshot.username')
+    ).length;
+
+    return { usernames: usernames.map((r) => r._id), totalCount };
+  };
+
+export default getOrCreateModel<ActivityDocument, ActivityModel>(
+  'Activity',
+  activitySchema,
+);

+ 60 - 38
apps/app/src/server/models/attachment.ts

@@ -1,12 +1,9 @@
-import path from 'path';
-
 import type { IAttachment } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
-import {
-  Schema, type Model, type Document,
-} from 'mongoose';
+import { type Document, type Model, Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
+import path from 'path';
 
 import loggerFactory from '~/utils/logger';
 
@@ -16,7 +13,6 @@ import { getOrCreateModel } from '../util/mongoose-utils';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:models:attachment');
 
-
 function generateFileHash(fileName) {
   const hash = require('crypto').createHash('md5');
   hash.update(`${fileName}_${Date.now()}`);
@@ -25,61 +21,77 @@ function generateFileHash(fileName) {
 }
 
 type GetValidTemporaryUrl = () => string | null | undefined;
-type CashTemporaryUrlByProvideSec = (temporaryUrl: string, lifetimeSec: number) => Promise<IAttachmentDocument>;
+type CashTemporaryUrlByProvideSec = (
+  temporaryUrl: string,
+  lifetimeSec: number,
+) => Promise<IAttachmentDocument>;
 
 export interface IAttachmentDocument extends IAttachment, Document {
-  getValidTemporaryUrl: GetValidTemporaryUrl
-  cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
+  getValidTemporaryUrl: GetValidTemporaryUrl;
+  cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec;
 }
 export interface IAttachmentModel extends Model<IAttachmentDocument> {
   createWithoutSave: (
-    pageId, user, originalName: string, fileFormat: string, fileSize: number, attachmentType: AttachmentType,
+    pageId,
+    user,
+    originalName: string,
+    fileFormat: string,
+    fileSize: number,
+    attachmentType: AttachmentType,
   ) => IAttachmentDocument;
 }
 
-const attachmentSchema = new Schema({
-  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
-  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  fileName: { type: String, required: true, unique: true },
-  fileFormat: { type: String, required: true },
-  fileSize: { type: Number, default: 0 },
-  originalName: { type: String },
-  temporaryUrlCached: { type: String },
-  temporaryUrlExpiredAt: { type: Date },
-  attachmentType: {
-    type: String,
-    enum: AttachmentType,
-    required: true,
+const attachmentSchema = new Schema(
+  {
+    page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+    creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+    filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
+    fileName: { type: String, required: true, unique: true },
+    fileFormat: { type: String, required: true },
+    fileSize: { type: Number, default: 0 },
+    originalName: { type: String },
+    temporaryUrlCached: { type: String },
+    temporaryUrlExpiredAt: { type: Date },
+    attachmentType: {
+      type: String,
+      enum: AttachmentType,
+      required: true,
+    },
   },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+  {
+    timestamps: { createdAt: true, updatedAt: false },
+  },
+);
 attachmentSchema.plugin(uniqueValidator);
 attachmentSchema.plugin(mongoosePaginate);
 
 // virtual
-attachmentSchema.virtual('filePathProxied').get(function() {
+attachmentSchema.virtual('filePathProxied').get(function () {
   return `/attachment/${this._id}`;
 });
 
-attachmentSchema.virtual('downloadPathProxied').get(function() {
+attachmentSchema.virtual('downloadPathProxied').get(function () {
   return `/download/${this._id}`;
 });
 
 attachmentSchema.set('toObject', { virtuals: true });
 attachmentSchema.set('toJSON', { virtuals: true });
 
-
-attachmentSchema.statics.createWithoutSave = function(
-    pageId, user, originalName: string, fileFormat: string, fileSize: number, attachmentType: AttachmentType,
+attachmentSchema.statics.createWithoutSave = function (
+  pageId,
+  user,
+  originalName: string,
+  fileFormat: string,
+  fileSize: number,
+  attachmentType: AttachmentType,
 ) {
-  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  // biome-ignore lint/complexity/noUselessThisAlias: ignore
   const Attachment = this;
 
   const extname = path.extname(originalName);
   let fileName = generateFileHash(originalName);
-  if (extname.length > 1) { // ignore if empty or '.' only
+  if (extname.length > 1) {
+    // ignore if empty or '.' only
     fileName = `${fileName}${extname}`;
   }
 
@@ -94,7 +106,9 @@ attachmentSchema.statics.createWithoutSave = function(
   return attachment;
 };
 
-const getValidTemporaryUrl: GetValidTemporaryUrl = function(this: IAttachmentDocument) {
+const getValidTemporaryUrl: GetValidTemporaryUrl = function (
+  this: IAttachmentDocument,
+) {
   if (this.temporaryUrlExpiredAt == null) {
     return null;
   }
@@ -106,7 +120,11 @@ const getValidTemporaryUrl: GetValidTemporaryUrl = function(this: IAttachmentDoc
 };
 attachmentSchema.methods.getValidTemporaryUrl = getValidTemporaryUrl;
 
-const cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec = function(this: IAttachmentDocument, temporaryUrl, lifetimeSec) {
+const cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec = function (
+  this: IAttachmentDocument,
+  temporaryUrl,
+  lifetimeSec,
+) {
   if (temporaryUrl == null) {
     throw new Error('url is required.');
   }
@@ -115,6 +133,10 @@ const cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec = function(this
 
   return this.save();
 };
-attachmentSchema.methods.cashTemporaryUrlByProvideSec = cashTemporaryUrlByProvideSec;
+attachmentSchema.methods.cashTemporaryUrlByProvideSec =
+  cashTemporaryUrlByProvideSec;
 
-export const Attachment = getOrCreateModel<IAttachmentDocument, IAttachmentModel>('Attachment', attachmentSchema);
+export const Attachment = getOrCreateModel<
+  IAttachmentDocument,
+  IAttachmentModel
+>('Attachment', attachmentSchema);

+ 127 - 62
apps/app/src/server/models/bookmark-folder.ts

@@ -1,54 +1,80 @@
 import type { IPageHasId } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
-import type { Types, Document, Model } from 'mongoose';
+import type { Document, Model, Types } from 'mongoose';
 import monggoose, { Schema } from 'mongoose';
 
-import type { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  IBookmarkFolder,
+} from '~/interfaces/bookmark-info';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
-
 import { InvalidParentBookmarkFolderError } from './errors';
 
-
 const logger = loggerFactory('growi:models:bookmark-folder');
 const Bookmark = monggoose.model('Bookmark');
 
 export interface BookmarkFolderDocument extends Document {
-  _id: Types.ObjectId
-  name: string
-  owner: Types.ObjectId
-  parent?: Types.ObjectId | undefined
-  bookmarks?: Types.ObjectId[],
-  childFolder?: BookmarkFolderDocument[]
+  _id: Types.ObjectId;
+  name: string;
+  owner: Types.ObjectId;
+  parent?: Types.ObjectId | undefined;
+  bookmarks?: Types.ObjectId[];
+  childFolder?: BookmarkFolderDocument[];
 }
 
-export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
-  createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
-  deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
-  insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
-  updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
+export interface BookmarkFolderModel extends Model<BookmarkFolderDocument> {
+  createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>;
+  deleteFolderAndChildren(
+    bookmarkFolderId: Types.ObjectId | string,
+  ): Promise<{ deletedCount: number }>;
+  updateBookmarkFolder(
+    bookmarkFolderId: string,
+    name: string,
+    parent: string | null,
+    childFolder: BookmarkFolderItems[],
+  ): Promise<BookmarkFolderDocument>;
+  insertOrUpdateBookmarkedPage(
+    pageId: IPageHasId,
+    userId: Types.ObjectId | string,
+    folderId: string | null,
+  ): Promise<BookmarkFolderDocument | null>;
+  updateBookmark(
+    pageId: Types.ObjectId | string,
+    status: boolean,
+    userId: Types.ObjectId | string,
+  ): Promise<BookmarkFolderDocument | null>;
 }
 
-const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderModel>({
-  name: { type: String },
-  owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  parent: {
-    type: Schema.Types.ObjectId,
-    ref: 'BookmarkFolder',
-    required: false,
+const bookmarkFolderSchema = new Schema<
+  BookmarkFolderDocument,
+  BookmarkFolderModel
+>(
+  {
+    name: { type: String },
+    owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+    parent: {
+      type: Schema.Types.ObjectId,
+      ref: 'BookmarkFolder',
+      required: false,
+    },
+    bookmarks: {
+      type: [
+        {
+          type: Schema.Types.ObjectId,
+          ref: 'Bookmark',
+          required: false,
+        },
+      ],
+      required: false,
+      default: [],
+    },
   },
-  bookmarks: {
-    type: [{
-      type: Schema.Types.ObjectId, ref: 'Bookmark', required: false,
-    }],
-    required: false,
-    default: [],
+  {
+    toObject: { virtuals: true },
   },
-}, {
-  toObject: { virtuals: true },
-});
+);
 
 bookmarkFolderSchema.virtual('childFolder', {
   ref: 'BookmarkFolder',
@@ -56,16 +82,19 @@ bookmarkFolderSchema.virtual('childFolder', {
   foreignField: 'parent',
 });
 
-bookmarkFolderSchema.statics.createByParameters = async function(params: IBookmarkFolder): Promise<BookmarkFolderDocument> {
+bookmarkFolderSchema.statics.createByParameters = async function (
+  params: IBookmarkFolder,
+): Promise<BookmarkFolderDocument> {
   const { name, owner, parent } = params;
   let bookmarkFolder: BookmarkFolderDocument;
 
   if (parent == null) {
     bookmarkFolder = await this.create({ name, owner });
-  }
-  else {
+  } else {
     // Check if parent folder id is valid and parent folder exists
-    const isParentFolderIdValid = objectIdUtils.isValidObjectId(parent as string);
+    const isParentFolderIdValid = objectIdUtils.isValidObjectId(
+      parent as string,
+    );
 
     if (!isParentFolderIdValid) {
       throw new InvalidParentBookmarkFolderError('Parent folder id is invalid');
@@ -74,13 +103,19 @@ bookmarkFolderSchema.statics.createByParameters = async function(params: IBookma
     if (parentFolder == null) {
       throw new InvalidParentBookmarkFolderError('Parent folder not found');
     }
-    bookmarkFolder = await this.create({ name, owner, parent:  parentFolder._id });
+    bookmarkFolder = await this.create({
+      name,
+      owner,
+      parent: parentFolder._id,
+    });
   }
 
   return bookmarkFolder;
 };
 
-bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
+bookmarkFolderSchema.statics.deleteFolderAndChildren = async function (
+  bookmarkFolderId: Types.ObjectId | string,
+): Promise<{ deletedCount: number }> {
   const bookmarkFolder = await this.findById(bookmarkFolderId);
   // Delete parent and all children folder
   let deletedCount = 0;
@@ -92,10 +127,14 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFo
     }
     // Delete all child recursively and update deleted count
     const childFolders = await this.find({ parent: bookmarkFolder._id });
-    await Promise.all(childFolders.map(async(child) => {
-      const deletedChildFolder = await this.deleteFolderAndChildren(child._id);
-      deletedCount += deletedChildFolder.deletedCount;
-    }));
+    await Promise.all(
+      childFolders.map(async (child) => {
+        const deletedChildFolder = await this.deleteFolderAndChildren(
+          child._id,
+        );
+        deletedCount += deletedChildFolder.deletedCount;
+      }),
+    );
     const deletedChild = await this.deleteMany({ parent: bookmarkFolder._id });
     deletedCount += deletedChild.deletedCount + 1;
     bookmarkFolder.delete();
@@ -103,14 +142,13 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFo
   return { deletedCount };
 };
 
-bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
-    bookmarkFolderId: string,
-    name: string,
-    parentId: string | null,
-    childFolder: BookmarkFolderItems[],
-):
- Promise<BookmarkFolderDocument> {
-  const updateFields: {name: string, parent: Types.ObjectId | null} = {
+bookmarkFolderSchema.statics.updateBookmarkFolder = async function (
+  bookmarkFolderId: string,
+  name: string,
+  parentId: string | null,
+  childFolder: BookmarkFolderItems[],
+): Promise<BookmarkFolderDocument> {
+  const updateFields: { name: string; parent: Types.ObjectId | null } = {
     name: '',
     parent: null,
   };
@@ -131,21 +169,33 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
     }
   }
 
-  const bookmarkFolder = await this.findByIdAndUpdate(bookmarkFolderId, { $set: updateFields }, { new: true });
+  const bookmarkFolder = await this.findByIdAndUpdate(
+    bookmarkFolderId,
+    { $set: updateFields },
+    { new: true },
+  );
   if (bookmarkFolder == null) {
     throw new Error('Update bookmark folder failed');
   }
   return bookmarkFolder;
-
 };
 
-bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null):
-Promise<BookmarkFolderDocument | null> {
+bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function (
+  pageId: IPageHasId,
+  userId: Types.ObjectId | string,
+  folderId: string | null,
+): Promise<BookmarkFolderDocument | null> {
   // Find bookmark
-  const bookmarkedPage = await Bookmark.findOne({ page: pageId, user: userId }, { new: true, upsert: true });
+  const bookmarkedPage = await Bookmark.findOne(
+    { page: pageId, user: userId },
+    { new: true, upsert: true },
+  );
 
   // Remove existing bookmark in bookmark folder
-  await this.updateMany({ owner: userId }, { $pull: { bookmarks:  bookmarkedPage?._id } });
+  await this.updateMany(
+    { owner: userId },
+    { $pull: { bookmarks: bookmarkedPage?._id } },
+  );
   if (folderId == null) {
     return null;
   }
@@ -160,14 +210,26 @@ Promise<BookmarkFolderDocument | null> {
   return bookmarkFolder;
 };
 
-bookmarkFolderSchema.statics.updateBookmark = async function(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId | string):
-Promise<BookmarkFolderDocument | null> {
+bookmarkFolderSchema.statics.updateBookmark = async function (
+  pageId: Types.ObjectId | string,
+  status: boolean,
+  userId: Types.ObjectId | string,
+): Promise<BookmarkFolderDocument | null> {
   // If isBookmarked
   if (status) {
-    const bookmarkedPage = await Bookmark.findOne({ page: pageId, user: userId });
-    const bookmarkFolder = await this.findOne({ owner: userId, bookmarks: { $in: [bookmarkedPage?._id] } });
+    const bookmarkedPage = await Bookmark.findOne({
+      page: pageId,
+      user: userId,
+    });
+    const bookmarkFolder = await this.findOne({
+      owner: userId,
+      bookmarks: { $in: [bookmarkedPage?._id] },
+    });
     if (bookmarkFolder != null) {
-      await this.updateOne({ owner: userId, _id: bookmarkFolder._id }, { $pull: { bookmarks:  bookmarkedPage?._id } });
+      await this.updateOne(
+        { owner: userId, _id: bookmarkFolder._id },
+        { $pull: { bookmarks: bookmarkedPage?._id } },
+      );
     }
 
     if (bookmarkedPage) {
@@ -179,4 +241,7 @@ Promise<BookmarkFolderDocument | null> {
   await Bookmark.create({ page: pageId, user: userId });
   return null;
 };
-export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);
+export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>(
+  'BookmarkFolder',
+  bookmarkFolderSchema,
+);

+ 24 - 24
apps/app/src/server/models/bookmark.js

@@ -16,25 +16,27 @@ const factory = (crowi) => {
 
   let bookmarkSchema = null;
 
-
-  bookmarkSchema = new mongoose.Schema({
-    page: { type: ObjectId, ref: 'Page', index: true },
-    user: { type: ObjectId, ref: 'User', index: true },
-  }, {
-    timestamps: { createdAt: true, updatedAt: false },
-  });
+  bookmarkSchema = new mongoose.Schema(
+    {
+      page: { type: ObjectId, ref: 'Page', index: true },
+      user: { type: ObjectId, ref: 'User', index: true },
+    },
+    {
+      timestamps: { createdAt: true, updatedAt: false },
+    },
+  );
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
   bookmarkSchema.plugin(mongoosePaginate);
   bookmarkSchema.plugin(uniqueValidator);
 
-  bookmarkSchema.statics.countByPageId = async function(pageId) {
+  bookmarkSchema.statics.countByPageId = async function (pageId) {
     return await this.count({ page: pageId });
   };
 
   /**
    * @return {object} key: page._id, value: bookmark count
    */
-  bookmarkSchema.statics.getPageIdToCountMap = async function(pageIds) {
+  bookmarkSchema.statics.getPageIdToCountMap = async function (pageIds) {
     const results = await this.aggregate()
       .match({ page: { $in: pageIds } })
       .group({ _id: '$page', count: { $sum: 1 } });
@@ -49,21 +51,20 @@ const factory = (crowi) => {
   };
 
   // bookmark チェック用
-  bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
-    const Bookmark = this;
-
-    return new Promise(((resolve, reject) => {
-      return Bookmark.findOne({ page: pageId, user: userId }, (err, doc) => {
+  bookmarkSchema.statics.findByPageIdAndUserId = function (pageId, userId) {
+    return new Promise((resolve, reject) => {
+      return this.findOne({ page: pageId, user: userId }, (err, doc) => {
         if (err) {
           return reject(err);
         }
 
         return resolve(doc);
       });
-    }));
+    });
   };
 
-  bookmarkSchema.statics.add = async function(page, user) {
+  bookmarkSchema.statics.add = async function (page, user) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const Bookmark = this;
 
     const newBookmark = new Bookmark({ page, user });
@@ -72,8 +73,7 @@ const factory = (crowi) => {
       const bookmark = await newBookmark.save();
       bookmarkEvent.emit('create', page._id);
       return bookmark;
-    }
-    catch (err) {
+    } catch (err) {
       if (err.code === 11000) {
         // duplicate key (dummy response of new object)
         return newBookmark;
@@ -88,29 +88,29 @@ const factory = (crowi) => {
    * used only when removing the page
    * @param {string} pageId
    */
-  bookmarkSchema.statics.removeBookmarksByPageId = async function(pageId) {
+  bookmarkSchema.statics.removeBookmarksByPageId = async function (pageId) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const Bookmark = this;
 
     try {
       const data = await Bookmark.remove({ page: pageId });
       bookmarkEvent.emit('delete', pageId);
       return data;
-    }
-    catch (err) {
+    } catch (err) {
       logger.debug('Bookmark.remove failed (removeBookmarkByPage)', err);
       throw err;
     }
   };
 
-  bookmarkSchema.statics.removeBookmark = async function(pageId, user) {
+  bookmarkSchema.statics.removeBookmark = async function (pageId, user) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const Bookmark = this;
 
     try {
       const data = await Bookmark.findOneAndRemove({ page: pageId, user });
       bookmarkEvent.emit('delete', pageId);
       return data;
-    }
-    catch (err) {
+    } catch (err) {
       logger.debug('Bookmark.findOneAndRemove failed', err);
       throw err;
     }

+ 14 - 10
apps/app/src/server/models/config.ts

@@ -4,7 +4,6 @@ import uniqueValidator from 'mongoose-unique-validator';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface IConfig {
   _id: Types.ObjectId;
   ns: string;
@@ -13,15 +12,20 @@ export interface IConfig {
   createdAt: Date;
 }
 
-
-const schema = new Schema<IConfig>({
-  ns: { type: String },
-  key: { type: String, required: true, unique: true },
-  value: { type: String, required: true },
-}, {
-  timestamps: true,
-});
+const schema = new Schema<IConfig>(
+  {
+    ns: { type: String },
+    key: { type: String, required: true, unique: true },
+    value: { type: String, required: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 
 schema.plugin(uniqueValidator);
 
-export const Config = getOrCreateModel<IConfig, Record<string, never>>('Config', schema);
+export const Config = getOrCreateModel<IConfig, Record<string, never>>(
+  'Config',
+  schema,
+);

+ 12 - 10
apps/app/src/server/models/editor-settings.ts

@@ -1,18 +1,18 @@
 import type { EditorSettings } from '@growi/editor';
-import type { Model, Document } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
+import type { Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface EditorSettingsDocument extends EditorSettings, Document {
-  userId: Schema.Types.ObjectId,
+  userId: Schema.Types.ObjectId;
 }
-export type EditorSettingsModel = Model<EditorSettingsDocument>
+export type EditorSettingsModel = Model<EditorSettingsDocument>;
 
-const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsModel>({
+const editorSettingsSchema = new Schema<
+  EditorSettingsDocument,
+  EditorSettingsModel
+>({
   userId: { type: Schema.Types.ObjectId },
   theme: { type: String },
   keymapMode: { type: String },
@@ -20,5 +20,7 @@ const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsMo
   autoFormatMarkdownTable: { type: Boolean, default: true },
 });
 
-
-export default getOrCreateModel<EditorSettingsDocument, EditorSettingsModel>('EditorSettings', editorSettingsSchema);
+export default getOrCreateModel<EditorSettingsDocument, EditorSettingsModel>(
+  'EditorSettings',
+  editorSettingsSchema,
+);

+ 2 - 5
apps/app/src/server/models/errors.ts

@@ -1,20 +1,17 @@
 import ExtensibleCustomError from 'extensible-custom-error';
 
 export class PathAlreadyExistsError extends ExtensibleCustomError {
-
   targetPath: string;
 
   constructor(message: string, targetPath: string) {
     super(message);
     this.targetPath = targetPath;
   }
-
 }
 
-
 /*
-* User Authentication
-*/
+ * User Authentication
+ */
 export class NullUsernameToBeRegisteredError extends ExtensibleCustomError {}
 
 // Invalid Parent bookmark folder error

+ 2 - 1
apps/app/src/server/models/eslint-rules-dir/no-populate.js

@@ -18,7 +18,8 @@ module.exports = {
         if (node.callee.property && node.callee.property.name === 'populate') {
           context.report({
             node,
-            message: "The 'populate' method should not be called in model modules.",
+            message:
+              "The 'populate' method should not be called in model modules.",
           });
         }
       },

+ 7 - 4
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -10,13 +10,16 @@ const ruleTester = new RuleTester({
 
 test('test no-populate', () => {
   ruleTester.run('no-populate', noPopulate, {
-    valid: [
-      { code: 'Model.find();' },
-    ],
+    valid: [{ code: 'Model.find();' }],
     invalid: [
       {
         code: "Model.find().populate('children');",
-        errors: [{ message: "The 'populate' method should not be called in model modules." }],
+        errors: [
+          {
+            message:
+              "The 'populate' method should not be called in model modules.",
+          },
+        ],
       },
     ],
   });

+ 97 - 66
apps/app/src/server/models/external-account.ts

@@ -1,7 +1,11 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
-import type { IUser, IUserHasId, IExternalAccount } from '@growi/core/dist/interfaces';
-import type { Model, Document, HydratedDocument } from 'mongoose';
+import type {
+  IExternalAccount,
+  IUser,
+  IUserHasId,
+} from '@growi/core/dist/interfaces';
+import type { Document, HydratedDocument, Model } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -14,19 +18,24 @@ import { getOrCreateModel } from '../util/mongoose-utils';
 
 const logger = loggerFactory('growi:models:external-account');
 
-export interface ExternalAccountDocument extends IExternalAccount<IExternalAuthProviderType>, Document {}
+export interface ExternalAccountDocument
+  extends IExternalAccount<IExternalAuthProviderType>,
+    Document {}
 
 export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
-  [x:string]: any, // for old methods
+  [x: string]: any; // for old methods
 }
 
-const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>({
-  providerType: { type: String, required: true },
-  accountId: { type: String, required: true },
-  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>(
+  {
+    providerType: { type: String, required: true },
+    accountId: { type: String, required: true },
+    user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+  },
+  {
+    timestamps: { createdAt: true, updatedAt: false },
+  },
+);
 // compound index
 schema.index({ providerType: 1, accountId: 1 }, { unique: true });
 // apply plugins
@@ -44,7 +53,6 @@ const DEFAULT_LIMIT = 50;
  * @class DuplicatedUsernameException
  */
 class DuplicatedUsernameException {
-
   name: string;
 
   message: string;
@@ -56,72 +64,92 @@ class DuplicatedUsernameException {
     this.message = message;
     this.user = user;
   }
-
 }
 
 /**
  * find an account or register if not found
  */
-schema.statics.findOrRegister = function(
-    isSameUsernameTreatedAsIdenticalUser: boolean,
-    isSameEmailTreatedAsIdenticalUser: boolean,
-    providerType: string,
-    accountId: string,
-    usernameToBeRegistered?: string,
-    nameToBeRegistered?: string,
-    mailToBeRegistered?: string,
+schema.statics.findOrRegister = function (
+  isSameUsernameTreatedAsIdenticalUser: boolean,
+  isSameEmailTreatedAsIdenticalUser: boolean,
+  providerType: string,
+  accountId: string,
+  usernameToBeRegistered?: string,
+  nameToBeRegistered?: string,
+  mailToBeRegistered?: string,
 ): Promise<HydratedDocument<IExternalAccount<IExternalAuthProviderType>>> {
-  return this.findOne({ providerType, accountId })
-    .then((account) => {
+  return this.findOne({ providerType, accountId }).then((account) => {
     // ExternalAccount is found
-      if (account != null) {
-        logger.debug(`ExternalAccount '${accountId}' is found `, account);
-        return account;
-      }
-
-      if (usernameToBeRegistered == null) {
-        throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
-      }
-
-      const User = mongoose.model<HydratedDocument<IUser>, Model<IUser> & { createUser, STATUS_ACTIVE }>('User');
-
-      let promise = User.findOne({ username: usernameToBeRegistered }).exec();
-      if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
-        promise = promise
-          .then((user) => {
-            if (user == null) { return User.findOne({ email: mailToBeRegistered }) }
-            return user;
-          });
-      }
-      else if (isSameEmailTreatedAsIdenticalUser) {
-        promise = User.findOne({ email: mailToBeRegistered }).exec();
-      }
-
-      return promise
-        .then((user) => {
+    if (account != null) {
+      logger.debug(`ExternalAccount '${accountId}' is found `, account);
+      return account;
+    }
+
+    if (usernameToBeRegistered == null) {
+      throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
+    }
+
+    const User = mongoose.model<
+      HydratedDocument<IUser>,
+      Model<IUser> & { createUser; STATUS_ACTIVE }
+    >('User');
+
+    let promise = User.findOne({ username: usernameToBeRegistered }).exec();
+    if (
+      isSameUsernameTreatedAsIdenticalUser &&
+      isSameEmailTreatedAsIdenticalUser
+    ) {
+      promise = promise.then((user) => {
+        if (user == null) {
+          return User.findOne({ email: mailToBeRegistered });
+        }
+        return user;
+      });
+    } else if (isSameEmailTreatedAsIdenticalUser) {
+      promise = User.findOne({ email: mailToBeRegistered }).exec();
+    }
+
+    return promise
+      .then((user) => {
         // when the User that have the same `username` exists
-          if (user != null) {
-            throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
-          }
-          if (nameToBeRegistered == null) {
+        if (user != null) {
+          throw new DuplicatedUsernameException(
+            `User '${usernameToBeRegistered}' already exists`,
+            user,
+          );
+        }
+        if (nameToBeRegistered == null) {
           // eslint-disable-next-line no-param-reassign
-            nameToBeRegistered = '';
-          }
-
-          // create a new User with STATUS_ACTIVE
-          logger.debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
-          return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
-        })
-        .then((newUser) => {
-          return this.associate(providerType, accountId, newUser);
-        });
-    });
+          nameToBeRegistered = '';
+        }
+
+        // create a new User with STATUS_ACTIVE
+        logger.debug(
+          `ExternalAccount '${accountId}' is not found, it is going to be registered.`,
+        );
+        return User.createUser(
+          nameToBeRegistered,
+          usernameToBeRegistered,
+          mailToBeRegistered,
+          undefined,
+          undefined,
+          User.STATUS_ACTIVE,
+        );
+      })
+      .then((newUser) => {
+        return this.associate(providerType, accountId, newUser);
+      });
+  });
 };
 
 /**
  * Create ExternalAccount document and associate to existing User
  */
-schema.statics.associate = function(providerType: string, accountId: string, user: IUserHasId) {
+schema.statics.associate = function (
+  providerType: string,
+  accountId: string,
+  user: IUserHasId,
+) {
   return this.create({ providerType, accountId, user: user._id });
 };
 
@@ -135,7 +163,7 @@ schema.statics.associate = function(providerType: string, accountId: string, use
  * @returns {Promise<any>} mongoose-paginate result object
  * @memberof ExternalAccount
  */
-schema.statics.findAllWithPagination = function(opts) {
+schema.statics.findAllWithPagination = function (opts) {
   const query = {};
   const options = Object.assign({ populate: 'user' }, opts);
   if (options.sort == null) {
@@ -148,4 +176,7 @@ schema.statics.findAllWithPagination = function(opts) {
   return this.paginate(query, options);
 };
 
-export default getOrCreateModel<ExternalAccountDocument, ExternalAccountModel>('ExternalAccount', schema);
+export default getOrCreateModel<ExternalAccountDocument, ExternalAccountModel>(
+  'ExternalAccount',
+  schema,
+);

+ 15 - 8
apps/app/src/server/models/in-app-notification-settings.ts

@@ -1,17 +1,21 @@
-import type { Model, Document, Types } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import type { IInAppNotificationSettings } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-export interface InAppNotificationSettingsDocument extends IInAppNotificationSettings<Types.ObjectId>, Document {}
-export type InAppNotificationSettingsModel = Model<InAppNotificationSettingsDocument>
+export interface InAppNotificationSettingsDocument
+  extends IInAppNotificationSettings<Types.ObjectId>,
+    Document {}
+export type InAppNotificationSettingsModel =
+  Model<InAppNotificationSettingsDocument>;
 
-const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocument, InAppNotificationSettingsModel>({
+const inAppNotificationSettingsSchema = new Schema<
+  InAppNotificationSettingsDocument,
+  InAppNotificationSettingsModel
+>({
   userId: { type: Schema.Types.ObjectId },
   subscribeRules: [
     {
@@ -22,4 +26,7 @@ const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocu
 });
 
 // eslint-disable-next-line max-len
-export default getOrCreateModel<InAppNotificationSettingsDocument, InAppNotificationSettingsModel>('InAppNotificationSettings', inAppNotificationSettingsSchema);
+export default getOrCreateModel<
+  InAppNotificationSettingsDocument,
+  InAppNotificationSettingsModel
+>('InAppNotificationSettings', inAppNotificationSettingsSchema);

+ 80 - 67
apps/app/src/server/models/in-app-notification.ts

@@ -1,82 +1,93 @@
-import type { Types, Document, Model } from 'mongoose';
+import type { Document, Model, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
-import { AllSupportedTargetModels, AllSupportedActions } from '~/interfaces/activity';
+import {
+  AllSupportedActions,
+  AllSupportedTargetModels,
+} from '~/interfaces/activity';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
-
 import type { ActivityDocument } from './activity';
 
-
 const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 export interface InAppNotificationDocument extends Document {
-  _id: Types.ObjectId
-  user: Types.ObjectId
-  targetModel: string
-  target: Types.ObjectId
-  action: string
-  activities: ActivityDocument[]
-  status: string
-  createdAt: Date
-  snapshot: string
+  _id: Types.ObjectId;
+  user: Types.ObjectId;
+  targetModel: string;
+  target: Types.ObjectId;
+  action: string;
+  activities: ActivityDocument[];
+  status: string;
+  createdAt: Date;
+  snapshot: string;
 }
 
+export interface InAppNotificationModel
+  extends Model<InAppNotificationDocument> {
+  findLatestInAppNotificationsByUser(
+    user: Types.ObjectId,
+    skip: number,
+    offset: number,
+  );
+  getUnreadCountByUser(user: Types.ObjectId): Promise<number | undefined>;
+  open(user, id: Types.ObjectId): Promise<InAppNotificationDocument | null>;
+  read(user) /* : Promise<Query<any>> */;
 
-export interface InAppNotificationModel extends Model<InAppNotificationDocument> {
-  findLatestInAppNotificationsByUser(user: Types.ObjectId, skip: number, offset: number)
-  getUnreadCountByUser(user: Types.ObjectId): Promise<number | undefined>
-  open(user, id: Types.ObjectId): Promise<InAppNotificationDocument | null>
-  read(user) /* : Promise<Query<any>> */
-
-  STATUS_UNOPENED: string
-  STATUS_OPENED: string
+  STATUS_UNOPENED: string;
+  STATUS_OPENED: string;
 }
 
-const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotificationModel>({
-  user: {
-    type: Schema.Types.ObjectId,
-    ref: 'User',
-    index: true,
-    required: true,
-  },
-  targetModel: {
-    type: String,
-    required: true,
-    enum: AllSupportedTargetModels,
-  },
-  target: {
-    type: Schema.Types.ObjectId,
-    refPath: 'targetModel',
-    required: true,
-  },
-  action: {
-    type: String,
-    required: true,
-    enum: AllSupportedActions,
-  },
-  activities: [
-    {
+const inAppNotificationSchema = new Schema<
+  InAppNotificationDocument,
+  InAppNotificationModel
+>(
+  {
+    user: {
+      type: Schema.Types.ObjectId,
+      ref: 'User',
+      index: true,
+      required: true,
+    },
+    targetModel: {
+      type: String,
+      required: true,
+      enum: AllSupportedTargetModels,
+    },
+    target: {
       type: Schema.Types.ObjectId,
-      ref: 'Activity',
+      refPath: 'targetModel',
+      required: true,
+    },
+    action: {
+      type: String,
+      required: true,
+      enum: AllSupportedActions,
+    },
+    activities: [
+      {
+        type: Schema.Types.ObjectId,
+        ref: 'Activity',
+      },
+    ],
+    status: {
+      type: String,
+      default: STATUS_UNOPENED,
+      enum: InAppNotificationStatuses,
+      index: true,
+      required: true,
+    },
+    snapshot: {
+      type: String,
+      required: true,
     },
-  ],
-  status: {
-    type: String,
-    default: STATUS_UNOPENED,
-    enum: InAppNotificationStatuses,
-    index: true,
-    required: true,
   },
-  snapshot: {
-    type: String,
-    required: true,
+  {
+    timestamps: { createdAt: true, updatedAt: false },
   },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+);
 // indexes
 inAppNotificationSchema.index({ createdAt: 1 });
 // apply plugins
@@ -88,16 +99,18 @@ const transform = (doc, ret) => {
 inAppNotificationSchema.set('toObject', { virtuals: true, transform });
 inAppNotificationSchema.set('toJSON', { virtuals: true, transform });
 inAppNotificationSchema.index({
-  user: 1, target: 1, action: 1, createdAt: 1,
+  user: 1,
+  target: 1,
+  action: 1,
+  createdAt: 1,
 });
 
-inAppNotificationSchema.statics.STATUS_UNOPENED = function() {
-  return STATUS_UNOPENED;
-};
-inAppNotificationSchema.statics.STATUS_OPENED = function() {
-  return STATUS_OPENED;
-};
+inAppNotificationSchema.statics.STATUS_UNOPENED = () => STATUS_UNOPENED;
+inAppNotificationSchema.statics.STATUS_OPENED = () => STATUS_OPENED;
 
-const InAppNotification = getOrCreateModel<InAppNotificationDocument, InAppNotificationModel>('InAppNotification', inAppNotificationSchema);
+const InAppNotification = getOrCreateModel<
+  InAppNotificationDocument,
+  InAppNotificationModel
+>('InAppNotification', inAppNotificationSchema);
 
 export { InAppNotification };

+ 12 - 9
apps/app/src/server/models/named-query.ts

@@ -1,9 +1,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import type { Model, Document } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
+import type { Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import type { INamedQuery } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
@@ -11,24 +9,26 @@ import { SearchDelegatorName } from '~/interfaces/named-query';
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:models:named-query');
 
 export interface NamedQueryDocument extends INamedQuery, Document {}
 
-export type NamedQueryModel = Model<NamedQueryDocument>
+export type NamedQueryModel = Model<NamedQueryDocument>;
 
 const schema = new Schema<NamedQueryDocument, NamedQueryModel>({
   name: { type: String, required: true, unique: true },
   aliasOf: { type: String },
   delegatorName: { type: String, enum: SearchDelegatorName },
   creator: {
-    type: Schema.Types.ObjectId, ref: 'User', index: true, default: null,
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    index: true,
+    default: null,
   },
 });
 
-schema.pre('validate', async function(this, next) {
+schema.pre('validate', async function (this, next) {
   if (this.aliasOf == null && this.delegatorName == null) {
     throw Error('Either of aliasOf and delegatorNameName must not be null.');
   }
@@ -36,4 +36,7 @@ schema.pre('validate', async function(this, next) {
   next();
 });
 
-export default getOrCreateModel<NamedQueryDocument, NamedQueryModel>('NamedQuery', schema);
+export default getOrCreateModel<NamedQueryDocument, NamedQueryModel>(
+  'NamedQuery',
+  schema,
+);

+ 298 - 148
apps/app/src/server/models/obsolete-page.js

@@ -1,5 +1,9 @@
 import { GroupType, Origin } from '@growi/core';
-import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import {
+  pagePathUtils,
+  pathUtils,
+  templateChecker,
+} from '@growi/core/dist/utils';
 import { differenceInYears } from 'date-fns/differenceInYears';
 import escapeStringRegexp from 'escape-string-regexp';
 
@@ -9,13 +13,11 @@ import ExternalUserGroupRelation from '~/features/external-user-group/server/mod
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../service/config-manager';
-
 import UserGroup from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
 
-
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -72,17 +74,25 @@ export const extractToAncestorsPaths = (pagePath) => {
  * @param {boolean} shouldExcludeBody boolean indicating whether to include 'revision.body' or not
  */
 /* eslint-disable object-curly-newline, object-property-newline */
-export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
-  return page
-    .populate([
-      { path: 'lastUpdateUser', select: userPublicFields },
-      { path: 'creator', select: userPublicFields },
-      { path: 'deleteUser', select: userPublicFields },
-      { path: 'grantedGroups.item' },
-      { path: 'revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
-        path: 'author', select: userPublicFields,
-      } },
-    ]);
+export const populateDataToShowRevision = (
+  page,
+  userPublicFields,
+  shouldExcludeBody = false,
+) => {
+  return page.populate([
+    { path: 'lastUpdateUser', select: userPublicFields },
+    { path: 'creator', select: userPublicFields },
+    { path: 'deleteUser', select: userPublicFields },
+    { path: 'grantedGroups.item' },
+    {
+      path: 'revision',
+      select: shouldExcludeBody ? '-body' : undefined,
+      populate: {
+        path: 'author',
+        select: userPublicFields,
+      },
+    },
+  ]);
 };
 /* eslint-enable object-curly-newline, object-property-newline */
 
@@ -101,15 +111,17 @@ export const getPageSchema = (crowi) => {
 
   function validateCrowi() {
     if (crowi == null) {
-      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+      throw new Error(
+        '"crowi" is null. Init User model with "crowi" argument first.',
+      );
     }
   }
 
-  pageSchema.methods.isDeleted = function() {
+  pageSchema.methods.isDeleted = function () {
     return isTrashPage(this.path);
   };
 
-  pageSchema.methods.isPublic = function() {
+  pageSchema.methods.isPublic = function () {
     if (!this.grant || this.grant === GRANT_PUBLIC) {
       return true;
     }
@@ -117,49 +129,60 @@ export const getPageSchema = (crowi) => {
     return false;
   };
 
-  pageSchema.methods.isTopPage = function() {
+  pageSchema.methods.isTopPage = function () {
     return isTopPage(this.path);
   };
 
-  pageSchema.methods.isTemplate = function() {
+  pageSchema.methods.isTemplate = function () {
     return checkTemplatePath(this.path);
   };
 
-  pageSchema.methods.isLatestRevision = function() {
+  pageSchema.methods.isLatestRevision = function () {
     // populate されていなくて判断できない
     if (!this.latestRevision || !this.revision) {
       return true;
     }
 
     // comparing ObjectId with string
-    // eslint-disable-next-line eqeqeq
-    return (this.latestRevision == this.revision._id.toString());
+    // biome-ignore lint/suspicious/noDoubleEquals: ignore
+    return this.latestRevision == this.revision._id.toString();
   };
 
-  pageSchema.methods.findRelatedTagsById = async function() {
+  pageSchema.methods.findRelatedTagsById = async function () {
     const PageTagRelation = mongoose.model('PageTagRelation');
-    const relations = await PageTagRelation.find({ relatedPage: this._id }).populate('relatedTag');
-    return relations.map((relation) => { return relation.relatedTag.name });
+    const relations = await PageTagRelation.find({
+      relatedPage: this._id,
+    }).populate('relatedTag');
+    return relations.map((relation) => {
+      return relation.relatedTag.name;
+    });
   };
 
-  pageSchema.methods.isUpdatable = async function(previousRevision, origin) {
-    const populatedPageDataWithRevisionOrigin = await this.populate('revision', 'origin');
-    const latestRevisionOrigin = populatedPageDataWithRevisionOrigin.revision.origin;
-    const ignoreLatestRevision = origin === Origin.Editor && (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
+  pageSchema.methods.isUpdatable = async function (previousRevision, origin) {
+    const populatedPageDataWithRevisionOrigin = await this.populate(
+      'revision',
+      'origin',
+    );
+    const latestRevisionOrigin =
+      populatedPageDataWithRevisionOrigin.revision.origin;
+    const ignoreLatestRevision =
+      origin === Origin.Editor &&
+      (latestRevisionOrigin === Origin.Editor ||
+        latestRevisionOrigin === Origin.View);
     if (ignoreLatestRevision) {
       return true;
     }
 
     const revision = this.latestRevision || this.revision._id;
     // comparing ObjectId with string
-    // eslint-disable-next-line eqeqeq
+    // biome-ignore lint/suspicious/noDoubleEquals: ignore
     if (revision != previousRevision) {
       return false;
     }
     return true;
   };
 
-  pageSchema.methods.isLiked = function(user) {
+  pageSchema.methods.isLiked = function (user) {
     if (user == null || user._id == null) {
       return false;
     }
@@ -169,53 +192,47 @@ export const getPageSchema = (crowi) => {
     });
   };
 
-  pageSchema.methods.like = function(userData) {
-    const self = this;
-
-    return new Promise(((resolve, reject) => {
-      const added = self.liker.addToSet(userData._id);
+  pageSchema.methods.like = function (userData) {
+    return new Promise((resolve, reject) => {
+      const added = this.liker.addToSet(userData._id);
       if (added.length > 0) {
-        self.save((err, data) => {
+        this.save((err, data) => {
           if (err) {
             return reject(err);
           }
           logger.debug('liker updated!', added);
           return resolve(data);
         });
-      }
-      else {
+      } else {
         logger.debug('liker not updated');
         return reject(new Error('Already liked'));
       }
-    }));
+    });
   };
 
-  pageSchema.methods.unlike = function(userData, callback) {
-    const self = this;
-
-    return new Promise(((resolve, reject) => {
-      const beforeCount = self.liker.length;
-      self.liker.pull(userData._id);
-      if (self.liker.length !== beforeCount) {
-        self.save((err, data) => {
+  pageSchema.methods.unlike = function (userData, callback) {
+    return new Promise((resolve, reject) => {
+      const beforeCount = this.liker.length;
+      this.liker.pull(userData._id);
+      if (this.liker.length !== beforeCount) {
+        this.save((err, data) => {
           if (err) {
             return reject(err);
           }
           return resolve(data);
         });
-      }
-      else {
+      } else {
         logger.debug('liker not updated');
         return reject(new Error('Already unliked'));
       }
-    }));
+    });
   };
 
-  pageSchema.methods.isSeenUser = function(userData) {
+  pageSchema.methods.isSeenUser = function (userData) {
     return this.seenUsers.includes(userData._id);
   };
 
-  pageSchema.methods.seen = async function(userData) {
+  pageSchema.methods.seen = async function (userData) {
     if (this.isSeenUser(userData)) {
       logger.debug('seenUsers not updated');
       return this;
@@ -234,27 +251,35 @@ export const getPageSchema = (crowi) => {
     return saved;
   };
 
-  pageSchema.methods.updateSlackChannels = function(slackChannels) {
+  pageSchema.methods.updateSlackChannels = function (slackChannels) {
     this.slackChannels = slackChannels;
 
     return this.save();
   };
 
-  pageSchema.methods.initLatestRevisionField = async function(revisionId) {
+  pageSchema.methods.initLatestRevisionField = async function (revisionId) {
     this.latestRevision = this.revision;
     if (revisionId != null) {
       this.revision = revisionId;
     }
   };
 
-  pageSchema.methods.populateDataToShowRevision = async function(shouldExcludeBody) {
+  pageSchema.methods.populateDataToShowRevision = async function (
+    shouldExcludeBody,
+  ) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL, shouldExcludeBody);
+    return populateDataToShowRevision(
+      this,
+      User.USER_FIELDS_EXCEPT_CONFIDENTIAL,
+      shouldExcludeBody,
+    );
   };
 
-  pageSchema.methods.populateDataToMakePresentation = async function(revisionId) {
+  pageSchema.methods.populateDataToMakePresentation = async function (
+    revisionId,
+  ) {
     this.latestRevision = this.revision;
     if (revisionId != null) {
       this.revision = revisionId;
@@ -262,7 +287,7 @@ export const getPageSchema = (crowi) => {
     return this.populate('revision');
   };
 
-  pageSchema.methods.applyScope = function(user, grant, grantUserGroupIds) {
+  pageSchema.methods.applyScope = function (user, grant, grantUserGroupIds) {
     // Reset
     this.grantedUsers = [];
     this.grantedGroups = [];
@@ -278,29 +303,25 @@ export const getPageSchema = (crowi) => {
     }
   };
 
-  pageSchema.methods.getContentAge = function() {
+  pageSchema.methods.getContentAge = function () {
     return differenceInYears(new Date(), this.updatedAt);
   };
 
-
-  pageSchema.statics.updateCommentCount = function(pageId) {
+  pageSchema.statics.updateCommentCount = function (pageId) {
     validateCrowi();
-
-    const self = this;
-    return Comment.countCommentByPageId(pageId)
-      .then((count) => {
-        self.update({ _id: pageId }, { commentCount: count }, {}, (err, data) => {
-          if (err) {
-            logger.debug('Update commentCount Error', err);
-            throw err;
-          }
-
-          return data;
-        });
+    return Comment.countCommentByPageId(pageId).then((count) => {
+      this.update({ _id: pageId }, { commentCount: count }, {}, (err, data) => {
+        if (err) {
+          logger.debug('Update commentCount Error', err);
+          throw err;
+        }
+
+        return data;
       });
+    });
   };
 
-  pageSchema.statics.getDeletedPageName = function(path) {
+  pageSchema.statics.getDeletedPageName = (path) => {
     if (path.match('/')) {
       // eslint-disable-next-line no-param-reassign
       path = path.substr(1);
@@ -308,16 +329,12 @@ export const getPageSchema = (crowi) => {
     return `/trash/${path}`;
   };
 
-  pageSchema.statics.getRevertDeletedPageName = function(path) {
-    return path.replace('/trash', '');
-  };
+  pageSchema.statics.getRevertDeletedPageName = (path) =>
+    path.replace('/trash', '');
 
-  pageSchema.statics.fixToCreatableName = function(path) {
-    return path
-      .replace(/\/\//g, '/');
-  };
+  pageSchema.statics.fixToCreatableName = (path) => path.replace(/\/\//g, '/');
 
-  pageSchema.statics.updateRevision = function(pageId, revisionId, cb) {
+  pageSchema.statics.updateRevision = function (pageId, revisionId, cb) {
     this.update({ _id: pageId }, { revision: revisionId }, {}, (err, data) => {
       cb(err, data);
     });
@@ -328,13 +345,18 @@ export const getPageSchema = (crowi) => {
    * @param {string} id ObjectId
    * @param {User} user
    */
-  pageSchema.statics.isAccessiblePageByViewer = async function(id, user) {
+  pageSchema.statics.isAccessiblePageByViewer = async function (id, user) {
     const baseQuery = this.count({ _id: id });
 
-    const userGroups = user != null ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : [];
+    const userGroups =
+      user != null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : [];
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
@@ -348,13 +370,23 @@ export const getPageSchema = (crowi) => {
    * @param {User} user User instance
    * @param {UserGroup[]} userGroups List of UserGroup instances
    */
-  pageSchema.statics.findByIdAndViewer = async function(id, user, userGroups, includeEmpty = false) {
+  pageSchema.statics.findByIdAndViewer = async function (
+    id,
+    user,
+    userGroups,
+    includeEmpty = false,
+  ) {
     const baseQuery = this.findOne({ _id: id });
 
-    const relatedUserGroups = (user != null && userGroups == null) ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : userGroups;
+    const relatedUserGroups =
+      user != null && userGroups == null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : userGroups;
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
@@ -363,12 +395,15 @@ export const getPageSchema = (crowi) => {
   };
 
   // find page by path
-  pageSchema.statics.findByPath = function(path, includeEmpty = false) {
+  pageSchema.statics.findByPath = function (path, includeEmpty = false) {
     if (path == null) {
       return null;
     }
 
-    const builder = new this.PageQueryBuilder(this.findOne({ path }), includeEmpty);
+    const builder = new this.PageQueryBuilder(
+      this.findOne({ path }),
+      includeEmpty,
+    );
 
     return builder.query.exec();
   };
@@ -378,7 +413,12 @@ export const getPageSchema = (crowi) => {
    * @param {User} user User instance
    * @param {UserGroup[]} userGroups List of UserGroup instances
    */
-  pageSchema.statics.findAncestorByPathAndViewer = async function(path, user, userGroups, includeEmpty = false) {
+  pageSchema.statics.findAncestorByPathAndViewer = async function (
+    path,
+    user,
+    userGroups,
+    includeEmpty = false,
+  ) {
     if (path == null) {
       throw new Error('path is required.');
     }
@@ -390,12 +430,19 @@ export const getPageSchema = (crowi) => {
     const ancestorsPaths = extractToAncestorsPaths(path);
 
     // pick the longest one
-    const baseQuery = this.findOne({ path: { $in: ancestorsPaths } }).sort({ path: -1 });
+    const baseQuery = this.findOne({ path: { $in: ancestorsPaths } }).sort({
+      path: -1,
+    });
 
-    const relatedUserGroups = (user != null && userGroups == null) ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : userGroups;
+    const relatedUserGroups =
+      user != null && userGroups == null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : userGroups;
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
@@ -406,7 +453,12 @@ export const getPageSchema = (crowi) => {
   /**
    * find pages that is match with `path` and its descendants
    */
-  pageSchema.statics.findListWithDescendants = async function(path, user, option = {}, includeEmpty = false) {
+  pageSchema.statics.findListWithDescendants = async function (
+    path,
+    user,
+    option = {},
+    includeEmpty = false,
+  ) {
     const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(path, option);
 
@@ -416,7 +468,12 @@ export const getPageSchema = (crowi) => {
   /**
    * find pages that is match with `path` and its descendants which user is able to manage
    */
-  pageSchema.statics.findManageableListWithDescendants = async function(page, user, option = {}, includeEmpty = false) {
+  pageSchema.statics.findManageableListWithDescendants = async function (
+    page,
+    user,
+    option = {},
+    includeEmpty = false,
+  ) {
     if (user == null) {
       return null;
     }
@@ -427,7 +484,12 @@ export const getPageSchema = (crowi) => {
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
 
-    const { pages } = await findListFromBuilderAndViewer(builder, user, false, option);
+    const { pages } = await findListFromBuilderAndViewer(
+      builder,
+      user,
+      false,
+      option,
+    );
 
     // add page if 'grant' is GRANT_RESTRICTED
     // because addConditionToListWithDescendants excludes GRANT_RESTRICTED pages
@@ -441,7 +503,12 @@ export const getPageSchema = (crowi) => {
   /**
    * find pages that start with `path`
    */
-  pageSchema.statics.findListByStartWith = async function(path, user, option, includeEmpty = false) {
+  pageSchema.statics.findListByStartWith = async function (
+    path,
+    user,
+    option,
+    includeEmpty = false,
+  ) {
     const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListByStartWith(path, option);
 
@@ -455,16 +522,27 @@ export const getPageSchema = (crowi) => {
    * @param {User} currentUser
    * @param {any} option
    */
-  pageSchema.statics.findListByCreator = async function(targetUser, currentUser, option) {
+  pageSchema.statics.findListByCreator = async function (
+    targetUser,
+    currentUser,
+    option,
+  ) {
     const opt = Object.assign({ sort: 'createdAt', desc: -1 }, option);
-    const builder = new this.PageQueryBuilder(this.find({ creator: targetUser._id }));
+    const builder = new this.PageQueryBuilder(
+      this.find({ creator: targetUser._id }),
+    );
 
     let showAnyoneKnowsLink = null;
     if (targetUser != null && currentUser != null) {
       showAnyoneKnowsLink = targetUser._id.equals(currentUser._id);
     }
 
-    return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
+    return await findListFromBuilderAndViewer(
+      builder,
+      currentUser,
+      showAnyoneKnowsLink,
+      opt,
+    );
   };
 
   /**
@@ -474,7 +552,12 @@ export const getPageSchema = (crowi) => {
    * @param {boolean} showAnyoneKnowsLink
    * @param {any} option
    */
-  async function findListFromBuilderAndViewer(builder, user, showAnyoneKnowsLink, option) {
+  async function findListFromBuilderAndViewer(
+    builder,
+    user,
+    showAnyoneKnowsLink,
+    option,
+  ) {
     validateCrowi();
 
     const User = crowi.model('User');
@@ -488,7 +571,11 @@ export const getPageSchema = (crowi) => {
     }
 
     // add grant conditions
-    await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
+    await addConditionToFilteringByViewerForList(
+      builder,
+      user,
+      showAnyoneKnowsLink,
+    );
 
     // count
     const totalCount = await builder.query.exec('count');
@@ -498,7 +585,10 @@ export const getPageSchema = (crowi) => {
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.lean().clone().exec('find');
     const result = {
-      pages, totalCount, offset: opt.offset, limit: opt.limit,
+      pages,
+      totalCount,
+      offset: opt.offset,
+      limit: opt.limit,
     };
     return result;
   }
@@ -511,20 +601,39 @@ export const getPageSchema = (crowi) => {
    * @param {User} user
    * @param {boolean} showAnyoneKnowsLink
    */
-  async function addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink) {
+  async function addConditionToFilteringByViewerForList(
+    builder,
+    user,
+    showAnyoneKnowsLink,
+  ) {
     validateCrowi();
 
     // determine User condition
-    const hidePagesRestrictedByOwner = configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-    const hidePagesRestrictedByGroup = configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+    const hidePagesRestrictedByOwner = configManager.getConfig(
+      'security:list-policy:hideRestrictedByOwner',
+    );
+    const hidePagesRestrictedByGroup = configManager.getConfig(
+      'security:list-policy:hideRestrictedByGroup',
+    );
 
     // determine UserGroup condition
-    const userGroups = user != null ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : null;
-
-    return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
+    const userGroups =
+      user != null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : null;
+
+    return builder.addConditionToFilteringByViewer(
+      user,
+      userGroups,
+      showAnyoneKnowsLink,
+      !hidePagesRestrictedByOwner,
+      !hidePagesRestrictedByGroup,
+    );
   }
 
   /**
@@ -537,46 +646,67 @@ export const getPageSchema = (crowi) => {
    */
   async function addConditionToFilteringByViewerToEdit(builder, user) {
     // determine UserGroup condition
-    const userGroups = user != null ? [
-      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
-    ] : null;
-
-    return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
+    const userGroups =
+      user != null
+        ? [
+            ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+            ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+              user,
+            )),
+          ]
+        : null;
+
+    return builder.addConditionToFilteringByViewer(
+      user,
+      userGroups,
+      false,
+      false,
+      false,
+    );
   }
 
   /**
    * export addConditionToFilteringByViewerForList as static method
    */
-  pageSchema.statics.addConditionToFilteringByViewerForList = addConditionToFilteringByViewerForList;
+  pageSchema.statics.addConditionToFilteringByViewerForList =
+    addConditionToFilteringByViewerForList;
 
   /**
    * export addConditionToFilteringByViewerToEdit as static method
    */
-  pageSchema.statics.addConditionToFilteringByViewerToEdit = addConditionToFilteringByViewerToEdit;
+  pageSchema.statics.addConditionToFilteringByViewerToEdit =
+    addConditionToFilteringByViewerToEdit;
 
   /**
    * Throw error for growi-lsx-plugin (v1.x)
    */
-  pageSchema.statics.generateQueryToListByStartWith = function(path, user, option) {
+  pageSchema.statics.generateQueryToListByStartWith = function (
+    path,
+    user,
+    option,
+  ) {
     const dummyQuery = this.find();
-    dummyQuery.exec = async() => {
-      throw new Error('Plugin version mismatch. Upgrade growi-lsx-plugin to v2.0.0 or above.');
+    dummyQuery.exec = async () => {
+      throw new Error(
+        'Plugin version mismatch. Upgrade growi-lsx-plugin to v2.0.0 or above.',
+      );
     };
     return dummyQuery;
   };
-  pageSchema.statics.generateQueryToListWithDescendants = pageSchema.statics.generateQueryToListByStartWith;
-
+  pageSchema.statics.generateQueryToListWithDescendants =
+    pageSchema.statics.generateQueryToListByStartWith;
 
   /**
    * find all templates applicable to the new page
    */
-  pageSchema.statics.findTemplate = async function(path) {
+  pageSchema.statics.findTemplate = async function (path) {
     const templatePath = nodePath.posix.dirname(path);
     const pathList = generatePathsOnTree(path, []);
     const regexpList = pathList.map((path) => {
       const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
-      return new RegExp(`^${escapeStringRegexp(pathWithTrailingSlash)}_{1,2}template$`);
+      return new RegExp(
+        `^${escapeStringRegexp(pathWithTrailingSlash)}_{1,2}template$`,
+      );
     });
 
     const templatePages = await this.find({ path: { $in: regexpList } })
@@ -602,12 +732,16 @@ export const getPageSchema = (crowi) => {
     const targetTemplatePath = urljoin(path, `${type}template`);
 
     return templates.find((template) => {
-      return (template.path === targetTemplatePath);
+      return template.path === targetTemplatePath;
     });
   };
 
   const assignDecendantsTemplate = (decendantsTemplates, path) => {
-    const decendantsTemplate = assignTemplateByType(decendantsTemplates, path, '__');
+    const decendantsTemplate = assignTemplateByType(
+      decendantsTemplates,
+      path,
+      '__',
+    );
     if (decendantsTemplate) {
       return decendantsTemplate;
     }
@@ -620,7 +754,7 @@ export const getPageSchema = (crowi) => {
     return assignDecendantsTemplate(decendantsTemplates, newPath);
   };
 
-  const fetchTemplate = async(templates, templatePath) => {
+  const fetchTemplate = async (templates, templatePath) => {
     let templateBody;
     let templateTags;
     /**
@@ -633,13 +767,15 @@ export const getPageSchema = (crowi) => {
      * get decendants templates
      * _tempate: applicable to all pages under
      */
-    const decendantsTemplate = assignDecendantsTemplate(templates, templatePath);
+    const decendantsTemplate = assignDecendantsTemplate(
+      templates,
+      templatePath,
+    );
 
     if (childrenTemplate) {
       templateBody = childrenTemplate.revision.body;
       templateTags = await childrenTemplate.findRelatedTagsById();
-    }
-    else if (decendantsTemplate) {
+    } else if (decendantsTemplate) {
       templateBody = decendantsTemplate.revision.body;
       templateTags = await decendantsTemplate.findRelatedTagsById();
     }
@@ -647,7 +783,10 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
+  pageSchema.statics.findListByPathsArray = async function (
+    paths,
+    includeEmpty = false,
+  ) {
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
 
@@ -659,19 +798,30 @@ export const getPageSchema = (crowi) => {
    * @param {Page[]} pages
    * @param {IGrantedGroup} transferToUserGroup
    */
-  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroup) {
-    const userGroupModel = transferToUserGroup.type === GroupType.userGroup ? UserGroup : ExternalUserGroup;
+  pageSchema.statics.transferPagesToGroup = async function (
+    pages,
+    transferToUserGroup,
+  ) {
+    const userGroupModel =
+      transferToUserGroup.type === GroupType.userGroup
+        ? UserGroup
+        : ExternalUserGroup;
 
     if ((await userGroupModel.count({ _id: transferToUserGroup.item })) === 0) {
-      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroup.item);
+      throw Error(
+        'Cannot find the group to which private pages belong to. _id: ',
+        transferToUserGroup.item,
+      );
     }
 
-    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
+    await this.updateMany(
+      { _id: { $in: pages.map((p) => p._id) } },
+      { grantedGroups: [transferToUserGroup] },
+    );
   };
 
-  pageSchema.statics.getHistories = function() {
+  pageSchema.statics.getHistories = () => {
     // TODO
-
   };
 
   pageSchema.statics.STATUS_PUBLISHED = STATUS_PUBLISHED;

+ 123 - 91
apps/app/src/server/models/page-operation.ts

@@ -1,15 +1,11 @@
 import type { IGrantedGroup } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
-import type {
-  Model, Document, QueryOptions, FilterQuery,
-} from 'mongoose';
-import mongoose, {
-  Schema,
-} from 'mongoose';
+import type { Document, FilterQuery, Model, QueryOptions } from 'mongoose';
+import mongoose, { Schema } from 'mongoose';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
-import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 
 import loggerFactory from '../../utils/logger';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -21,61 +17,69 @@ const logger = loggerFactory('growi:models:page-operation');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-
 type IPageForResuming = {
-  _id: ObjectIdLike,
-  path: string,
-  isEmpty: boolean,
-  parent?: ObjectIdLike,
-  grant?: number,
-  grantedUsers?: ObjectIdLike[],
-  grantedGroups: IGrantedGroup[],
-  descendantCount: number,
-  status?: number,
-  revision?: ObjectIdLike,
-  lastUpdateUser?: ObjectIdLike,
-  creator?: ObjectIdLike,
+  _id: ObjectIdLike;
+  path: string;
+  isEmpty: boolean;
+  parent?: ObjectIdLike;
+  grant?: number;
+  grantedUsers?: ObjectIdLike[];
+  grantedGroups: IGrantedGroup[];
+  descendantCount: number;
+  status?: number;
+  revision?: ObjectIdLike;
+  lastUpdateUser?: ObjectIdLike;
+  creator?: ObjectIdLike;
 };
 
 type IUserForResuming = {
-  _id: ObjectIdLike,
+  _id: ObjectIdLike;
 };
 
 type IOptionsForResuming = {
-  format: 'md' | 'pdf',
-  updateMetadata?: boolean,
-  createRedirectPage?: boolean,
-  prevDescendantCount?: number,
-} & IOptionsForUpdate & IOptionsForCreate;
-
+  format: 'md' | 'pdf';
+  updateMetadata?: boolean;
+  createRedirectPage?: boolean;
+  prevDescendantCount?: number;
+} & IOptionsForUpdate &
+  IOptionsForCreate;
 
 /*
  * Main Schema
  */
 export interface IPageOperation {
-  actionType: PageActionType,
-  actionStage: PageActionStage,
-  fromPath: string,
-  toPath?: string,
-  page: IPageForResuming,
-  user: IUserForResuming,
-  options?: IOptionsForResuming,
-  incForUpdatingDescendantCount?: number,
-  unprocessableExpiryDate: Date,
-  exPage?: IPageForResuming,
-
-  isProcessable(): boolean
+  actionType: PageActionType;
+  actionStage: PageActionStage;
+  fromPath: string;
+  toPath?: string;
+  page: IPageForResuming;
+  user: IUserForResuming;
+  options?: IOptionsForResuming;
+  incForUpdatingDescendantCount?: number;
+  unprocessableExpiryDate: Date;
+  exPage?: IPageForResuming;
+
+  isProcessable(): boolean;
 }
 
 export interface PageOperationDocument extends IPageOperation, Document {}
 
-export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectIdLike };
+export type PageOperationDocumentHasId = PageOperationDocument & {
+  _id: ObjectIdLike;
+};
 
 export interface PageOperationModel extends Model<PageOperationDocument> {
-  findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
-  findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
-  deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>
-  extendExpiryDate(operationId: ObjectIdLike): Promise<void>
+  findByIdAndUpdatePageActionStage(
+    pageOpId: ObjectIdLike,
+    stage: PageActionStage,
+  ): Promise<PageOperationDocumentHasId | null>;
+  findMainOps(
+    filter?: FilterQuery<PageOperationDocument>,
+    projection?: any,
+    options?: QueryOptions,
+  ): Promise<PageOperationDocumentHasId[]>;
+  deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>;
+  extendExpiryDate(operationId: ObjectIdLike): Promise<void>;
 }
 
 const pageSchemaForResuming = new Schema<IPageForResuming>({
@@ -88,17 +92,21 @@ const pageSchemaForResuming = new Schema<IPageForResuming>({
   status: { type: String },
   grant: { type: Number },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroups: [{
-    type: {
-      type: String,
-      enum: Object.values(GroupType),
-      required: true,
-      default: 'UserGroup',
+  grantedGroups: [
+    {
+      type: {
+        type: String,
+        enum: Object.values(GroupType),
+        required: true,
+        default: 'UserGroup',
+      },
+      item: {
+        type: ObjectId,
+        refPath: 'grantedGroups.type',
+        required: true,
+      },
     },
-    item: {
-      type: ObjectId, refPath: 'grantedGroups.type', required: true,
-    },
-  }],
+  ],
   creator: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
 });
@@ -107,25 +115,32 @@ const userSchemaForResuming = new Schema<IUserForResuming>({
   _id: { type: ObjectId, ref: 'User', required: true },
 });
 
-const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
-  createRedirectPage: { type: Boolean },
-  updateMetadata: { type: Boolean },
-  prevDescendantCount: { type: Number },
-  grant: { type: Number },
-  grantUserGroupIds: [{
-    type: {
-      type: String,
-      enum: Object.values(GroupType),
-      required: true,
-      default: 'UserGroup',
-    },
-    item: {
-      type: ObjectId, refPath: 'grantedGroups.type', required: true,
-    },
-  }],
-  format: { type: String },
-  overwriteScopesOfDescendants: { type: Boolean },
-}, { _id: false });
+const optionsSchemaForResuming = new Schema<IOptionsForResuming>(
+  {
+    createRedirectPage: { type: Boolean },
+    updateMetadata: { type: Boolean },
+    prevDescendantCount: { type: Number },
+    grant: { type: Number },
+    grantUserGroupIds: [
+      {
+        type: {
+          type: String,
+          enum: Object.values(GroupType),
+          required: true,
+          default: 'UserGroup',
+        },
+        item: {
+          type: ObjectId,
+          refPath: 'grantedGroups.type',
+          required: true,
+        },
+      },
+    ],
+    format: { type: String },
+    overwriteScopesOfDescendants: { type: Boolean },
+  },
+  { _id: false },
+);
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
   actionType: {
@@ -147,22 +162,30 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
-  unprocessableExpiryDate: { type: Date, default: () => addSeconds(new Date(), 10) },
+  unprocessableExpiryDate: {
+    type: Date,
+    default: () => addSeconds(new Date(), 10),
+  },
 });
 
-schema.statics.findByIdAndUpdatePageActionStage = async function(
-    pageOpId: ObjectIdLike, stage: PageActionStage,
+schema.statics.findByIdAndUpdatePageActionStage = async function (
+  pageOpId: ObjectIdLike,
+  stage: PageActionStage,
 ): Promise<PageOperationDocumentHasId | null> {
-
-  return this.findByIdAndUpdate(pageOpId, {
-    $set: { actionStage: stage },
-  }, { new: true });
+  return this.findByIdAndUpdate(
+    pageOpId,
+    {
+      $set: { actionStage: stage },
+    },
+    { new: true },
+  );
 };
 
-schema.statics.findMainOps = async function(
-    filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions,
+schema.statics.findMainOps = async function (
+  filter?: FilterQuery<PageOperationDocument>,
+  projection?: any,
+  options?: QueryOptions,
 ): Promise<PageOperationDocumentHasId[]> {
-
   return this.find(
     { ...filter, actionStage: PageActionStage.Main },
     projection,
@@ -170,25 +193,34 @@ schema.statics.findMainOps = async function(
   );
 };
 
-schema.statics.deleteByActionTypes = async function(
-    actionTypes: PageActionType[],
+schema.statics.deleteByActionTypes = async function (
+  actionTypes: PageActionType[],
 ): Promise<void> {
-
   await this.deleteMany({ actionType: { $in: actionTypes } });
-  logger.info(`Deleted all PageOperation documents with actionType: [${actionTypes}]`);
+  logger.info(
+    `Deleted all PageOperation documents with actionType: [${actionTypes}]`,
+  );
 };
 
 /**
  * add TIME_TO_ADD_SEC to current time and update unprocessableExpiryDate with it
  */
-schema.statics.extendExpiryDate = async function(operationId: ObjectIdLike): Promise<void> {
+schema.statics.extendExpiryDate = async function (
+  operationId: ObjectIdLike,
+): Promise<void> {
   const date = addSeconds(new Date(), TIME_TO_ADD_SEC);
   await this.findByIdAndUpdate(operationId, { unprocessableExpiryDate: date });
 };
 
-schema.methods.isProcessable = function(): boolean {
+schema.methods.isProcessable = function (): boolean {
   const { unprocessableExpiryDate } = this;
-  return unprocessableExpiryDate == null || (unprocessableExpiryDate != null && new Date() > unprocessableExpiryDate);
+  return (
+    unprocessableExpiryDate == null ||
+    (unprocessableExpiryDate != null && new Date() > unprocessableExpiryDate)
+  );
 };
 
-export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);
+export default getOrCreateModel<PageOperationDocument, PageOperationModel>(
+  'PageOperation',
+  schema,
+);

+ 45 - 30
apps/app/src/server/models/page-redirect.ts

@@ -1,47 +1,54 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import type { Model, Document } from 'mongoose';
+import type { Document, Model } from 'mongoose';
 import { Schema } from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 const logger = loggerFactory('growi:models:page-redirects');
 
-
 export type IPageRedirect = {
-  fromPath: string,
-  toPath: string,
-}
+  fromPath: string;
+  toPath: string;
+};
 
 export type IPageRedirectEndpoints = {
-  start: IPageRedirect,
-  end: IPageRedirect,
-}
+  start: IPageRedirect;
+  end: IPageRedirect;
+};
 
 export interface PageRedirectDocument extends IPageRedirect, Document {}
 
 export interface PageRedirectModel extends Model<PageRedirectDocument> {
-  retrievePageRedirectEndpoints(fromPath: string): Promise<IPageRedirectEndpoints>
-  removePageRedirectsByToPath(toPath: string): Promise<void>
+  retrievePageRedirectEndpoints(
+    fromPath: string,
+  ): Promise<IPageRedirectEndpoints>;
+  removePageRedirectsByToPath(toPath: string): Promise<void>;
 }
 
 const CHAINS_FIELD_NAME = 'chains';
 const DEPTH_FIELD_NAME = 'depth';
 type IPageRedirectWithChains = PageRedirectDocument & {
-  [CHAINS_FIELD_NAME]: (PageRedirectDocument & { [DEPTH_FIELD_NAME]: number })[]
+  [CHAINS_FIELD_NAME]: (PageRedirectDocument & {
+    [DEPTH_FIELD_NAME]: number;
+  })[];
 };
 
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
-    type: String, required: true, unique: true, index: true,
+    type: String,
+    required: true,
+    unique: true,
+    index: true,
   },
   toPath: { type: String, required: true },
 });
 
-schema.statics.retrievePageRedirectEndpoints = async function(fromPath: string): Promise<IPageRedirectEndpoints|null> {
+schema.statics.retrievePageRedirectEndpoints = async function (
+  fromPath: string,
+): Promise<IPageRedirectEndpoints | null> {
   const aggResult: IPageRedirectWithChains[] = await this.aggregate([
     { $match: { fromPath } },
     {
@@ -82,23 +89,30 @@ schema.statics.retrievePageRedirectEndpoints = async function(fromPath: string):
   }
 
   if (aggResult.length > 1) {
-    logger.warn(`Although two or more PageRedirect documents starts from '${fromPath}' exists, The first one is used.`);
+    logger.warn(
+      `Although two or more PageRedirect documents starts from '${fromPath}' exists, The first one is used.`,
+    );
   }
 
   const redirectWithChains = aggResult[0];
 
   // sort chains in desc
-  const sortedChains = redirectWithChains[CHAINS_FIELD_NAME].sort((a, b) => b[DEPTH_FIELD_NAME] - a[DEPTH_FIELD_NAME]);
+  const sortedChains = redirectWithChains[CHAINS_FIELD_NAME].sort(
+    (a, b) => b[DEPTH_FIELD_NAME] - a[DEPTH_FIELD_NAME],
+  );
 
-  const start = { fromPath: redirectWithChains.fromPath, toPath: redirectWithChains.toPath };
-  const end = sortedChains.length === 0
-    ? start
-    : sortedChains[0];
+  const start = {
+    fromPath: redirectWithChains.fromPath,
+    toPath: redirectWithChains.toPath,
+  };
+  const end = sortedChains.length === 0 ? start : sortedChains[0];
 
   return { start, end };
 };
 
-schema.statics.removePageRedirectsByToPath = async function(toPath: string): Promise<void> {
+schema.statics.removePageRedirectsByToPath = async function (
+  toPath: string,
+): Promise<void> {
   const aggResult: IPageRedirectWithChains[] = await this.aggregate([
     { $match: { toPath } },
     {
@@ -145,17 +159,18 @@ schema.statics.removePageRedirectsByToPath = async function(toPath: string): Pro
     return;
   }
 
-  const idsToRemove = aggResult
-    .map((redirectWithChains) => {
-      return [
-        redirectWithChains._id,
-        redirectWithChains[CHAINS_FIELD_NAME].map(doc => doc._id),
-      ].flat();
-    })
-    .flat();
+  const idsToRemove = aggResult.flatMap((redirectWithChains) => {
+    return [
+      redirectWithChains._id,
+      redirectWithChains[CHAINS_FIELD_NAME].map((doc) => doc._id),
+    ].flat();
+  });
 
   await this.deleteMany({ _id: { $in: idsToRemove } });
   return;
 };
 
-export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>('PageRedirect', schema);
+export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>(
+  'PageRedirect',
+  schema,
+);

+ 91 - 53
apps/app/src/server/models/page-tag-relation.ts

@@ -1,7 +1,5 @@
 import type { ITag } from '@growi/core';
-import type {
-  Document, Model, ObjectId, Types,
-} from 'mongoose';
+import type { Document, Model, ObjectId, Types } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -10,52 +8,65 @@ import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
 
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
-
 import type { IdToNamesMap } from './tag';
 import Tag from './tag';
 
-
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
 const flatMap = require('array.prototype.flatmap');
 
-
-export interface PageTagRelationDocument extends IPageTagRelation, Document {
-}
+export interface PageTagRelationDocument extends IPageTagRelation, Document {}
 
 type CreateTagListWithCountOpts = {
-  sortOpt?: any,
-  offset?: number,
-  limit?: number,
-}
+  sortOpt?: any;
+  offset?: number;
+  limit?: number;
+};
 type CreateTagListWithCountResult = {
-  data: ITag[],
-  totalCount: number
-}
-type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
-
-type ListTagNamesByPage = (pageId: Types.ObjectId | string) => Promise<PageTagRelationDocument[]>;
-
-type FindByPageId = (pageId: Types.ObjectId | string, options?: { nullable?: boolean }) => Promise<PageTagRelationDocument[]>;
-
-type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNamesMap>;
-
-type UpdatePageTags = (this: PageTagRelationModel, pageId: Types.ObjectId | string, tags: string[]) => Promise<void>
+  data: ITag[];
+  totalCount: number;
+};
+type CreateTagListWithCount = (
+  this: PageTagRelationModel,
+  opts?: CreateTagListWithCountOpts,
+) => Promise<CreateTagListWithCountResult>;
+
+type ListTagNamesByPage = (
+  pageId: Types.ObjectId | string,
+) => Promise<PageTagRelationDocument[]>;
+
+type FindByPageId = (
+  pageId: Types.ObjectId | string,
+  options?: { nullable?: boolean },
+) => Promise<PageTagRelationDocument[]>;
+
+type GetIdToTagNamesMap = (
+  this: PageTagRelationModel,
+  pageIds: string[],
+) => Promise<IdToNamesMap>;
+
+type UpdatePageTags = (
+  this: PageTagRelationModel,
+  pageId: Types.ObjectId | string,
+  tags: string[],
+) => Promise<void>;
 
 export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
-  createTagListWithCount: CreateTagListWithCount
-  findByPageId: FindByPageId
-  listTagNamesByPage: ListTagNamesByPage
-  getIdToTagNamesMap: GetIdToTagNamesMap
-  updatePageTags: UpdatePageTags
+  createTagListWithCount: CreateTagListWithCount;
+  findByPageId: FindByPageId;
+  listTagNamesByPage: ListTagNamesByPage;
+  getIdToTagNamesMap: GetIdToTagNamesMap;
+  updatePageTags: UpdatePageTags;
 }
 
-
 /*
  * define schema
  */
-const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
+const schema = new mongoose.Schema<
+  PageTagRelationDocument,
+  PageTagRelationModel
+>({
   relatedPage: {
     type: mongoose.Schema.Types.ObjectId,
     ref: 'Page',
@@ -80,7 +91,10 @@ schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
-const createTagListWithCount: CreateTagListWithCount = async function(this, opts) {
+const createTagListWithCount: CreateTagListWithCount = async function (
+  this,
+  opts,
+) {
   const sortOpt = opts?.sortOpt || {};
   const offset = opts?.offset ?? 0;
   const limit = opts?.limit;
@@ -94,7 +108,11 @@ const createTagListWithCount: CreateTagListWithCount = async function(this, opts
       as: 'tag',
     })
     .unwind('$tag')
-    .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+    .group({
+      _id: '$relatedTag',
+      count: { $sum: 1 },
+      name: { $first: '$tag.name' },
+    })
     .sort(sortOpt)
     .skip(offset);
 
@@ -102,26 +120,36 @@ const createTagListWithCount: CreateTagListWithCount = async function(this, opts
     query = query.limit(limit);
   }
 
-  const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+  const totalCount = (
+    await this.find({ isPageTrashed: false }).distinct('relatedTag')
+  ).length;
 
   return { data: await query.exec(), totalCount };
 };
 schema.statics.createTagListWithCount = createTagListWithCount;
 
-const findByPageId: FindByPageId = async function(pageId, options = {}) {
+const findByPageId: FindByPageId = async function (pageId, options = {}) {
   const isAcceptRelatedTagNull = options.nullable || null;
-  const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
-  return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
+  const relations = await this.find({ relatedPage: pageId })
+    .populate('relatedTag')
+    .select('relatedTag');
+  return isAcceptRelatedTagNull
+    ? relations
+    : relations.filter((relation) => {
+        return relation.relatedTag !== null;
+      });
 };
 schema.statics.findByPageId = findByPageId;
 
-const listTagNamesByPage: ListTagNamesByPage = async function(pageId) {
+const listTagNamesByPage: ListTagNamesByPage = async function (pageId) {
   const relations = await this.findByPageId(pageId);
-  return relations.map((relation) => { return relation.relatedTag.name });
+  return relations.map((relation) => {
+    return relation.relatedTag.name;
+  });
 };
 schema.statics.listTagNamesByPage = listTagNamesByPage;
 
-const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
+const getIdToTagNamesMap: GetIdToTagNamesMap = async function (this, pageIds) {
   /**
    * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
    *
@@ -132,7 +160,10 @@ const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
    *   ...
    * ]
    */
-  const results = await this.aggregate<{ _id: ObjectId, tagIds: ObjectIdLike[] }>()
+  const results = await this.aggregate<{
+    _id: ObjectId;
+    tagIds: ObjectIdLike[];
+  }>()
     .match({ relatedPage: { $in: pageIds } })
     .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
 
@@ -143,8 +174,7 @@ const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
   results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
 
   // extract distinct tag ids
-  const allTagIds = results
-    .flatMap(result => result.tagIds); // map + flatten
+  const allTagIds = results.flatMap((result) => result.tagIds); // map + flatten
   const distinctTagIds = Array.from(new Set(allTagIds));
 
   // TODO: set IdToNameMap type by 93933
@@ -154,8 +184,8 @@ const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
   const idToTagNamesMap = {};
   results.forEach((result) => {
     const tagNames = result.tagIds
-      .map(tagId => tagIdToNameMap[tagId.toString()])
-      .filter(tagName => tagName != null); // filter null object
+      .map((tagId) => tagIdToNameMap[tagId.toString()])
+      .filter((tagName) => tagName != null); // filter null object
 
     idToTagNamesMap[result._id.toString()] = tagNames;
   });
@@ -164,14 +194,16 @@ const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
 };
 schema.statics.getIdToTagNamesMap = getIdToTagNamesMap;
 
-const updatePageTags: UpdatePageTags = async function(pageId, tags) {
+const updatePageTags: UpdatePageTags = async function (pageId, tags) {
   if (pageId == null || tags == null) {
-    throw new Error('args \'pageId\' and \'tags\' are required.');
+    throw new Error("args 'pageId' and 'tags' are required.");
   }
 
   // filter empty string
   // eslint-disable-next-line no-param-reassign
-  tags = tags.filter((tag) => { return tag !== '' });
+  tags = tags.filter((tag) => {
+    return tag !== '';
+  });
 
   // get relations for this page
   const relations = await this.findByPageId(pageId, { nullable: true });
@@ -182,17 +214,20 @@ const updatePageTags: UpdatePageTags = async function(pageId, tags) {
   relations.forEach((relation) => {
     if (relation.relatedTag == null) {
       unlinkTagRelationIds.push(relation._id);
-    }
-    else {
+    } else {
       relatedTagNames.push(relation.relatedTag.name);
       if (!tags.includes(relation.relatedTag.name)) {
         unlinkTagRelationIds.push(relation._id);
       }
     }
   });
-  const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
+  const bulkDeletePromise = this.deleteMany({
+    _id: { $in: unlinkTagRelationIds },
+  });
   // find or create tags
-  const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+  const tagsToCreate = tags.filter((tag) => {
+    return !relatedTagNames.includes(tag);
+  });
   const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
 
   // create relations
@@ -209,4 +244,7 @@ const updatePageTags: UpdatePageTags = async function(pageId, tags) {
 };
 schema.statics.updatePageTags = updatePageTags;
 
-export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>('PageTagRelation', schema);
+export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>(
+  'PageTagRelation',
+  schema,
+);

File diff ditekan karena terlalu besar
+ 416 - 295
apps/app/src/server/models/page.ts


+ 41 - 36
apps/app/src/server/models/password-reset-order.ts

@@ -1,63 +1,65 @@
 import crypto from 'crypto';
-
 import { addMinutes } from 'date-fns/addMinutes';
-import type { Model, Document } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
+import type { Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface IPasswordResetOrder {
-  token: string,
-  email: string,
+  token: string;
+  email: string;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  relatedUser: any,
-  isRevoked: boolean,
-  createdAt: Date,
-  expiredAt: Date,
+  relatedUser: any;
+  isRevoked: boolean;
+  createdAt: Date;
+  expiredAt: Date;
 }
 
-export interface PasswordResetOrderDocument extends IPasswordResetOrder, Document {
-  isExpired(): boolean
-  revokeOneTimeToken(): Promise<void>
+export interface PasswordResetOrderDocument
+  extends IPasswordResetOrder,
+    Document {
+  isExpired(): boolean;
+  revokeOneTimeToken(): Promise<void>;
 }
 
-export interface PasswordResetOrderModel extends Model<PasswordResetOrderDocument> {
-  generateOneTimeToken(): string
-  createPasswordResetOrder(email: string): PasswordResetOrderDocument
+export interface PasswordResetOrderModel
+  extends Model<PasswordResetOrderDocument> {
+  generateOneTimeToken(): string;
+  createPasswordResetOrder(email: string): PasswordResetOrderDocument;
 }
 
 const expiredAt = (): Date => {
   return addMinutes(new Date(), 10);
 };
 
-const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
-  token: { type: String, required: true, unique: true },
-  email: { type: String, required: true },
-  relatedUser: { type: Schema.Types.ObjectId, ref: 'User' },
-  isRevoked: { type: Boolean, default: false, required: true },
-  expiredAt: { type: Date, default: expiredAt, required: true },
-}, {
-  timestamps: {
-    createdAt: true,
-    updatedAt: false,
+const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>(
+  {
+    token: { type: String, required: true, unique: true },
+    email: { type: String, required: true },
+    relatedUser: { type: Schema.Types.ObjectId, ref: 'User' },
+    isRevoked: { type: Boolean, default: false, required: true },
+    expiredAt: { type: Date, default: expiredAt, required: true },
+  },
+  {
+    timestamps: {
+      createdAt: true,
+      updatedAt: false,
+    },
   },
-});
+);
 schema.plugin(uniqueValidator);
 
-schema.statics.generateOneTimeToken = function() {
+schema.statics.generateOneTimeToken = () => {
   const buf = crypto.randomBytes(256);
   const token = buf.toString('hex');
 
   return token;
 };
 
-schema.statics.createPasswordResetOrder = async function(email) {
-  let token;
-  let duplicateToken;
+schema.statics.createPasswordResetOrder = async function (email) {
+  let token: string;
+  let duplicateToken: PasswordResetOrderDocument | null = null;
 
   do {
     token = this.generateOneTimeToken();
@@ -70,13 +72,16 @@ schema.statics.createPasswordResetOrder = async function(email) {
   return passwordResetOrderData;
 };
 
-schema.methods.isExpired = function() {
+schema.methods.isExpired = function () {
   return this.expiredAt.getTime() < Date.now();
 };
 
-schema.methods.revokeOneTimeToken = async function() {
+schema.methods.revokeOneTimeToken = async function () {
   this.isRevoked = true;
   return this.save();
 };
 
-export default getOrCreateModel<PasswordResetOrderDocument, PasswordResetOrderModel>('PasswordResetOrder', schema);
+export default getOrCreateModel<
+  PasswordResetOrderDocument,
+  PasswordResetOrderModel
+>('PasswordResetOrder', schema);

+ 59 - 35
apps/app/src/server/models/revision.ts

@@ -5,61 +5,74 @@ import type {
   Origin,
 } from '@growi/core/dist/interfaces';
 import type { Types } from 'mongoose';
-import {
-  Schema, type Document, type Model,
-} from 'mongoose';
+import { type Document, type Model, Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
-
 import type { PageDocument } from './page';
 
 const logger = loggerFactory('growi:models:revision');
 
+export interface IRevisionDocument extends IRevision, Document {}
 
-export interface IRevisionDocument extends IRevision, Document {
-}
-
-type UpdateRevisionListByPageId = (pageId: Types.ObjectId, updateData: Partial<IRevision>) => Promise<void>;
+type UpdateRevisionListByPageId = (
+  pageId: Types.ObjectId,
+  updateData: Partial<IRevision>,
+) => Promise<void>;
 type PrepareRevision = (
-  pageData: PageDocument, body: string, previousBody: string | null, user: HasObjectId, origin?: Origin, options?: { format: string }
+  pageData: PageDocument,
+  body: string,
+  previousBody: string | null,
+  user: HasObjectId,
+  origin?: Origin,
+  options?: { format: string },
 ) => IRevisionDocument;
 
 export interface IRevisionModel extends Model<IRevisionDocument> {
-  updateRevisionListByPageId: UpdateRevisionListByPageId,
-  prepareRevision: PrepareRevision,
+  updateRevisionListByPageId: UpdateRevisionListByPageId;
+  prepareRevision: PrepareRevision;
 }
 
 // Use this to allow empty strings to pass the `required` validator
-Schema.Types.String.checkRequired(v => typeof v === 'string');
+Schema.Types.String.checkRequired((v) => typeof v === 'string');
 
-const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
-  // The type of pageId is always converted to String at server startup
-  // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
-  pageId: {
-    type: Schema.Types.ObjectId, ref: 'Page', required: true, index: true,
-  },
-  body: {
-    type: String,
-    required: true,
-    get: (data) => {
-    // replace CR/CRLF to LF above v3.1.5
-    // see https://github.com/growilabs/growi/issues/463
-      return data ? data.replace(/\r\n?/g, '\n') : '';
+const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>(
+  {
+    // The type of pageId is always converted to String at server startup
+    // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
+    pageId: {
+      type: Schema.Types.ObjectId,
+      ref: 'Page',
+      required: true,
+      index: true,
+    },
+    body: {
+      type: String,
+      required: true,
+      get: (data) => {
+        // replace CR/CRLF to LF above v3.1.5
+        // see https://github.com/growilabs/growi/issues/463
+        return data ? data.replace(/\r\n?/g, '\n') : '';
+      },
     },
+    format: { type: String, default: 'markdown' },
+    author: { type: Schema.Types.ObjectId, ref: 'User' },
+    hasDiffToPrev: { type: Boolean },
+    origin: { type: String, enum: allOrigin },
+  },
+  {
+    timestamps: { createdAt: true, updatedAt: false },
   },
-  format: { type: String, default: 'markdown' },
-  author: { type: Schema.Types.ObjectId, ref: 'User' },
-  hasDiffToPrev: { type: Boolean },
-  origin: { type: String, enum: allOrigin },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+);
 revisionSchema.plugin(mongoosePaginate);
 
-const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(this: IRevisionModel, pageId, updateData) {
+const updateRevisionListByPageId: UpdateRevisionListByPageId = async function (
+  this: IRevisionModel,
+  pageId,
+  updateData,
+) {
   // Check pageId for safety
   if (pageId == null) {
     throw new Error('Error: pageId is required');
@@ -68,7 +81,15 @@ const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(th
 };
 revisionSchema.statics.updateRevisionListByPageId = updateRevisionListByPageId;
 
-const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData, body, previousBody, user, origin, options = { format: 'markdown' }) {
+const prepareRevision: PrepareRevision = function (
+  this: IRevisionModel,
+  pageData,
+  body,
+  previousBody,
+  user,
+  origin,
+  options = { format: 'markdown' },
+) {
   if (user._id == null) {
     throw new Error('user should have _id');
   }
@@ -90,4 +111,7 @@ const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData
 };
 revisionSchema.statics.prepareRevision = prepareRevision;
 
-export const Revision = getOrCreateModel<IRevisionDocument, IRevisionModel>('Revision', revisionSchema);
+export const Revision = getOrCreateModel<IRevisionDocument, IRevisionModel>(
+  'Revision',
+  revisionSchema,
+);

+ 5 - 1
apps/app/src/server/models/serializers/page-serializer.js

@@ -14,7 +14,11 @@ function serializeInsecureUserAttributes(page) {
   if (page.creator != null && page.creator._id != null) {
     page.creator = serializeUserSecurely(page.creator);
   }
-  if (page.revision != null && page.revision.author != null && page.revision.author._id != null) {
+  if (
+    page.revision != null &&
+    page.revision.author != null &&
+    page.revision.author._id != null
+  ) {
     page.revision.author = serializeUserSecurely(page.revision.author);
   }
   return page;

+ 7 - 2
apps/app/src/server/models/serializers/user-group-relation-serializer.js

@@ -1,8 +1,13 @@
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function serializeInsecureUserAttributes(userGroupRelation) {
-  if (userGroupRelation.relatedUser != null && userGroupRelation.relatedUser._id != null) {
-    userGroupRelation.relatedUser = serializeUserSecurely(userGroupRelation.relatedUser);
+  if (
+    userGroupRelation.relatedUser != null &&
+    userGroupRelation.relatedUser._id != null
+  ) {
+    userGroupRelation.relatedUser = serializeUserSecurely(
+      userGroupRelation.relatedUser,
+    );
   }
   return userGroupRelation;
 }

+ 21 - 19
apps/app/src/server/models/share-link.ts

@@ -1,7 +1,5 @@
+import type { Document, Model } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';
-import type {
-  Document, Model,
-} from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
@@ -9,37 +7,41 @@ import type { IShareLink } from '~/interfaces/share-link';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface ShareLinkDocument extends IShareLink, Document {
-  isExpired: () => boolean,
+  isExpired: () => boolean;
 }
 
 export type ShareLinkModel = Model<ShareLinkDocument>;
 
-
 /*
  * define schema
  */
-const schema = new Schema<ShareLinkDocument, ShareLinkModel>({
-  relatedPage: {
-    type: Schema.Types.ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
+const schema = new Schema<ShareLinkDocument, ShareLinkModel>(
+  {
+    relatedPage: {
+      type: Schema.Types.ObjectId,
+      ref: 'Page',
+      required: true,
+      index: true,
+    },
+    expiredAt: { type: Date },
+    description: { type: String },
+  },
+  {
+    timestamps: { createdAt: true, updatedAt: false },
   },
-  expiredAt: { type: Date },
-  description: { type: String },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+);
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
-schema.methods.isExpired = function() {
+schema.methods.isExpired = function () {
   if (this.expiredAt == null) {
     return false;
   }
   return this.expiredAt.getTime() < new Date().getTime();
 };
 
-export default getOrCreateModel<ShareLinkDocument, ShareLinkModel>('ShareLink', schema);
+export default getOrCreateModel<ShareLinkDocument, ShareLinkModel>(
+  'ShareLink',
+  schema,
+);

+ 24 - 14
apps/app/src/server/models/slack-app-integration.js

@@ -1,12 +1,10 @@
-import crypto from 'crypto';
-
 import { defaultSupportedSlackEventActions } from '@growi/slack';
+import crypto from 'crypto';
 import mongoose from 'mongoose';
 
 import { configManager } from '../service/config-manager';
 import { getModelSafely } from '../util/mongoose-utils';
 
-
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
@@ -15,18 +13,23 @@ const schema = new mongoose.Schema({
   permissionsForSingleUseCommands: Map,
   permissionsForSlackEventActions: {
     type: Map,
-    default: new Map(defaultSupportedSlackEventActions.map(action => [action, false])),
+    default: new Map(
+      defaultSupportedSlackEventActions.map((action) => [action, false]),
+    ),
   },
 });
 
 class SlackAppIntegration {
-
   static generateAccessTokens(saltForGtoP, saltForPtoG) {
     const now = new Date().getTime();
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
-    const tokenGtoP = hasher1.update(`gtop-${saltForGtoP}-${now.toString()}`).digest('base64');
-    const tokenPtoG = hasher2.update(`ptog-${saltForPtoG}-${now.toString()}`).digest('base64');
+    const tokenGtoP = hasher1
+      .update(`gtop-${saltForGtoP}-${now.toString()}`)
+      .digest('base64');
+    const tokenPtoG = hasher2
+      .update(`ptog-${saltForPtoG}-${now.toString()}`)
+      .digest('base64');
     return [tokenGtoP, tokenPtoG];
   }
 
@@ -37,21 +40,28 @@ class SlackAppIntegration {
     let generateTokens;
 
     // get salt strings
-    const saltForGtoP = configManager.getConfig('slackbot:withProxy:saltForGtoP');
-    const saltForPtoG = configManager.getConfig('slackbot:withProxy:saltForPtoG');
+    const saltForGtoP = configManager.getConfig(
+      'slackbot:withProxy:saltForGtoP',
+    );
+    const saltForPtoG = configManager.getConfig(
+      'slackbot:withProxy:saltForPtoG',
+    );
 
     do {
-      generateTokens = this.generateAccessTokens(saltForGtoP, saltForPtoG);
+      generateTokens = SlackAppIntegration.generateAccessTokens(
+        saltForGtoP,
+        saltForPtoG,
+      );
       tokenGtoP = generateTokens[0];
       tokenPtoG = generateTokens[1];
-      // eslint-disable-next-line no-await-in-loop
-      duplicateTokens = await this.findOne({ $or: [{ tokenGtoP }, { tokenPtoG }] });
+      // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+      duplicateTokens = await this.findOne({
+        $or: [{ tokenGtoP }, { tokenPtoG }],
+      });
     } while (duplicateTokens != null);
 
-
     return { tokenGtoP, tokenPtoG };
   }
-
 }
 
 const factory = (crowi) => {

+ 103 - 56
apps/app/src/server/models/subscription.ts

@@ -1,95 +1,142 @@
-import type {
-  Ref, IPage, IUser, ISubscription,
-} from '@growi/core';
-import {
-  SubscriptionStatusType, AllSubscriptionStatusType,
-} from '@growi/core';
-import {
-  type Types, type Document, type Model, Schema,
-} from 'mongoose';
+import type { IPage, ISubscription, IUser, Ref } from '@growi/core';
+import { AllSubscriptionStatusType, SubscriptionStatusType } from '@growi/core';
+import { type Document, type Model, Schema, type Types } from 'mongoose';
 
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { SupportedTargetModelType } from '~/interfaces/activity';
-import { AllSupportedTargetModels, SupportedTargetModel } from '~/interfaces/activity';
+import {
+  AllSupportedTargetModels,
+  SupportedTargetModel,
+} from '~/interfaces/activity';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
-  findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
-  upsertSubscription(user: Ref<IUser>, targetModel: SupportedTargetModelType, target: Ref<IPage> | Ref<IUser> | Ref<IPageBulkExportJob>, status: string): any
-  subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
-  getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
-  getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
-  getSubscriptions(targets: Ref<IPage>[]): Promise<Ref<IUser>[]>
+  findByUserIdAndTargetId(
+    userId: Types.ObjectId | string,
+    targetId: Types.ObjectId | string,
+  ): any;
+  upsertSubscription(
+    user: Ref<IUser>,
+    targetModel: SupportedTargetModelType,
+    target: Ref<IPage> | Ref<IUser> | Ref<IPageBulkExportJob>,
+    status: string,
+  ): any;
+  subscribeByPageId(
+    userId: Types.ObjectId,
+    pageId: Types.ObjectId,
+    status: string,
+  ): any;
+  getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>;
+  getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>;
+  getSubscriptions(targets: Ref<IPage>[]): Promise<Ref<IUser>[]>;
 }
 
-const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
-  user: {
-    type: Schema.Types.ObjectId,
-    ref: 'User',
-    index: true,
-    required: true,
-  },
-  targetModel: {
-    type: String,
-    required: true,
-    enum: AllSupportedTargetModels,
-  },
-  target: {
-    type: Schema.Types.ObjectId,
-    ref: 'Page',
-    refPath: 'targetModel',
-    required: true,
+const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>(
+  {
+    user: {
+      type: Schema.Types.ObjectId,
+      ref: 'User',
+      index: true,
+      required: true,
+    },
+    targetModel: {
+      type: String,
+      required: true,
+      enum: AllSupportedTargetModels,
+    },
+    target: {
+      type: Schema.Types.ObjectId,
+      ref: 'Page',
+      refPath: 'targetModel',
+      required: true,
+    },
+    status: {
+      type: String,
+      required: true,
+      enum: AllSubscriptionStatusType,
+    },
   },
-  status: {
-    type: String,
-    required: true,
-    enum: AllSubscriptionStatusType,
+  {
+    timestamps: true,
   },
-}, {
-  timestamps: true,
-});
+);
 
-subscriptionSchema.methods.isSubscribing = function() {
+subscriptionSchema.methods.isSubscribing = function () {
   return this.status === SubscriptionStatusType.SUBSCRIBE;
 };
 
-subscriptionSchema.methods.isUnsubscribing = function() {
+subscriptionSchema.methods.isUnsubscribing = function () {
   return this.status === SubscriptionStatusType.UNSUBSCRIBE;
 };
 
-subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
+subscriptionSchema.statics.findByUserIdAndTargetId = function (
+  userId,
+  targetId,
+) {
   return this.findOne({ user: userId, target: targetId });
 };
 
-subscriptionSchema.statics.upsertSubscription = function(
-    user: Ref<IUser>, targetModel: SupportedTargetModelType, target: Ref<IPage>, status: SubscriptionStatusType,
+subscriptionSchema.statics.upsertSubscription = function (
+  user: Ref<IUser>,
+  targetModel: SupportedTargetModelType,
+  target: Ref<IPage>,
+  status: SubscriptionStatusType,
 ) {
   const query = { user, targetModel, target };
   const doc = { ...query, status };
   const options = {
-    upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true,
+    upsert: true,
+    new: true,
+    setDefaultsOnInsert: true,
+    runValidators: true,
   };
   return this.findOneAndUpdate(query, doc, options);
 };
 
-subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
-  return this.upsertSubscription(userId, SupportedTargetModel.MODEL_PAGE, pageId, status);
+subscriptionSchema.statics.subscribeByPageId = function (
+  userId,
+  pageId,
+  status,
+) {
+  return this.upsertSubscription(
+    userId,
+    SupportedTargetModel.MODEL_PAGE,
+    pageId,
+    status,
+  );
 };
 
-subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {
-  return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
+subscriptionSchema.statics.getSubscription = async function (
+  target: Ref<IPage>,
+) {
+  return this.find({
+    target,
+    status: SubscriptionStatusType.SUBSCRIBE,
+  }).distinct('user');
 };
 
-subscriptionSchema.statics.getUnsubscription = async function(target: Ref<IPage>) {
-  return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
+subscriptionSchema.statics.getUnsubscription = async function (
+  target: Ref<IPage>,
+) {
+  return this.find({
+    target,
+    status: SubscriptionStatusType.UNSUBSCRIBE,
+  }).distinct('user');
 };
 
-subscriptionSchema.statics.getSubscriptions = async function(targets: Ref<IPage>[]) {
-  return this.find({ target: { $in: targets }, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
+subscriptionSchema.statics.getSubscriptions = async function (
+  targets: Ref<IPage>[],
+) {
+  return this.find({
+    target: { $in: targets },
+    status: SubscriptionStatusType.SUBSCRIBE,
+  }).distinct('user');
 };
 
-export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);
+export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>(
+  'Subscription',
+  subscriptionSchema,
+);

+ 18 - 14
apps/app/src/server/models/tag.ts

@@ -1,4 +1,4 @@
-import type { Types, Model } from 'mongoose';
+import type { Model, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -7,21 +7,19 @@ import { getOrCreateModel } from '../util/mongoose-utils';
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-
 export interface TagDocument {
   _id: Types.ObjectId;
   name: string;
 }
 
-export type IdToNameMap = {[key: string] : string }
-export type IdToNamesMap = {[key: string] : string[] }
+export type IdToNameMap = { [key: string]: string };
+export type IdToNamesMap = { [key: string]: string[] };
 
-export interface TagModel extends Model<TagDocument>{
-  getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap
-  findOrCreateMany(tagNames: string[]): Promise<TagDocument[]>
+export interface TagModel extends Model<TagDocument> {
+  getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap;
+  findOrCreateMany(tagNames: string[]): Promise<TagDocument[]>;
 }
 
-
 const tagSchema = new Schema<TagDocument, TagModel>({
   name: {
     type: String,
@@ -32,8 +30,9 @@ const tagSchema = new Schema<TagDocument, TagModel>({
 tagSchema.plugin(mongoosePaginate);
 tagSchema.plugin(uniqueValidator);
 
-
-tagSchema.statics.getIdToNameMap = async function(tagIds: ObjectIdLike[]): Promise<IdToNameMap> {
+tagSchema.statics.getIdToNameMap = async function (
+  tagIds: ObjectIdLike[],
+): Promise<IdToNameMap> {
   const tags = await this.find({ _id: { $in: tagIds } });
 
   const idToNameMap = {};
@@ -44,12 +43,18 @@ tagSchema.statics.getIdToNameMap = async function(tagIds: ObjectIdLike[]): Promi
   return idToNameMap;
 };
 
-tagSchema.statics.findOrCreateMany = async function(tagNames: string[]): Promise<TagDocument[]> {
+tagSchema.statics.findOrCreateMany = async function (
+  tagNames: string[],
+): Promise<TagDocument[]> {
   const existTags = await this.find({ name: { $in: tagNames } });
-  const existTagNames = existTags.map((tag) => { return tag.name });
+  const existTagNames = existTags.map((tag) => {
+    return tag.name;
+  });
 
   // bulk insert
-  const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
+  const tagsToCreate = tagNames.filter((tagName) => {
+    return !existTagNames.includes(tagName);
+  });
   await this.insertMany(
     tagsToCreate.map((tag) => {
       return { name: tag };
@@ -59,5 +64,4 @@ tagSchema.statics.findOrCreateMany = async function(tagNames: string[]): Promise
   return this.find({ name: { $in: tagNames } });
 };
 
-
 export default getOrCreateModel<TagDocument, TagModel>('Tag', tagSchema);

+ 19 - 12
apps/app/src/server/models/transfer-key.ts

@@ -1,4 +1,4 @@
-import type { Model, HydratedDocument } from 'mongoose';
+import type { HydratedDocument, Model } from 'mongoose';
 import { Schema } from 'mongoose';
 
 import type { ITransferKey } from '~/interfaces/transfer-key';
@@ -6,24 +6,31 @@ import type { ITransferKey } from '~/interfaces/transfer-key';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 interface ITransferKeyMethods {
-  findOneActiveTransferKey(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null>;
+  findOneActiveTransferKey(
+    key: string,
+  ): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null>;
 }
 
 type TransferKeyModel = Model<ITransferKey, any, ITransferKeyMethods>;
 
-const schema = new Schema<ITransferKey, TransferKeyModel, ITransferKeyMethods>({
-  expireAt: { type: Date, default: () => new Date(), expires: '30m' },
-  keyString: { type: String, unique: true }, // original key string
-  key: { type: String, unique: true },
-}, {
-  timestamps: {
-    createdAt: true,
-    updatedAt: false,
+const schema = new Schema<ITransferKey, TransferKeyModel, ITransferKeyMethods>(
+  {
+    expireAt: { type: Date, default: () => new Date(), expires: '30m' },
+    keyString: { type: String, unique: true }, // original key string
+    key: { type: String, unique: true },
   },
-});
+  {
+    timestamps: {
+      createdAt: true,
+      updatedAt: false,
+    },
+  },
+);
 
 // TODO: validate createdAt
-schema.statics.findOneActiveTransferKey = async function(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null> {
+schema.statics.findOneActiveTransferKey = async function (
+  key: string,
+): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null> {
   return this.findOne({ key });
 };
 

+ 48 - 35
apps/app/src/server/models/update-post.ts

@@ -1,50 +1,56 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import type { Types, Model, Document } from 'mongoose';
+import type { Document, Model, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 export interface IUpdatePost {
-  pathPattern: string
-  patternPrefix: string
-  patternPrefix2: string
-  channel: string
-  provider: string
-  creator: Types.ObjectId
-  createdAt: Date
+  pathPattern: string;
+  patternPrefix: string;
+  patternPrefix2: string;
+  channel: string;
+  provider: string;
+  creator: Types.ObjectId;
+  createdAt: Date;
 }
 
 export interface UpdatePostDocument extends IUpdatePost, Document {}
 
 export interface UpdatePostModel extends Model<UpdatePostDocument> {
-  normalizeChannelName(channel): any
-  createPrefixesByPathPattern(pathPattern): any
-  getRegExpByPattern(pattern): any
-  findSettingsByPath(path): Promise<UpdatePostDocument[]>
-  findAll(offset?: number): Promise<UpdatePostDocument[]>
-  createUpdatePost(pathPattern: string, channel: string, creator: Types.ObjectId): Promise<UpdatePostDocument>
+  normalizeChannelName(channel): any;
+  createPrefixesByPathPattern(pathPattern): any;
+  getRegExpByPattern(pattern): any;
+  findSettingsByPath(path): Promise<UpdatePostDocument[]>;
+  findAll(offset?: number): Promise<UpdatePostDocument[]>;
+  createUpdatePost(
+    pathPattern: string,
+    channel: string,
+    creator: Types.ObjectId,
+  ): Promise<UpdatePostDocument>;
 }
 
 /**
  * This is the setting for notify to 3rd party tool (like Slack).
  */
-const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
-  pathPattern: { type: String, required: true },
-  patternPrefix: { type: String, required: true },
-  patternPrefix2: { type: String, required: true },
-  channel: { type: String, required: true },
-  provider: { type: String, required: true },
-  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-}, {
-  timestamps: true,
-});
-
-updatePostSchema.statics.normalizeChannelName = function(channel) {
-  return channel.replace(/(#|,)/g, '');
-};
-
-updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
+const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>(
+  {
+    pathPattern: { type: String, required: true },
+    patternPrefix: { type: String, required: true },
+    patternPrefix2: { type: String, required: true },
+    channel: { type: String, required: true },
+    provider: { type: String, required: true },
+    creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  },
+  {
+    timestamps: true,
+  },
+);
+
+updatePostSchema.statics.normalizeChannelName = (channel) =>
+  channel.replace(/(#|,)/g, '');
+
+updatePostSchema.statics.createPrefixesByPathPattern = (pathPattern) => {
   const patternPrefix = ['*', '*'];
 
   // not begin with slash
@@ -64,7 +70,7 @@ updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
   return patternPrefix;
 };
 
-updatePostSchema.statics.getRegExpByPattern = function(pattern) {
+updatePostSchema.statics.getRegExpByPattern = (pattern) => {
   let reg = pattern;
   if (!reg.match(/^\/.*/)) {
     reg = `/*${reg}*`;
@@ -76,7 +82,7 @@ updatePostSchema.statics.getRegExpByPattern = function(pattern) {
   return new RegExp(reg);
 };
 
-updatePostSchema.statics.findSettingsByPath = async function(path) {
+updatePostSchema.statics.findSettingsByPath = async function (path) {
   const prefixes = this.createPrefixesByPathPattern(path);
 
   const settings = await this.find({
@@ -100,11 +106,15 @@ updatePostSchema.statics.findSettingsByPath = async function(path) {
 };
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-updatePostSchema.statics.findAll = function(offset = 0) {
+updatePostSchema.statics.findAll = function (offset = 0) {
   return this.find().sort({ createdAt: 1 }).populate('creator').exec();
 };
 
-updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel, creator) {
+updatePostSchema.statics.createUpdatePost = async function (
+  pathPattern,
+  channel,
+  creator,
+) {
   const provider = 'slack'; // now slack only
 
   const prefixes = this.createPrefixesByPathPattern(pathPattern);
@@ -119,4 +129,7 @@ updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel,
   });
 };
 
-export default getOrCreateModel<UpdatePostDocument, UpdatePostModel>('UpdatePost', updatePostSchema);
+export default getOrCreateModel<UpdatePostDocument, UpdatePostModel>(
+  'UpdatePost',
+  updatePostSchema,
+);

+ 135 - 100
apps/app/src/server/models/user-group-relation.ts

@@ -1,8 +1,6 @@
-import {
-  getIdForRef, isPopulated,
-} from '@growi/core';
+import { getIdForRef, isPopulated } from '@growi/core';
 import type { IUserGroupRelation } from '@growi/core/dist/interfaces';
-import type { Model, Document } from 'mongoose';
+import type { Document, Model } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -11,48 +9,64 @@ import loggerFactory from '~/utils/logger';
 
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
-
 import type { UserGroupDocument } from './user-group';
 
 const logger = loggerFactory('growi:models:userGroupRelation');
 
+export interface UserGroupRelationDocument
+  extends IUserGroupRelation,
+    Document {}
 
-export interface UserGroupRelationDocument extends IUserGroupRelation, Document {}
-
-export interface UserGroupRelationModel extends Model<UserGroupRelationDocument> {
-  [x:string]: any, // for old methods
+export interface UserGroupRelationModel
+  extends Model<UserGroupRelationDocument> {
+  [x: string]: any; // for old methods
 
-  PAGE_ITEMS: 50,
+  PAGE_ITEMS: 50;
 
-  removeAllByUserGroups: (groupsToDelete: UserGroupDocument[]) => Promise<any>,
+  removeAllByUserGroups: (groupsToDelete: UserGroupDocument[]) => Promise<any>;
 
-  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+  findAllUserIdsForUserGroups: (
+    userGroupIds: ObjectIdLike[],
+  ) => Promise<string[]>;
 
-  findGroupsWithDescendantsByGroupAndUser: (group: UserGroupDocument, user) => Promise<UserGroupDocument[]>,
+  findGroupsWithDescendantsByGroupAndUser: (
+    group: UserGroupDocument,
+    user,
+  ) => Promise<UserGroupDocument[]>;
 
-  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
+  countByGroupIdsAndUser: (
+    userGroupIds: ObjectIdLike[],
+    userData,
+  ) => Promise<number>;
 
-  findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>
+  findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>;
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>;
 }
 
 /*
  * define schema
  */
-const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>({
-  relatedGroup: { type: Schema.Types.ObjectId, ref: 'UserGroup', required: true },
-  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>(
+  {
+    relatedGroup: {
+      type: Schema.Types.ObjectId,
+      ref: 'UserGroup',
+      required: true,
+    },
+    relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+  },
+  {
+    timestamps: { createdAt: true, updatedAt: false },
+  },
+);
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
 /**
  * remove all invalid relations that has reference to unlinked document
  */
-schema.statics.removeAllInvalidRelations = function() {
+schema.statics.removeAllInvalidRelations = function () {
   return this.findAllRelation()
     .then((relations) => {
       // filter invalid documents
@@ -61,24 +75,22 @@ schema.statics.removeAllInvalidRelations = function() {
       });
     })
     .then((invalidRelations) => {
-      const ids = invalidRelations.map((relation) => { return relation._id });
+      const ids = invalidRelations.map((relation) => {
+        return relation._id;
+      });
       return this.deleteMany({ _id: { $in: ids } });
     });
 };
 
 /**
-   * find all user and group relation
-   *
-   * @static
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-schema.statics.findAllRelation = function() {
-  return this
-    .find()
-    .populate('relatedUser')
-    .populate('relatedGroup')
-    .exec();
+ * find all user and group relation
+ *
+ * @static
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelation = function () {
+  return this.find().populate('relatedUser').populate('relatedGroup').exec();
 };
 
 /**
@@ -89,22 +101,20 @@ schema.statics.findAllRelation = function() {
  * @returns {Promise<UserGroupRelation[]>}
  * @memberof UserGroupRelation
  */
-schema.statics.findAllRelationForUserGroup = function(userGroup) {
+schema.statics.findAllRelationForUserGroup = function (userGroup) {
   logger.debug('findAllRelationForUserGroup is called', userGroup);
-  return this
-    .find({ relatedGroup: userGroup })
-    .populate('relatedUser')
-    .exec();
+  return this.find({ relatedGroup: userGroup }).populate('relatedUser').exec();
 };
 
-schema.statics.findAllUserIdsForUserGroups = async function(userGroupIds: ObjectIdLike[]): Promise<string[]> {
-  const relations = await this
-    .find({ relatedGroup: { $in: userGroupIds } })
+schema.statics.findAllUserIdsForUserGroups = async function (
+  userGroupIds: ObjectIdLike[],
+): Promise<string[]> {
+  const relations = await this.find({ relatedGroup: { $in: userGroupIds } })
     .select('relatedUser')
     .exec();
 
   // return unique ids
-  return [...new Set(relations.map(r => r.relatedUser.toString()))];
+  return [...new Set(relations.map((r) => r.relatedUser.toString()))];
 };
 
 /**
@@ -115,9 +125,8 @@ schema.statics.findAllUserIdsForUserGroups = async function(userGroupIds: Object
  * @returns {Promise<UserGroupRelation[]>}
  * @memberof UserGroupRelation
  */
-schema.statics.findAllRelationForUserGroups = function(userGroups) {
-  return this
-    .find({ relatedGroup: { $in: userGroups } })
+schema.statics.findAllRelationForUserGroups = function (userGroups) {
+  return this.find({ relatedGroup: { $in: userGroups } })
     .populate('relatedUser')
     .exec();
 };
@@ -130,12 +139,20 @@ schema.statics.findAllRelationForUserGroups = function(userGroups) {
  * @returns {Promise<UserGroupDocument[]>}
  * @memberof UserGroupRelation
  */
-schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDocument[]> {
-  const userGroupRelations = await this.find({ relatedUser: user._id }).populate('relatedGroup');
+schema.statics.findAllGroupsForUser = async function (
+  user,
+): Promise<UserGroupDocument[]> {
+  const userGroupRelations = await this.find({
+    relatedUser: user._id,
+  }).populate('relatedGroup');
   const userGroups = userGroupRelations.map((relation) => {
-    return isPopulated(relation.relatedGroup) ? relation.relatedGroup as unknown as UserGroupDocument : null;
+    return isPopulated(relation.relatedGroup)
+      ? (relation.relatedGroup as unknown as UserGroupDocument)
+      : null;
   });
-  return userGroups.filter((group): group is NonNullable<UserGroupDocument> => group != null);
+  return userGroups.filter(
+    (group): group is NonNullable<UserGroupDocument> => group != null,
+  );
 };
 
 /**
@@ -145,12 +162,16 @@ schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDoc
  * @param {User} user
  * @returns {Promise<ObjectId[]>}
  */
-schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<ObjectIdLike[]> {
+schema.statics.findAllUserGroupIdsRelatedToUser = async function (
+  user,
+): Promise<ObjectIdLike[]> {
   const relations = await this.find({ relatedUser: user._id })
     .select('relatedGroup')
     .exec();
 
-  return relations.map((relation) => { return getIdForRef(relation.relatedGroup) });
+  return relations.map((relation) => {
+    return getIdForRef(relation.relatedGroup);
+  });
 };
 
 /**
@@ -161,7 +182,10 @@ schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<
  * @param {User} userData find query param for relatedUser
  * @returns {Promise<number>}
  */
-schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLike[], userData): Promise<number> {
+schema.statics.countByGroupIdsAndUser = async function (
+  userGroupIds: ObjectIdLike[],
+  userData,
+): Promise<number> {
   const query = {
     relatedGroup: { $in: userGroupIds },
     relatedUser: userData._id,
@@ -178,7 +202,7 @@ schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLik
  * @returns {Promise<User>}
  * @memberof UserGroupRelation
  */
-schema.statics.findUserByNotRelatedGroup = function(userGroup, queryOptions) {
+schema.statics.findUserByNotRelatedGroup = function (userGroup, queryOptions) {
   const User = mongoose.model('User') as any;
   let searchWord = new RegExp(`${queryOptions.searchWord}`);
   switch (queryOptions.searchType) {
@@ -189,26 +213,27 @@ schema.statics.findUserByNotRelatedGroup = function(userGroup, queryOptions) {
       searchWord = new RegExp(`${queryOptions.searchWord}$`);
       break;
   }
-  const searthField: Record<string, RegExp>[] = [
-    { username: searchWord },
-  ];
-  if (queryOptions.isAlsoMailSearched === 'true') { searthField.push({ email: searchWord }) }
-  if (queryOptions.isAlsoNameSearched === 'true') { searthField.push({ name: searchWord }) }
+  const searthField: Record<string, RegExp>[] = [{ username: searchWord }];
+  if (queryOptions.isAlsoMailSearched === 'true') {
+    searthField.push({ email: searchWord });
+  }
+  if (queryOptions.isAlsoNameSearched === 'true') {
+    searthField.push({ name: searchWord });
+  }
 
-  return this.findAllRelationForUserGroup(userGroup)
-    .then((relations) => {
-      const relatedUserIds = relations.map((relation) => {
-        return relation.relatedUser.id;
-      });
-      const query = {
-        _id: { $nin: relatedUserIds },
-        status: User.STATUS_ACTIVE,
-        $or: searthField,
-      };
-
-      logger.debug('findUserByNotRelatedGroup ', query);
-      return User.find(query).exec();
+  return this.findAllRelationForUserGroup(userGroup).then((relations) => {
+    const relatedUserIds = relations.map((relation) => {
+      return relation.relatedUser.id;
     });
+    const query = {
+      _id: { $nin: relatedUserIds },
+      status: User.STATUS_ACTIVE,
+      $or: searthField,
+    };
+
+    logger.debug('findUserByNotRelatedGroup ', query);
+    return User.find(query).exec();
+  });
 };
 
 /**
@@ -220,18 +245,17 @@ schema.statics.findUserByNotRelatedGroup = function(userGroup, queryOptions) {
  * @returns {Promise<boolean>} is user related for group(or not)
  * @memberof UserGroupRelation
  */
-schema.statics.isRelatedUserForGroup = function(userGroup, user) {
+schema.statics.isRelatedUserForGroup = function (userGroup, user) {
   const query = {
     relatedGroup: userGroup.id,
     relatedUser: user.id,
   };
 
-  return this
-    .count(query)
+  return this.count(query)
     .exec()
     .then((count) => {
       // return true or false of the relation is exists(not count)
-      return (count > 0);
+      return count > 0;
     });
 };
 
@@ -244,14 +268,14 @@ schema.statics.isRelatedUserForGroup = function(userGroup, user) {
  * @returns {Promise<UserGroupRelation>} created relation
  * @memberof UserGroupRelation
  */
-schema.statics.createRelation = function(userGroup, user) {
+schema.statics.createRelation = function (userGroup, user) {
   return this.create({
     relatedGroup: userGroup.id,
     relatedUser: user.id,
   });
 };
 
-schema.statics.createRelations = async function(userGroupIds, user) {
+schema.statics.createRelations = async function (userGroupIds, user) {
   const documentsToInsertMany = userGroupIds.map((groupId) => {
     return {
       relatedGroup: groupId,
@@ -271,7 +295,9 @@ schema.statics.createRelations = async function(userGroupIds, user) {
  * @returns {Promise<any>}
  * @memberof UserGroupRelation
  */
-schema.statics.removeAllByUserGroups = function(groupsToDelete: UserGroupDocument[]) {
+schema.statics.removeAllByUserGroups = function (
+  groupsToDelete: UserGroupDocument[],
+) {
   return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
 };
 
@@ -283,25 +309,28 @@ schema.statics.removeAllByUserGroups = function(groupsToDelete: UserGroupDocumen
  * @returns {Promise<any>}
  * @memberof UserGroupRelation
  */
-schema.statics.removeById = function(id) {
-  return this.findById(id)
-    .then((relationData) => {
-      if (relationData == null) {
-        throw new Error('UserGroupRelation data is not exists. id:', id);
-      }
-      else {
-        relationData.remove();
-      }
-    });
+schema.statics.removeById = function (id) {
+  return this.findById(id).then((relationData) => {
+    if (relationData == null) {
+      throw new Error('UserGroupRelation data is not exists. id:', id);
+    } else {
+      relationData.remove();
+    }
+  });
 };
 
-schema.statics.findUserIdsByGroupId = async function(groupId) {
-  const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
+schema.statics.findUserIdsByGroupId = async function (groupId) {
+  const relations = await this.find(
+    { relatedGroup: groupId },
+    { _id: 0, relatedUser: 1 },
+  )
+    .lean()
+    .exec(); // .lean() to get not ObjectId but string
 
-  return relations.map(relation => relation.relatedUser);
+  return relations.map((relation) => relation.relatedUser);
 };
 
-schema.statics.createByGroupIdsAndUserIds = async function(groupIds, userIds) {
+schema.statics.createByGroupIdsAndUserIds = async function (groupIds, userIds) {
   const insertOperations: any[] = [];
 
   groupIds.forEach((groupId) => {
@@ -327,11 +356,14 @@ schema.statics.createByGroupIdsAndUserIds = async function(groupIds, userIds) {
  * @param {UserDocument} user
  * @returns UserGroupDocument[]
  */
-schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group: UserGroupDocument, user): Promise<UserGroupDocument[]> {
+schema.statics.findGroupsWithDescendantsByGroupAndUser = async function (
+  group: UserGroupDocument,
+  user,
+): Promise<UserGroupDocument[]> {
   const descendantGroups = [group];
 
-  const incrementGroupsRecursively = async(groups, user) => {
-    const groupIds = groups.map(g => g._id);
+  const incrementGroupsRecursively = async (groups, user) => {
+    const groupIds = groups.map((g) => g._id);
 
     const populatedRelations = await this.aggregate([
       {
@@ -359,7 +391,7 @@ schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group: U
       },
     ]);
 
-    const nextGroups = populatedRelations.map(d => d.relatedGroup);
+    const nextGroups = populatedRelations.map((d) => d.relatedGroup);
 
     // End
     const shouldEnd = nextGroups.length === 0;
@@ -378,4 +410,7 @@ schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group: U
   return descendantGroups;
 };
 
-export default getOrCreateModel<UserGroupRelationDocument, UserGroupRelationModel>('UserGroupRelation', schema);
+export default getOrCreateModel<
+  UserGroupRelationDocument,
+  UserGroupRelationModel
+>('UserGroupRelation', schema);

+ 50 - 30
apps/app/src/server/models/user-group.ts

@@ -1,36 +1,41 @@
 import type { IUserGroup } from '@growi/core';
-import type { Model, Document } from 'mongoose';
+import type { Document, Model } from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface UserGroupDocument extends IUserGroup, Document {}
 
 export interface UserGroupModel extends Model<UserGroupDocument> {
-  [x:string]: any, // for old methods
+  [x: string]: any; // for old methods
 
-  PAGE_ITEMS: 10,
+  PAGE_ITEMS: 10;
 
-  findGroupsWithDescendantsRecursively: (groups: UserGroupDocument[], descendants?: UserGroupDocument[]) => Promise<UserGroupDocument[]>,
+  findGroupsWithDescendantsRecursively: (
+    groups: UserGroupDocument[],
+    descendants?: UserGroupDocument[],
+  ) => Promise<UserGroupDocument[]>;
 }
 
 /*
  * define schema
  */
-const schema = new Schema<UserGroupDocument, UserGroupModel>({
-  name: { type: String, required: true, unique: true },
-  parent: { type: Schema.Types.ObjectId, ref: 'UserGroup', index: true },
-  description: { type: String, default: '' },
-}, {
-  timestamps: true,
-});
+const schema = new Schema<UserGroupDocument, UserGroupModel>(
+  {
+    name: { type: String, required: true, unique: true },
+    parent: { type: Schema.Types.ObjectId, ref: 'UserGroup', index: true },
+    description: { type: String, default: '' },
+  },
+  {
+    timestamps: true,
+  },
+);
 schema.plugin(mongoosePaginate);
 
 const PAGE_ITEMS = 10;
 
-schema.statics.findWithPagination = function(opts) {
+schema.statics.findWithPagination = function (opts) {
   const query = { parent: null };
   const options = Object.assign({}, opts);
   if (options.page == null) {
@@ -40,20 +45,23 @@ schema.statics.findWithPagination = function(opts) {
     options.limit = PAGE_ITEMS;
   }
 
-  return this.paginate(query, options)
-    .catch((err) => {
-      // debug('Error on pagination:', err); TODO: add logger
-    });
+  return this.paginate(query, options).catch((err) => {
+    // debug('Error on pagination:', err); TODO: add logger
+  });
 };
 
-
-schema.statics.findChildrenByParentIds = async function(parentIds: string[], includeGrandChildren = false) {
+schema.statics.findChildrenByParentIds = async function (
+  parentIds: string[],
+  includeGrandChildren = false,
+) {
   const childUserGroups = await this.find({ parent: { $in: parentIds } });
 
   let grandChildUserGroups: UserGroupDocument[] | null = null;
   if (includeGrandChildren) {
-    const childUserGroupIds = childUserGroups.map(group => group._id);
-    grandChildUserGroups = await this.find({ parent: { $in: childUserGroupIds } });
+    const childUserGroupIds = childUserGroups.map((group) => group._id);
+    grandChildUserGroups = await this.find({
+      parent: { $in: childUserGroupIds },
+    });
   }
 
   return {
@@ -62,11 +70,11 @@ schema.statics.findChildrenByParentIds = async function(parentIds: string[], inc
   };
 };
 
-schema.statics.countUserGroups = function() {
+schema.statics.countUserGroups = function () {
   return this.estimatedDocumentCount();
 };
 
-schema.statics.createGroup = async function(name, description, parentId) {
+schema.statics.createGroup = async function (name, description, parentId) {
   let parent: UserGroupDocument | null = null;
   if (parentId != null) {
     parent = await this.findOne({ _id: parentId });
@@ -85,7 +93,10 @@ schema.statics.createGroup = async function(name, description, parentId) {
  * @param ancestors UserGroupDocument[]
  * @returns UserGroupDocument[]
  */
-schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
+schema.statics.findGroupsWithAncestorsRecursively = async function (
+  group,
+  ancestors = [group],
+) {
   if (group == null) {
     return ancestors;
   }
@@ -108,19 +119,25 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
  * @param descendants UserGroupDocument[]
  * @returns UserGroupDocument[]
  */
-schema.statics.findGroupsWithDescendantsRecursively = async function(
-    groups: UserGroupDocument[], descendants: UserGroupDocument[] = groups,
+schema.statics.findGroupsWithDescendantsRecursively = async function (
+  groups: UserGroupDocument[],
+  descendants: UserGroupDocument[] = groups,
 ): Promise<UserGroupDocument[]> {
-  const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
+  const nextGroups = await this.find({
+    parent: { $in: groups.map((g) => g._id) },
+  });
 
   if (nextGroups.length === 0) {
     return descendants;
   }
 
-  return this.findGroupsWithDescendantsRecursively(nextGroups, descendants.concat(nextGroups));
+  return this.findGroupsWithDescendantsRecursively(
+    nextGroups,
+    descendants.concat(nextGroups),
+  );
 };
 
-schema.statics.findGroupsWithDescendantsById = async function(groupId) {
+schema.statics.findGroupsWithDescendantsById = async function (groupId) {
   const root = await this.findOne({ _id: groupId });
   if (root == null) {
     throw Error('The root user group does not exist');
@@ -128,4 +145,7 @@ schema.statics.findGroupsWithDescendantsById = async function(groupId) {
   return this.findGroupsWithDescendantsRecursively([root]);
 };
 
-export default getOrCreateModel<UserGroupDocument, UserGroupModel>('UserGroup', schema);
+export default getOrCreateModel<UserGroupDocument, UserGroupModel>(
+  'UserGroup',
+  schema,
+);

+ 40 - 32
apps/app/src/server/models/user-registration-order.ts

@@ -1,57 +1,62 @@
 import crypto from 'crypto';
-
 import { addHours } from 'date-fns/addHours';
-import type { Model, Document } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
+import type { Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface IUserRegistrationOrder {
-  token: string,
-  email: string,
-  isRevoked: boolean,
-  createdAt: Date,
-  expiredAt: Date,
+  token: string;
+  email: string;
+  isRevoked: boolean;
+  createdAt: Date;
+  expiredAt: Date;
 }
 
-export interface UserRegistrationOrderDocument extends IUserRegistrationOrder, Document {
-  isExpired(): boolean
-  revokeOneTimeToken(): Promise<void>
+export interface UserRegistrationOrderDocument
+  extends IUserRegistrationOrder,
+    Document {
+  isExpired(): boolean;
+  revokeOneTimeToken(): Promise<void>;
 }
 
-export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderDocument> {
-  generateOneTimeToken(): string
-  createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
+export interface UserRegistrationOrderModel
+  extends Model<UserRegistrationOrderDocument> {
+  generateOneTimeToken(): string;
+  createUserRegistrationOrder(email: string): UserRegistrationOrderDocument;
 }
 
 const expiredAt = (): Date => {
   return addHours(new Date(), 1);
 };
 
-const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
-  token: { type: String, required: true, unique: true },
-  email: { type: String, required: true },
-  isRevoked: { type: Boolean, default: false, required: true },
-  expiredAt: { type: Date, default: expiredAt, required: true },
-}, {
-  timestamps: true,
-});
+const schema = new Schema<
+  UserRegistrationOrderDocument,
+  UserRegistrationOrderModel
+>(
+  {
+    token: { type: String, required: true, unique: true },
+    email: { type: String, required: true },
+    isRevoked: { type: Boolean, default: false, required: true },
+    expiredAt: { type: Date, default: expiredAt, required: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 schema.plugin(uniqueValidator);
 
-schema.statics.generateOneTimeToken = function() {
+schema.statics.generateOneTimeToken = () => {
   const buf = crypto.randomBytes(256);
   const token = buf.toString('hex');
 
   return token;
 };
 
-schema.statics.createUserRegistrationOrder = async function(email) {
-  let token;
-  let duplicateToken;
+schema.statics.createUserRegistrationOrder = async function (email) {
+  let token: string;
+  let duplicateToken: UserRegistrationOrderDocument | null = null;
 
   do {
     token = this.generateOneTimeToken();
@@ -64,13 +69,16 @@ schema.statics.createUserRegistrationOrder = async function(email) {
   return userRegistrationOrderData;
 };
 
-schema.methods.isExpired = function() {
+schema.methods.isExpired = function () {
   return this.expiredAt.getTime() < Date.now();
 };
 
-schema.methods.revokeOneTimeToken = async function() {
+schema.methods.revokeOneTimeToken = async function () {
   this.isRevoked = true;
   return this.save();
 };
 
-export default getOrCreateModel<UserRegistrationOrderDocument, UserRegistrationOrderModel>('UserRegistrationOrder', schema);
+export default getOrCreateModel<
+  UserRegistrationOrderDocument,
+  UserRegistrationOrderModel
+>('UserRegistrationOrder', schema);

+ 9 - 11
apps/app/src/server/models/user-ui-settings.ts

@@ -1,20 +1,16 @@
-import type { Ref, IUser } from '@growi/core';
-import type { Model, Document } from 'mongoose';
-import {
-  Schema,
-} from 'mongoose';
-
+import type { IUser, Ref } from '@growi/core';
+import type { Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { SidebarContentsType } from '~/interfaces/ui';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-
 export interface UserUISettingsDocument extends IUserUISettings, Document {
-  user: Ref<IUser>,
+  user: Ref<IUser>;
 }
-export type UserUISettingsModel = Model<UserUISettingsDocument>
+export type UserUISettingsModel = Model<UserUISettingsDocument>;
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
@@ -27,5 +23,7 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   preferCollapsedModeByUser: { type: Boolean, default: false },
 });
 
-
-export default getOrCreateModel<UserUISettingsDocument, UserUISettingsModel>('UserUISettings', schema);
+export default getOrCreateModel<UserUISettingsDocument, UserUISettingsModel>(
+  'UserUISettings',
+  schema,
+);

+ 233 - 143
apps/app/src/server/models/user.js

@@ -10,10 +10,8 @@ import loggerFactory from '~/utils/logger';
 import { aclService } from '../service/acl';
 import { configManager } from '../service/config-manager';
 import { getModelSafely } from '../util/mongoose-utils';
-
 import { Attachment } from './attachment';
 
-
 const crypto = require('crypto');
 
 const mongoose = require('mongoose');
@@ -24,7 +22,6 @@ const logger = loggerFactory('growi:models:user');
 
 /** @param {import('~/server/crowi').default | null} crowi Crowi instance */
 const factory = (crowi) => {
-
   const userModelExists = getModelSafely('User');
   if (userModelExists != null) {
     return userModelExists;
@@ -35,8 +32,9 @@ const factory = (crowi) => {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_FIELDS_EXCEPT_CONFIDENTIAL = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
-  + ' status lang createdAt lastLoginAt admin imageUrlCached';
+  const USER_FIELDS_EXCEPT_CONFIDENTIAL =
+    '_id image isEmailPublished isGravatarEnabled googleId name username email introduction' +
+    ' status lang createdAt lastLoginAt admin imageUrlCached';
 
   const PAGE_ITEMS = 50;
 
@@ -48,53 +46,63 @@ const factory = (crowi) => {
     userEvent.on('activated', userEvent.onActivated);
   }
 
-
-  const userSchema = new mongoose.Schema({
-    userId: String,
-    image: String,
-    imageAttachment: { type: mongoose.Schema.Types.ObjectId, ref: 'Attachment' },
-    imageUrlCached: String,
-    isGravatarEnabled: { type: Boolean, default: false },
-    isEmailPublished: { type: Boolean, default: true },
-    googleId: String,
-    name: { type: String, index: true },
-    username: { type: String, required: true, unique: true },
-    email: { type: String, unique: true, sparse: true },
-    slackMemberId: { type: String, unique: true, sparse: true },
-    // === Crowi settings
-    // username: { type: String, index: true },
-    // email: { type: String, required: true, index: true },
-    // === crowi-plus (>= 2.1.0, <2.3.0) settings
-    // email: { type: String, required: true, unique: true },
-    introduction: String,
-    password: String,
-    apiToken: { type: String, index: true },
-    lang: {
-      type: String,
-      enum: i18n.locales,
-      default: 'en_US',
-    },
-    status: {
-      type: Number, required: true, default: STATUS_ACTIVE, index: true,
+  const userSchema = new mongoose.Schema(
+    {
+      userId: String,
+      image: String,
+      imageAttachment: {
+        type: mongoose.Schema.Types.ObjectId,
+        ref: 'Attachment',
+      },
+      imageUrlCached: String,
+      isGravatarEnabled: { type: Boolean, default: false },
+      isEmailPublished: { type: Boolean, default: true },
+      googleId: String,
+      name: { type: String, index: true },
+      username: { type: String, required: true, unique: true },
+      email: { type: String, unique: true, sparse: true },
+      slackMemberId: { type: String, unique: true, sparse: true },
+      // === Crowi settings
+      // username: { type: String, index: true },
+      // email: { type: String, required: true, index: true },
+      // === crowi-plus (>= 2.1.0, <2.3.0) settings
+      // email: { type: String, required: true, unique: true },
+      introduction: String,
+      password: String,
+      apiToken: { type: String, index: true },
+      lang: {
+        type: String,
+        enum: i18n.locales,
+        default: 'en_US',
+      },
+      status: {
+        type: Number,
+        required: true,
+        default: STATUS_ACTIVE,
+        index: true,
+      },
+      lastLoginAt: { type: Date, index: true },
+      admin: { type: Boolean, default: 0, index: true },
+      readOnly: { type: Boolean, default: 0 },
+      isInvitationEmailSended: { type: Boolean, default: false },
     },
-    lastLoginAt: { type: Date, index: true },
-    admin: { type: Boolean, default: 0, index: true },
-    readOnly: { type: Boolean, default: 0 },
-    isInvitationEmailSended: { type: Boolean, default: false },
-  }, {
-    timestamps: true,
-    toObject: {
-      transform: (doc, ret, opt) => {
-        return omitInsecureAttributes(ret);
+    {
+      timestamps: true,
+      toObject: {
+        transform: (doc, ret, opt) => {
+          return omitInsecureAttributes(ret);
+        },
       },
     },
-  });
+  );
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
 
   function validateCrowi() {
     if (crowi == null) {
-      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+      throw new Error(
+        '"crowi" is null. Init User model with "crowi" argument first.',
+      );
     }
   }
 
@@ -107,7 +115,9 @@ const factory = (crowi) => {
     }
 
     // status decided depends on registrationMode
-    const registrationMode = configManager.getConfig('security:registrationMode');
+    const registrationMode = configManager.getConfig(
+      'security:registrationMode',
+    );
     switch (registrationMode) {
       case aclService.labels.SECURITY_REGISTRATION_MODE_OPEN:
         return STATUS_ACTIVE;
@@ -120,7 +130,8 @@ const factory = (crowi) => {
   }
 
   function generateRandomTempPassword() {
-    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
+    const chars =
+      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
     let password = '';
     const len = 12;
 
@@ -148,20 +159,18 @@ const factory = (crowi) => {
 
   function generateApiToken(user) {
     const hasher = crypto.createHash('sha256');
-    hasher.update((new Date()).getTime() + user._id);
+    hasher.update(new Date().getTime() + user._id);
 
     return hasher.digest('base64');
   }
 
-  userSchema.methods.isUniqueEmail = async function() {
+  userSchema.methods.isUniqueEmail = async function () {
     const query = this.model('User').find();
 
-    const count = await query.count((
-      {
-        username: { $ne: this.username },
-        email: this.email,
-      }
-    ));
+    const count = await query.count({
+      username: { $ne: this.username },
+      email: this.email,
+    });
 
     if (count > 0) {
       return false;
@@ -169,66 +178,66 @@ const factory = (crowi) => {
     return true;
   };
 
-  userSchema.methods.isPasswordSet = function() {
+  userSchema.methods.isPasswordSet = function () {
     if (this.password) {
       return true;
     }
     return false;
   };
 
-  userSchema.methods.isPasswordValid = function(password) {
+  userSchema.methods.isPasswordValid = function (password) {
     return this.password === generatePassword(password);
   };
 
-  userSchema.methods.setPassword = function(password) {
+  userSchema.methods.setPassword = function (password) {
     this.password = generatePassword(password);
     return this;
   };
 
-  userSchema.methods.isEmailSet = function() {
+  userSchema.methods.isEmailSet = function () {
     if (this.email) {
       return true;
     }
     return false;
   };
 
-  userSchema.methods.updateLastLoginAt = function(lastLoginAt, callback) {
+  userSchema.methods.updateLastLoginAt = function (lastLoginAt, callback) {
     this.lastLoginAt = lastLoginAt;
     this.save((err, userData) => {
       return callback(err, userData);
     });
   };
 
-  userSchema.methods.updateIsGravatarEnabled = async function(isGravatarEnabled) {
+  userSchema.methods.updateIsGravatarEnabled = async function (
+    isGravatarEnabled,
+  ) {
     this.isGravatarEnabled = isGravatarEnabled;
     await this.updateImageUrlCached();
     const userData = await this.save();
     return userData;
   };
 
-  userSchema.methods.updatePassword = async function(password) {
+  userSchema.methods.updatePassword = async function (password) {
     this.setPassword(password);
     const userData = await this.save();
     return userData;
   };
 
-  userSchema.methods.updateApiToken = async function() {
-    const self = this;
-
-    self.apiToken = generateApiToken(this);
-    const userData = await self.save();
+  userSchema.methods.updateApiToken = async function () {
+    this.apiToken = generateApiToken(this);
+    const userData = await this.save();
     return userData;
   };
 
   // TODO: create UserService and transplant this method because image uploading depends on AttachmentService
-  userSchema.methods.updateImage = async function(attachment) {
+  userSchema.methods.updateImage = async function (attachment) {
     this.imageAttachment = attachment;
     await this.updateImageUrlCached();
     return this.save();
   };
 
   // TODO: create UserService and transplant this method because image deletion depends on AttachmentService
-  userSchema.methods.deleteImage = async function() {
+  userSchema.methods.deleteImage = async function () {
     validateCrowi();
 
     // the 'image' field became DEPRECATED in v3.3.8
@@ -244,11 +253,11 @@ const factory = (crowi) => {
     return this.save();
   };
 
-  userSchema.methods.updateImageUrlCached = async function() {
+  userSchema.methods.updateImageUrlCached = async function () {
     this.imageUrlCached = await this.generateImageUrlCached();
   };
 
-  userSchema.methods.generateImageUrlCached = async function() {
+  userSchema.methods.generateImageUrlCached = async function () {
     if (this.isGravatarEnabled) {
       return generateGravatarSrc(this.email);
     }
@@ -262,23 +271,29 @@ const factory = (crowi) => {
     return '/images/icons/user.svg';
   };
 
-  userSchema.methods.updateGoogleId = function(googleId, callback) {
+  userSchema.methods.updateGoogleId = function (googleId, callback) {
     this.googleId = googleId;
     this.save((err, userData) => {
       return callback(err, userData);
     });
   };
 
-  userSchema.methods.deleteGoogleId = function(callback) {
+  userSchema.methods.deleteGoogleId = function (callback) {
     return this.updateGoogleId(null, callback);
   };
 
-  userSchema.methods.activateInvitedUser = async function(username, name, password) {
+  userSchema.methods.activateInvitedUser = async function (
+    username,
+    name,
+    password,
+  ) {
     this.setPassword(password);
     this.name = name;
     this.username = username;
     this.status = STATUS_ACTIVE;
-    this.isEmailPublished = configManager.getConfig('customize:isEmailPublishedForNewUser');
+    this.isEmailPublished = configManager.getConfig(
+      'customize:isEmailPublishedForNewUser',
+    );
 
     this.save((err, userData) => {
       userEvent.emit('activated', userData);
@@ -289,58 +304,61 @@ const factory = (crowi) => {
     });
   };
 
-  userSchema.methods.grantAdmin = async function() {
+  userSchema.methods.grantAdmin = async function () {
     logger.debug('Grant Admin', this);
     this.admin = 1;
     return this.save();
   };
 
-  userSchema.methods.revokeAdmin = async function() {
+  userSchema.methods.revokeAdmin = async function () {
     logger.debug('Revove admin', this);
     this.admin = 0;
     return this.save();
   };
 
-  userSchema.methods.grantReadOnly = async function() {
+  userSchema.methods.grantReadOnly = async function () {
     logger.debug('Grant read only access', this);
     this.readOnly = 1;
     return this.save();
   };
 
-  userSchema.methods.revokeReadOnly = async function() {
+  userSchema.methods.revokeReadOnly = async function () {
     logger.debug('Revoke read only access', this);
     this.readOnly = 0;
     return this.save();
   };
 
-  userSchema.methods.asyncGrantAdmin = async function(callback) {
+  userSchema.methods.asyncGrantAdmin = async function (callback) {
     this.admin = 1;
     return this.save();
   };
 
-  userSchema.methods.statusActivate = async function() {
+  userSchema.methods.statusActivate = async function () {
     logger.debug('Activate User', this);
     this.status = STATUS_ACTIVE;
     const userData = await this.save();
     return userEvent.emit('activated', userData);
   };
 
-  userSchema.methods.statusSuspend = async function() {
+  userSchema.methods.statusSuspend = async function () {
     logger.debug('Suspend User', this);
     this.status = STATUS_SUSPENDED;
-    if (this.email === undefined || this.email === null) { // migrate old data
+    if (this.email === undefined || this.email === null) {
+      // migrate old data
       this.email = '-';
     }
-    if (this.name === undefined || this.name === null) { // migrate old data
+    if (this.name === undefined || this.name === null) {
+      // migrate old data
       this.name = `-${Date.now()}`;
     }
-    if (this.username === undefined || this.usename === null) { // migrate old data
+    if (this.username === undefined || this.usename === null) {
+      // migrate old data
       this.username = '-';
     }
     return this.save();
   };
 
-  userSchema.methods.statusDelete = async function() {
+  userSchema.methods.statusDelete = async function () {
     logger.debug('Delete User', this);
 
     const now = new Date();
@@ -357,7 +375,7 @@ const factory = (crowi) => {
     return this.save();
   };
 
-  userSchema.statics.getUserStatusLabels = function() {
+  userSchema.statics.getUserStatusLabels = () => {
     const userStatus = {};
     userStatus[STATUS_REGISTERED] = 'Approval Pending';
     userStatus[STATUS_ACTIVE] = 'Active';
@@ -368,7 +386,7 @@ const factory = (crowi) => {
     return userStatus;
   };
 
-  userSchema.statics.isEmailValid = function(email, callback) {
+  userSchema.statics.isEmailValid = (email, callback) => {
     validateCrowi();
 
     const whitelist = configManager.getConfig('security:registrationWhitelist');
@@ -383,7 +401,7 @@ const factory = (crowi) => {
     return true;
   };
 
-  userSchema.statics.findUsers = function(options, callback) {
+  userSchema.statics.findUsers = function (options, callback) {
     const sort = options.sort || { status: 1, createdAt: 1 };
 
     this.find()
@@ -395,7 +413,7 @@ const factory = (crowi) => {
       });
   };
 
-  userSchema.statics.findAllUsers = function(option) {
+  userSchema.statics.findAllUsers = function (option) {
     // eslint-disable-next-line no-param-reassign
     option = option || {};
 
@@ -408,12 +426,16 @@ const factory = (crowi) => {
     }
 
     return this.find()
-      .or(status.map((s) => { return { status: s } }))
+      .or(
+        status.map((s) => {
+          return { status: s };
+        }),
+      )
       .select(fields)
       .sort(sort);
   };
 
-  userSchema.statics.findUsersByIds = function(ids, option) {
+  userSchema.statics.findUsersByIds = function (ids, option) {
     // eslint-disable-next-line no-param-reassign
     option = option || {};
 
@@ -426,7 +448,7 @@ const factory = (crowi) => {
       .sort(sort);
   };
 
-  userSchema.statics.findAdmins = async function(option) {
+  userSchema.statics.findAdmins = async function (option) {
     const sort = option?.sort ?? { createdAt: -1 };
 
     let status = option?.status ?? [STATUS_ACTIVE];
@@ -434,25 +456,24 @@ const factory = (crowi) => {
       status = [status];
     }
 
-    return this.find({ admin: true, status: { $in: status } })
-      .sort(sort);
+    return this.find({ admin: true, status: { $in: status } }).sort(sort);
   };
 
-  userSchema.statics.findUserByUsername = function(username) {
+  userSchema.statics.findUserByUsername = function (username) {
     if (username == null) {
       return Promise.resolve(null);
     }
     return this.findOne({ username });
   };
 
-  userSchema.statics.findUserByApiToken = function(apiToken) {
+  userSchema.statics.findUserByApiToken = function (apiToken) {
     if (apiToken == null) {
       return Promise.resolve(null);
     }
     return this.findOne({ apiToken }).lean();
   };
 
-  userSchema.statics.findUserByGoogleId = function(googleId, callback) {
+  userSchema.statics.findUserByGoogleId = function (googleId, callback) {
     if (googleId == null) {
       callback(null, null);
     }
@@ -461,25 +482,30 @@ const factory = (crowi) => {
     });
   };
 
-  userSchema.statics.findUserByUsernameOrEmail = function(usernameOrEmail, password, callback) {
+  userSchema.statics.findUserByUsernameOrEmail = function (
+    usernameOrEmail,
+    password,
+    callback,
+  ) {
     this.findOne()
-      .or([
-        { username: usernameOrEmail },
-        { email: usernameOrEmail },
-      ])
+      .or([{ username: usernameOrEmail }, { email: usernameOrEmail }])
       .exec((err, userData) => {
         callback(err, userData);
       });
   };
 
-  userSchema.statics.findUserByEmailAndPassword = function(email, password, callback) {
+  userSchema.statics.findUserByEmailAndPassword = function (
+    email,
+    password,
+    callback,
+  ) {
     const hashedPassword = generatePassword(password);
     this.findOne({ email, password: hashedPassword }, (err, userData) => {
       callback(err, userData);
     });
   };
 
-  userSchema.statics.isUserCountExceedsUpperLimit = async function() {
+  userSchema.statics.isUserCountExceedsUpperLimit = async function () {
     const userUpperLimit = configManager.getConfig('security:userUpperLimit');
 
     const activeUsers = await this.countActiveUsers();
@@ -490,11 +516,12 @@ const factory = (crowi) => {
     return false;
   };
 
-  userSchema.statics.countActiveUsers = async function() {
+  userSchema.statics.countActiveUsers = async function () {
     return this.countListByStatus(STATUS_ACTIVE);
   };
 
-  userSchema.statics.countListByStatus = async function(status) {
+  userSchema.statics.countListByStatus = async function (status) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
     const conditions = { status };
 
@@ -502,7 +529,7 @@ const factory = (crowi) => {
     return User.count(conditions);
   };
 
-  userSchema.statics.isRegisterableUsername = async function(username) {
+  userSchema.statics.isRegisterableUsername = async function (username) {
     let usernameUsable = true;
 
     const userData = await this.findOne({ username });
@@ -512,7 +539,7 @@ const factory = (crowi) => {
     return usernameUsable;
   };
 
-  userSchema.statics.isRegisterableEmail = async function(email) {
+  userSchema.statics.isRegisterableEmail = async function (email) {
     let isEmailUsable = true;
 
     const userData = await this.findOne({ email });
@@ -522,13 +549,14 @@ const factory = (crowi) => {
     return isEmailUsable;
   };
 
-  userSchema.statics.isRegisterable = function(email, username, callback) {
+  userSchema.statics.isRegisterable = function (email, username, callback) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
     let emailUsable = true;
     let usernameUsable = true;
 
     // username check
-    this.findOne({ username }, (err, userData) => {
+    User.findOne({ username }, (err, userData) => {
       if (userData) {
         usernameUsable = false;
       }
@@ -540,7 +568,10 @@ const factory = (crowi) => {
         }
 
         if (!emailUsable || !usernameUsable) {
-          return callback(false, { email: emailUsable, username: usernameUsable });
+          return callback(false, {
+            email: emailUsable,
+            username: usernameUsable,
+          });
         }
 
         return callback(true, {});
@@ -548,7 +579,7 @@ const factory = (crowi) => {
     });
   };
 
-  userSchema.statics.resetPasswordByRandomString = async function(id) {
+  userSchema.statics.resetPasswordByRandomString = async function (id) {
     const user = await this.findById(id);
 
     if (!user) {
@@ -562,7 +593,8 @@ const factory = (crowi) => {
     return newPassword;
   };
 
-  userSchema.statics.createUserByEmail = async function(email) {
+  userSchema.statics.createUserByEmail = async function (email) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
     const newUser = new User();
 
@@ -588,21 +620,28 @@ const factory = (crowi) => {
         password,
         user: newUserData,
       };
-    }
-    catch (err) {
+    } catch (err) {
       return {
         email,
       };
     }
   };
 
-  userSchema.statics.createUsersByEmailList = async function(emailList) {
+  userSchema.statics.createUsersByEmailList = async function (emailList) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
 
     // check exists and get list of try to create
-    const existingUserList = await User.find({ email: { $in: emailList }, userStatus: { $ne: STATUS_DELETED } });
-    const existingEmailList = existingUserList.map((user) => { return user.email });
-    const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });
+    const existingUserList = await User.find({
+      email: { $in: emailList },
+      userStatus: { $ne: STATUS_DELETED },
+    });
+    const existingEmailList = existingUserList.map((user) => {
+      return user.email;
+    });
+    const creationEmailList = emailList.filter((email) => {
+      return existingEmailList.indexOf(email) === -1;
+    });
 
     const createdUserList = [];
     const failedToCreateUserEmailList = [];
@@ -612,8 +651,7 @@ const factory = (crowi) => {
         // eslint-disable-next-line no-await-in-loop
         const createdUser = await this.createUserByEmail(email);
         createdUserList.push(createdUser);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         failedToCreateUserEmailList.push({
           email,
@@ -625,12 +663,22 @@ const factory = (crowi) => {
     return { createdUserList, existingEmailList, failedToCreateUserEmailList };
   };
 
-  userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {
+  userSchema.statics.createUserByEmailAndPasswordAndStatus = async function (
+    name,
+    username,
+    email,
+    password,
+    lang,
+    status,
+    callback,
+  ) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
     const newUser = new User();
 
     // check user upper limit
-    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    const isUserCountExceedsUpperLimit =
+      await User.isUserCountExceedsUpperLimit();
     if (isUserCountExceedsUpperLimit) {
       const err = new UserUpperLimitException();
       return callback(err);
@@ -651,7 +699,9 @@ const factory = (crowi) => {
     }
 
     // Default email show/hide is up to the administrator
-    newUser.isEmailPublished = configManager.getConfig('customize:isEmailPublishedForNewUser');
+    newUser.isEmailPublished = configManager.getConfig(
+      'customize:isEmailPublishedForNewUser',
+    );
 
     const globalLang = configManager.getConfig('app:globalLang');
     if (globalLang != null) {
@@ -680,8 +730,23 @@ const factory = (crowi) => {
    * A wrapper function of createUserByEmailAndPasswordAndStatus with callback
    *
    */
-  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
-    this.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, undefined, callback);
+  userSchema.statics.createUserByEmailAndPassword = function (
+    name,
+    username,
+    email,
+    password,
+    lang,
+    callback,
+  ) {
+    this.createUserByEmailAndPasswordAndStatus(
+      name,
+      username,
+      email,
+      password,
+      lang,
+      undefined,
+      callback,
+    );
   };
 
   /**
@@ -689,20 +754,36 @@ const factory = (crowi) => {
    *
    * @return {Promise<User>}
    */
-  userSchema.statics.createUser = function(name, username, email, password, lang, status) {
+  userSchema.statics.createUser = function (
+    name,
+    username,
+    email,
+    password,
+    lang,
+    status,
+  ) {
+    // biome-ignore lint/complexity/noUselessThisAlias: ignore
     const User = this;
 
     return new Promise((resolve, reject) => {
-      User.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, status, (err, userData) => {
-        if (err) {
-          return reject(err);
-        }
-        return resolve(userData);
-      });
+      User.createUserByEmailAndPasswordAndStatus(
+        name,
+        username,
+        email,
+        password,
+        lang,
+        status,
+        (err, userData) => {
+          if (err) {
+            return reject(err);
+          }
+          return resolve(userData);
+        },
+      );
     });
   };
 
-  userSchema.statics.isExistUserByUserPagePath = async function(path) {
+  userSchema.statics.isExistUserByUserPagePath = async function (path) {
     const username = pagePathUtils.getUsernameByPath(path);
 
     if (username == null) {
@@ -713,7 +794,7 @@ const factory = (crowi) => {
     return user != null;
   };
 
-  userSchema.statics.updateIsInvitationEmailSended = async function(id) {
+  userSchema.statics.updateIsInvitationEmailSended = async function (id) {
     const user = await this.findById(id);
 
     if (user == null) {
@@ -728,7 +809,7 @@ const factory = (crowi) => {
     user.save();
   };
 
-  userSchema.statics.findUserBySlackMemberId = async function(slackMemberId) {
+  userSchema.statics.findUserBySlackMemberId = async function (slackMemberId) {
     const user = this.findOne({ slackMemberId });
     if (user == null) {
       throw new Error('User not found');
@@ -736,7 +817,9 @@ const factory = (crowi) => {
     return user;
   };
 
-  userSchema.statics.findUsersBySlackMemberIds = async function(slackMemberIds) {
+  userSchema.statics.findUsersBySlackMemberIds = async function (
+    slackMemberIds,
+  ) {
     const users = this.find({ slackMemberId: { $in: slackMemberIds } });
     if (users.length === 0) {
       throw new Error('No user found');
@@ -744,30 +827,36 @@ const factory = (crowi) => {
     return users;
   };
 
-  userSchema.statics.findUserByUsernameRegexWithTotalCount = async function(username, status, option) {
+  userSchema.statics.findUserByUsernameRegexWithTotalCount = async function (
+    username,
+    status,
+    option,
+  ) {
     const opt = option || {};
     const sortOpt = opt.sortOpt || { username: 1 };
     const offset = opt.offset || 0;
     const limit = opt.limit || 10;
 
-    const conditions = { username: { $regex: username, $options: 'i' }, status: { $in: status } };
+    const conditions = {
+      username: { $regex: username, $options: 'i' },
+      status: { $in: status },
+    };
 
     const users = await this.find(conditions)
       .sort(sortOpt)
       .skip(offset)
       .limit(limit);
 
-    const totalCount = (await this.find(conditions).distinct('username')).length;
+    const totalCount = (await this.find(conditions).distinct('username'))
+      .length;
 
     return { users, totalCount };
   };
 
   class UserUpperLimitException {
-
     constructor() {
       this.name = this.constructor.name;
     }
-
   }
 
   userSchema.statics.STATUS_REGISTERED = STATUS_REGISTERED;
@@ -775,7 +864,8 @@ const factory = (crowi) => {
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
-  userSchema.statics.USER_FIELDS_EXCEPT_CONFIDENTIAL = USER_FIELDS_EXCEPT_CONFIDENTIAL;
+  userSchema.statics.USER_FIELDS_EXCEPT_CONFIDENTIAL =
+    USER_FIELDS_EXCEPT_CONFIDENTIAL;
   userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
 
   return mongoose.model('User', userSchema);

+ 0 - 2
apps/app/src/server/models/vo/collection-progress.ts

@@ -1,5 +1,4 @@
 class CollectionProgress {
-
   collectionName: string;
 
   currentCount = 0;
@@ -13,7 +12,6 @@ class CollectionProgress {
   constructor(collectionName: string) {
     this.collectionName = collectionName;
   }
-
 }
 
 export default CollectionProgress;

+ 1 - 6
apps/app/src/server/models/vo/collection-progressing-status.ts

@@ -1,7 +1,6 @@
 import CollectionProgress from './collection-progress';
 
 class CollectionProgressingStatus {
-
   totalCount = 0;
 
   progressList: CollectionProgress[];
@@ -29,12 +28,8 @@ class CollectionProgressingStatus {
   }
 
   get currentCount(): number {
-    return this.progressList.reduce(
-      (acc, crr) => acc + crr.currentCount,
-      0,
-    );
+    return this.progressList.reduce((acc, crr) => acc + crr.currentCount, 0);
   }
-
 }
 
 export default CollectionProgressingStatus;

+ 2 - 3
apps/app/src/server/models/vo/g2g-transfer-error.ts

@@ -6,10 +6,10 @@ export const G2GTransferErrorCode = {
   FAILED_TO_RETRIEVE_FILE_METADATA: 'FAILED_TO_RETRIEVE_FILE_METADATA',
 } as const;
 
-export type G2GTransferErrorCode = typeof G2GTransferErrorCode[keyof typeof G2GTransferErrorCode];
+export type G2GTransferErrorCode =
+  (typeof G2GTransferErrorCode)[keyof typeof G2GTransferErrorCode];
 
 export class G2GTransferError extends ExtensibleCustomError {
-
   readonly id = 'G2GTransferError';
 
   code!: G2GTransferErrorCode;
@@ -18,7 +18,6 @@ export class G2GTransferError extends ExtensibleCustomError {
     super(message);
     this.code = code;
   }
-
 }
 
 export const isG2GTransferError = (err: any): err is G2GTransferError => {

+ 1 - 6
apps/app/src/server/models/vo/s2c-message.js

@@ -4,14 +4,10 @@ const { serializePageSecurely } = require('../serializers/page-serializer');
  * Server-to-client message VO
  */
 class S2cMessagePageUpdated {
-
-
   constructor(page, user) {
     const serializedPage = serializePageSecurely(page);
 
-    const {
-      _id, revision, updatedAt,
-    } = serializedPage;
+    const { _id, revision, updatedAt } = serializedPage;
 
     this.pageId = _id;
     this.revisionId = revision;
@@ -25,7 +21,6 @@ class S2cMessagePageUpdated {
       this.lastUpdateUsername = user.name;
     }
   }
-
 }
 
 module.exports = {

+ 1 - 3
apps/app/src/server/models/vo/s2s-message.js

@@ -2,7 +2,6 @@
  * Server-to-server message VO
  */
 class S2sMessage {
-
   constructor(eventName, body = {}) {
     this.eventName = eventName;
     for (const [key, value] of Object.entries(body)) {
@@ -18,12 +17,11 @@ class S2sMessage {
     const body = JSON.parse(messageString);
 
     if (body.eventName == null) {
-      throw new Error('message body must contain \'eventName\'');
+      throw new Error("message body must contain 'eventName'");
     }
 
     return new S2sMessage(body.eventName, body);
   }
-
 }
 
 module.exports = S2sMessage;

+ 0 - 2
apps/app/src/server/models/vo/search-error.ts

@@ -3,7 +3,6 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import type { AllTermsKey } from '~/server/interfaces/search';
 
 export class SearchError extends ExtensibleCustomError {
-
   readonly id = 'SearchError';
 
   unavailableTermsKeys!: AllTermsKey[];
@@ -12,7 +11,6 @@ export class SearchError extends ExtensibleCustomError {
     super(message);
     this.unavailableTermsKeys = unavailableTermsKeys;
   }
-
 }
 
 export const isSearchError = (err: any): err is SearchError => {

+ 11 - 14
apps/app/src/server/models/vo/slack-command-handler-error.ts

@@ -2,36 +2,33 @@ import type { RespondBodyForResponseUrl } from '@growi/slack';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import ExtensibleCustomError from 'extensible-custom-error';
 
-export const generateDefaultRespondBodyForInternalServerError = (message: string): RespondBodyForResponseUrl => {
+export const generateDefaultRespondBodyForInternalServerError = (
+  message: string,
+): RespondBodyForResponseUrl => {
   return {
     text: message,
-    blocks: [
-      markdownSectionBlock(`*An error occured*\n ${message}`),
-    ],
+    blocks: [markdownSectionBlock(`*An error occured*\n ${message}`)],
   };
 };
 
 type Opts = {
-  responseUrl?: string,
-  respondBody?: RespondBodyForResponseUrl,
-}
+  responseUrl?: string;
+  respondBody?: RespondBodyForResponseUrl;
+};
 
 /**
  * Error class for slackbot service
  */
 export class SlackCommandHandlerError extends ExtensibleCustomError {
-
   responseUrl?: string;
 
   respondBody: RespondBodyForResponseUrl;
 
-  constructor(
-      message: string,
-      opts: Opts = {},
-  ) {
+  constructor(message: string, opts: Opts = {}) {
     super(message);
     this.responseUrl = opts.responseUrl;
-    this.respondBody = opts.respondBody || generateDefaultRespondBodyForInternalServerError(message);
+    this.respondBody =
+      opts.respondBody ||
+      generateDefaultRespondBodyForInternalServerError(message);
   }
-
 }

+ 0 - 2
apps/app/src/server/models/vo/v5-conversion-error.ts

@@ -3,7 +3,6 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import type { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 
 export class V5ConversionError extends ExtensibleCustomError {
-
   readonly id = 'V5ConversionError';
 
   code!: V5ConversionErrCode;
@@ -12,7 +11,6 @@ export class V5ConversionError extends ExtensibleCustomError {
     super(message);
     this.code = code;
   }
-
 }
 
 export const isV5ConversionError = (err: any): err is V5ConversionError => {

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

@@ -84,6 +84,7 @@ module.exports = (crowi, app) => {
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 
   router.use('/personal-setting', require('./personal-setting')(crowi));
+  router.use('/user-activities', require('./user-activities')(crowi));
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));

+ 300 - 0
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -0,0 +1,300 @@
+import type { IUserHasId } from '@growi/core';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Request, Router } from 'express';
+import express from 'express';
+import { query } from 'express-validator';
+import type { PipelineStage, PaginateResult } from 'mongoose';
+import { Types } from 'mongoose';
+
+import type { IActivity } from '~/interfaces/activity';
+import { ActivityLogActions } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+
+import type Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import type { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:activity');
+
+const validator = {
+  list: [
+    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100')
+      .toInt(),
+    query('offset').optional().isInt().withMessage('page must be a number')
+      .toInt(),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
+    query('targetUserId').optional().isMongoId().withMessage('user ID must be a MongoDB ID'),
+  ],
+};
+
+interface StrictActivityQuery {
+  limit?: number;
+  offset?: number;
+  searchFilter?: string;
+  targetUserId?: string;
+}
+
+type CustomRequest<
+  TQuery = Request['query'],
+  TBody = any,
+  TParams = any
+> = Omit<Request<TParams, any, TBody, TQuery>, 'query'> & {
+    query: TQuery & Request['query'];
+    user?: IUserHasId;
+};
+
+type AuthorizedRequest = CustomRequest<StrictActivityQuery>;
+
+type ActivityPaginationResult = PaginateResult<IActivity>;
+
+
+/**
+ * @swagger
+ *
+ * components:
+ *   schemas:
+ *     ActivityResponse:
+ *       type: object
+ *       properties:
+ *         serializedPaginationResult:
+ *           type: object
+ *           properties:
+ *             docs:
+ *               type: array
+ *               items:
+ *                 type: object
+ *                 properties:
+ *                   _id:
+ *                     type: string
+ *                     example: "67e33da5d97e8d3b53e99f95"
+ *                   targetModel:
+ *                     type: string
+ *                     example: "Page"
+ *                   target:
+ *                     type: string
+ *                     example: "675547e97f208f8050a361d4"
+ *                   action:
+ *                     type: string
+ *                     example: "PAGE_UPDATE"
+ *                   createdAt:
+ *                     type: string
+ *                     format: date-time
+ *                     example: "2025-03-25T23:35:01.584Z"
+ *                   user:
+ *                     type: object
+ *                     properties:
+ *                       _id:
+ *                         type: string
+ *                         example: "669a5aa48d45e62b521d00e4"
+ *                       name:
+ *                         type: string
+ *                         example: "Taro"
+ *                       username:
+ *                         type: string
+ *                         example: "growi"
+ *                       imageUrlCached:
+ *                         type: string
+ *                         example: "/images/icons/user.svg"
+ *             totalDocs:
+ *               type: integer
+ *               example: 3
+ *             offset:
+ *               type: integer
+ *               example: 0
+ *             limit:
+ *               type: integer
+ *               example: 10
+ *             totalPages:
+ *               type: integer
+ *               example: 1
+ *             page:
+ *               type: integer
+ *               example: 1
+ *             pagingCounter:
+ *               type: integer
+ *               example: 1
+ *             hasPrevPage:
+ *               type: boolean
+ *               example: false
+ *             hasNextPage:
+ *               type: boolean
+ *               example: false
+ *             prevPage:
+ *               type: integer
+ *               nullable: true
+ *               example: null
+ *             nextPage:
+ *               type: integer
+ *               nullable: true
+ *               example: null
+ */
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const router = express.Router();
+
+  /**
+   * @swagger
+   *
+   * /activity:
+   *   get:
+   *     summary: /activity
+   *     tags: [Activity]
+   *     security:
+   *       - cookieAuth: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
+   *     parameters:
+   *       - name: limit
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: integer
+   *       - name: offset
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: integer
+   *       - name: searchFilter
+   *         in: query
+   *         required: false
+   *         schema:
+   *           type: string
+   *     responses:
+   *       200:
+   *         description: Activity fetched successfully
+   *         content:
+   *           application/json:
+   *             schema:
+   *               $ref: '#/components/schemas/ActivityResponse'
+   */
+  router.get('/',
+    loginRequiredStrictly, validator.list, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+
+      const defaultLimit = configManager.getConfig('customize:showPageLimitationS');
+
+      const limit = req.query.limit || defaultLimit || 10;
+      const offset = req.query.offset || 0;
+      let targetUserId = req.query.targetUserId;
+
+      if (typeof targetUserId !== 'string') {
+        targetUserId = req.user?._id;
+      }
+
+      if (!targetUserId) {
+        return res.apiv3Err('Target user ID is missing and authenticated user ID is unavailable.', 400);
+      }
+
+
+      try {
+        const userObjectId = new Types.ObjectId(targetUserId);
+
+        const userActivityPipeline: PipelineStage[] = [
+          {
+            $match: {
+              user: userObjectId,
+              action: { $in: Object.values(ActivityLogActions) },
+            },
+          },
+          {
+            $facet: {
+              totalCount: [
+                { $count: 'count' },
+              ],
+              docs: [
+                { $sort: { createdAt: -1 } },
+                { $skip: offset },
+                { $limit: limit },
+                {
+                  $lookup: {
+                    from: 'pages',
+                    localField: 'target',
+                    foreignField: '_id',
+                    as: 'target',
+                  },
+                },
+                {
+                  $unwind: {
+                    path: '$target',
+                    preserveNullAndEmptyArrays: true,
+                  },
+                },
+                {
+                  $lookup: {
+                    from: 'users',
+                    localField: 'user',
+                    foreignField: '_id',
+                    as: 'user',
+                  },
+                },
+                {
+                  $unwind: {
+                    path: '$user',
+                    preserveNullAndEmptyArrays: true,
+                  },
+                },
+                {
+                  $project: {
+                    _id: 1,
+                    'user._id': 1,
+                    'user.username': 1,
+                    'user.name': 1,
+                    'user.imageUrlCached': 1,
+                    action: 1,
+                    createdAt: 1,
+                    target: 1,
+                    targetModel: 1,
+                  },
+                },
+              ],
+            },
+          },
+        ];
+
+        const [activityResults] = await Activity.aggregate(userActivityPipeline);
+
+        const serializedResults = activityResults.docs.map((doc: IActivity) => {
+          const { user, ...rest } = doc;
+          return {
+            user: serializeUserSecurely(user),
+            ...rest,
+          };
+        });
+
+        const totalDocs = activityResults.totalCount.length > 0 ? activityResults.totalCount[0].count : 0;
+        const totalPages = Math.ceil(totalDocs / limit);
+        const page = Math.floor(offset / limit) + 1;
+
+        const nextPage = page < totalPages ? page + 1 : null;
+        const prevPage = page > 1 ? page - 1 : null;
+        const pagingCounter = offset + 1;
+
+        const serializedPaginationResult: ActivityPaginationResult = {
+          docs: serializedResults,
+          totalDocs,
+          limit,
+          offset,
+          page,
+          totalPages,
+          hasPrevPage: page > 1,
+          hasNextPage: page < totalPages,
+          nextPage,
+          prevPage,
+          pagingCounter,
+        };
+
+        return res.apiv3({ serializedPaginationResult });
+      }
+      catch (err) {
+        logger.error('Failed to get paginated activity', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
+  return router;
+};

+ 39 - 0
apps/app/src/server/util/locale-utils.ts

@@ -1,4 +1,5 @@
 import { Lang } from '@growi/core/dist/interfaces';
+import { enUS, fr, ja, ko, type Locale, zhCN } from 'date-fns/locale';
 import type { IncomingHttpHeaders } from 'http';
 
 import * as i18nextConfig from '^/config/i18next.config';
@@ -11,6 +12,44 @@ const ACCEPT_LANG_MAP = {
   ko: Lang.ko_KR,
 };
 
+const DATE_FNS_LOCALE_MAP: Record<string, Locale | undefined> = {
+  en: enUS,
+  'en-US': enUS,
+  en_US: enUS,
+
+  ja: ja,
+  'ja-JP': ja,
+  ja_JP: ja,
+
+  fr: fr,
+  'fr-FR': fr,
+  fr_FR: fr,
+
+  ko: ko,
+  'ko-KR': ko,
+  ko_KR: ko,
+
+  zh: zhCN,
+  'zh-CN': zhCN,
+  zh_CN: zhCN,
+};
+
+/**
+ * Gets the corresponding date-fns Locale object from an i18next language code.
+ * @param langCode The i18n language code (e.g., 'ja_JP').
+ * @returns The date-fns Locale object, defaulting to enUS if not found.
+ */
+export const getLocale = (langCode: string): Locale => {
+  let locale = DATE_FNS_LOCALE_MAP[langCode];
+
+  if (!locale) {
+    const baseCode = langCode.split(/[-_]/)[0];
+    locale = DATE_FNS_LOCALE_MAP[baseCode];
+  }
+
+  return locale ?? enUS;
+};
+
 /**
  * It return the first language that matches ACCEPT_LANG_MAP keys from sorted accept languages array
  * @param sortedAcceptLanguagesArray

+ 32 - 0
apps/app/src/stores/recent-activity.ts

@@ -0,0 +1,32 @@
+import type { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type {
+  IActivityHasId,
+  UserActivitiesResult,
+} from '~/interfaces/activity';
+import type { PaginateResult } from '~/interfaces/mongoose-utils';
+
+export const useSWRxRecentActivity = (
+  limit?: number,
+  offset?: number,
+  targetUserId?: string,
+): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+  const shouldFetch = targetUserId && targetUserId.length > 0;
+  const key = shouldFetch
+    ? ['/user-activities', limit, offset, targetUserId]
+    : null;
+
+  const fetcher = ([endpoint, limitParam, offsetParam, targetUserIdParam]) => {
+    const promise = apiv3Get<UserActivitiesResult>(endpoint, {
+      limit: limitParam,
+      offset: offsetParam,
+      targetUserId: targetUserIdParam,
+    });
+
+    return promise.then((result) => result.data.serializedPaginationResult);
+  };
+
+  return useSWRImmutable(key, fetcher);
+};

+ 1 - 1
apps/pdf-converter/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pdf-converter",
-  "version": "1.1.2-RC.0",
+  "version": "1.1.3-RC.0",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "license": "MIT",

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.3.4-slackbot-proxy.0",
+  "version": "7.3.5-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 0 - 2
biome.json

@@ -27,10 +27,8 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/playwright",
       "!apps/app/src/client",
       "!apps/app/src/server/middlewares",
-      "!apps/app/src/server/models",
       "!apps/app/src/server/routes",
       "!apps/app/src/server/service"
     ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.3.4",
+  "version": "7.3.5-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini