Просмотр исходного кода

Merge branch 'master' into dev/7.4.x with refectoring UsersHomepageFooter.tsx

Yuki Takei 3 месяцев назад
Родитель
Сommit
38733cff92
44 измененных файлов с 850 добавлено и 554 удалено
  1. 0 5
      .changeset/clever-paws-wink.md
  2. 0 5
      .changeset/healthy-pianos-brake.md
  3. 0 5
      .changeset/lazy-penguins-hammer.md
  4. 1 1
      .github/workflows/reusable-app-build-image.yml
  5. 1 0
      .gitignore
  6. 40 1
      CHANGELOG.md
  7. 3 0
      apps/app/.gitignore
  8. BIN
      apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c
  9. 3 0
      apps/app/docker/codebuild/.terraform.lock.hcl
  10. 1 1
      apps/app/docker/codebuild/main.tf
  11. 8 0
      apps/app/docker/codebuild/oidc.tf
  12. 1 1
      apps/app/package.json
  13. 11 0
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  14. 4 1
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  15. 6 2
      apps/app/playwright/23-editor/saving.spec.ts
  16. 0 2
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  17. 9 3
      apps/app/playwright/60-home/home.spec.ts
  18. 7 5
      apps/app/playwright/utils/CollapseSidebar.ts
  19. 26 2
      apps/app/src/client/components/ContentLinkButtons.tsx
  20. 82 13
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  21. 8 6
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  22. 3 0
      apps/app/src/client/components/UsersHomepageFooter.consts.tsx
  23. 12 13
      apps/app/src/client/components/UsersHomepageFooter.tsx
  24. 20 5
      apps/app/src/interfaces/activity.ts
  25. 26 14
      apps/app/src/pages/[[...path]]/page-data-props.ts
  26. 69 9
      apps/app/src/pages/[[...path]]/server-side-props.ts
  27. 3 7
      apps/app/src/pages/common-props/i18n.ts
  28. 0 37
      apps/app/src/pages/general-page/get-activity-action.ts
  29. 0 1
      apps/app/src/pages/general-page/index.ts
  30. 6 6
      apps/app/src/pages/share/[[...path]]/page-data-props.ts
  31. 24 2
      apps/app/src/pages/share/[[...path]]/server-side-props.ts
  32. 16 12
      apps/app/src/server/routes/apiv3/page/index.ts
  33. 222 0
      apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts
  34. 7 174
      apps/app/src/server/service/page/index.ts
  35. 8 32
      apps/app/src/server/service/page/page-service.ts
  36. 4 4
      apps/app/src/stores/recent-activity.ts
  37. 1 1
      apps/slackbot-proxy/package.json
  38. 1 1
      package.json
  39. 12 0
      packages/core/CHANGELOG.md
  40. 1 1
      packages/core/package.json
  41. 12 0
      packages/core/src/interfaces/page.ts
  42. 11 0
      packages/pluginkit/CHANGELOG.md
  43. 2 2
      packages/pluginkit/package.json
  44. 179 180
      pnpm-lock.yaml

+ 0 - 5
.changeset/clever-paws-wink.md

@@ -1,5 +0,0 @@
----
-'@growi/core': minor
----
-
-Add global EventTarget instance provider

+ 0 - 5
.changeset/healthy-pianos-brake.md

@@ -1,5 +0,0 @@
----
-'@growi/core': major
----
-
-Remove global socket management and useSWRStatic

+ 0 - 5
.changeset/lazy-penguins-hammer.md

@@ -1,5 +0,0 @@
----
-'@growi/core': major
----
-
-Update IPage interfaces family

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -40,7 +40,7 @@ jobs:
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
-        role-session-name: SessionForReleaseGROWI-RC
+        role-session-name: GitHubActions-SessionForReleaseGROWI-${{ github.run_id }}
 
     - name: Run CodeBuild
       uses: dark-mechanicum/aws-codebuild@v1

+ 1 - 0
.gitignore

@@ -33,6 +33,7 @@ yarn-error.log*
 # Terraform
 **/.terraform/*
 *.tfstate.*
+/aws/
 
 # IDE, dev #
 .idea

+ 40 - 1
CHANGELOG.md

@@ -1,9 +1,48 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.9...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.0](https://github.com/growilabs/compare/v7.3.9...v7.4.0) - 2025-12-24
+
+### 💎 Features
+
+* feat: PageTree Virtualization (#10581) @yuki-takei
+* feat: Can set default user role as read-only for new users (#10623) @Ryosei-Fukushima
+* feat: Can create page when executing page edit shortcut key on empty page (#10594) @miya
+
+### 🚀 Improvement
+
+* imprv: Admin sidebar mode setting (#10617) @miya
+* imprv: Empty page operation (#10604) @yuki-takei
+* imprv: Support target attribute for anchor links (#10566) @yuki-takei
+* imprv: Use EventTarget instead of EventEmitter on the client side (#10472) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Aftercare for Revisions migration script-bug (#10620) @yuki-takei
+* fix: Omit file upload restriction feature for non image files (#10602) @miya
+
+### 🧰 Maintenance
+
+* support: Use jotai for state management (#10474) @yuki-takei
+* support: Omit importers for esa.io and Qiita (#10584) @yuki-takei
+* support: Configure biome for app client services (#10600) @arafubeatbox
+* support: Configure biome for app client utils (#10601) @arafubeatbox
+* support: Configure biome for app client models/interfaces (#10599) @arafubeatbox
+* support: Configure biome for app server services 4 (#10583) @arafubeatbox
+* support: Configure biome for app server services 3 (#10578) @arafubeatbox
+* ci(mergify): upgrade configuration to current format (#10372) @[mergify[bot]](https://github.com/apps/mergify)
+* support: Configure biome for app server services 2 (#10575) @arafubeatbox
+* support: Configure biome for some app server services (#10574) @arafubeatbox
+* support: Configure biome for apiv3 js files (#10537) @arafubeatbox
+* support: Reapply biome configuration for app apiv3 routes (app-settings, page) (#10555) @arafubeatbox
+* support: Configure biome for apiv3 routes (remaining ts files) (#10536) @arafubeatbox
+* support: Configure biome for app apiv3 routes (app-settings, page) (#10532) @arafubeatbox
+* support: Configure biome for app apiv3 routes (personal-setting, security-settings, interfaces, pages, user) (#10500) @arafubeatbox
+* support: Configure biome for app server middlewares (#10507) @arafubeatbox
+
 ## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09
 
 ### 🐛 Bug Fixes

+ 3 - 0
apps/app/.gitignore

@@ -14,3 +14,6 @@
 /public/uploads
 /src/styles/prebuilt
 /tmp/
+
+# cache
+/.swc/

BIN
apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c


+ 3 - 0
apps/app/docker/codebuild/.terraform.lock.hcl

@@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" {
   version     = "6.12.0"
   constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
+    "h1:8u90EMle+I3Auh4f/LPP6fEfRsAF6xCFnUZF4b7ngEs=",
     "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
     "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
     "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
   version     = "3.7.2"
   constraints = ">= 2.1.0"
   hashes = [
+    "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=",
     "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
     "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
     "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
@@ -48,6 +50,7 @@ provider "registry.terraform.io/hashicorp/tls" {
   version     = "4.1.0"
   constraints = ">= 4.0.0"
   hashes = [
+    "h1:Ka8mEwRFXBabR33iN/WTIEW6RP0z13vFsDlwn11Pf2I=",
     "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
     "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
     "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",

+ 1 - 1
apps/app/docker/codebuild/main.tf

@@ -18,6 +18,6 @@ terraform {
 }
 
 provider "aws" {
-  profile = "weseek"
+  profile = "weseek-tf"
   region  = "ap-northeast-1"
 }

+ 8 - 0
apps/app/docker/codebuild/oidc.tf

@@ -23,4 +23,12 @@ data "aws_iam_policy_document" "policy_document" {
       module.codebuild.project_arn
     ]
   }
+  statement {
+    actions = [
+      "logs:GetLogEvents"
+    ]
+    resources = [
+      "arn:aws:logs:*:*:log-group:/aws/codebuild/${module.codebuild.project_name}:*"
+    ]
+  }
 }

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.0-RC.0",
+  "version": "7.4.1-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 11 - 0
apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts

@@ -5,6 +5,17 @@ test('Sub navigation sticky changes when scrolling down and up', async ({
 }) => {
   await page.goto('/Sandbox');
 
+  // Wait until the page is scrollable
+  await expect
+    .poll(async () => {
+      const { scrollHeight, innerHeight } = await page.evaluate(() => ({
+        scrollHeight: document.body.scrollHeight,
+        innerHeight: window.innerHeight,
+      }));
+      return scrollHeight > innerHeight + 250;
+    })
+    .toBe(true);
+
   // Sticky
   await page.evaluate(() => window.scrollTo(0, 250));
   await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(

+ 4 - 1
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -10,7 +10,10 @@ test.describe
       await page.goto('/Sandbox/Bootstrap5');
 
       // Create Sharelink
-      await page.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-accessories-modal-btn-with-share-link-management-data-tab',

+ 6 - 2
apps/app/playwright/23-editor/saving.spec.ts

@@ -14,8 +14,12 @@ test('Successfully create page under specific path', async ({ page }) => {
 
   await page.goto('/Sandbox');
 
-  await page.keyboard.press(openPageCreateModalShortcutKey);
-  await expect(page.getByTestId('page-create-modal')).toBeVisible();
+  await expect(async () => {
+    await page.keyboard.press(openPageCreateModalShortcutKey);
+    await expect(page.getByTestId('page-create-modal')).toBeVisible({
+      timeout: 1000,
+    });
+  }).toPass();
   page
     .getByTestId('page-create-modal')
     .locator('.rbt-input-main')

+ 0 - 2
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -13,8 +13,6 @@ test('admin/app is successfully loaded', async ({ page }) => {
   await page.goto('/admin/app');
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
-  // await expect(page.getByTestId('v5-page-migration')).toBeVisible();
-  await expect(page.locator('#cbFileUpload')).toBeChecked();
 });
 
 test('admin/security is successfully loaded', async ({ page }) => {

+ 9 - 3
apps/app/playwright/60-home/home.spec.ts

@@ -46,12 +46,18 @@ test('Access External account', async ({ page }) => {
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await page.getByTestId('external-accounts-tab-button').first().click();
 
-  // Expect an error toaster to be displayed when the AddExternalAccountsButton is pressed
+  // press AddExternalAccountButton
   await page.getByTestId('grw-external-account-add-button').click();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await page.getByTestId('add-external-account-button').click();
-  await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await page.locator('.Toastify__close-button').click();
+
+  // Expect a few failed toasters to be displayed
+  await expect(page.locator('.Toastify__toast').first()).toBeVisible();
+  const toastCloseButtons = page.locator('.Toastify__close-button');
+  const count = await toastCloseButtons.count();
+  for (let i = 0; i < count; i++) {
+    await toastCloseButtons.first().click();
+  }
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 

+ 7 - 5
apps/app/playwright/utils/CollapseSidebar.ts

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

+ 26 - 2
apps/app/src/client/components/ContentLinkButtons.tsx

@@ -3,10 +3,16 @@ import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 
+import {
+  BOOKMARKS_LIST_ID,
+  RECENT_ACTIVITY_LIST_ID,
+  RECENTLY_CREATED_LIST_ID,
+} from './UsersHomepageFooter.consts';
+
 const BookMarkLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="bookmarks-list" offset={-120}>
+    <ScrollLink to={BOOKMARKS_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -23,7 +29,7 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 const RecentlyCreatedLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="recently-created-list" offset={-120}>
+    <ScrollLink to={RECENTLY_CREATED_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -37,6 +43,23 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
+const RecentActivityLinkButton = React.memo(() => {
+  const { t } = useTranslation();
+  return (
+    <ScrollLink to={RECENT_ACTIVITY_LIST_ID} offset={-120}>
+      <button
+        type="button"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      >
+        <span className="material-symbols-outlined mx-1">update</span>
+        <span>{t('user_home_page.recent_activity')}</span>
+      </button>
+    </ScrollLink>
+  );
+});
+
+RecentActivityLinkButton.displayName = 'RecentActivityLinkButton';
+
 export type ContentLinkButtonsProps = {
   author?: IUserHasId;
 };
@@ -54,6 +77,7 @@ export const ContentLinkButtons = (
     <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
+      <RecentActivityLinkButton />
     </div>
   );
 };

+ 82 - 13
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -1,9 +1,10 @@
 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 { useTranslation } from 'next-i18next';
+
+import type { SupportedActivityActionType, ActivityHasTargetPage } from '~/interfaces/activity';
 import { ActivityLogActions } from '~/interfaces/activity';
+import { getLocale } from '~/server/util/locale-utils';
 
 
 export const ActivityActionTranslationMap: Record<
@@ -36,6 +37,18 @@ export const IconActivityTranslationMap: Record<
   [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
 };
 
+type ActivityListItemProps = {
+  activity: ActivityHasTargetPage,
+}
+
+type AllowPageDisplayPayload = {
+  grant: number | undefined,
+  status: string,
+  wip: boolean,
+  deletedAt?: Date,
+  path: string,
+}
+
 const translateAction = (action: SupportedActivityActionType): string => {
   return ActivityActionTranslationMap[action] || 'unknown_action';
 };
@@ -53,29 +66,85 @@ const calculateTimePassed = (date: Date, locale: Locale): string => {
   return timePassed;
 };
 
+const pageAllowedForDisplay = (allowDisplayPayload: AllowPageDisplayPayload): boolean => {
+  const {
+    grant, status, wip, deletedAt,
+  } = allowDisplayPayload;
+  if (grant !== 1) return false;
+
+  if (status !== 'published') return false;
+
+  if (wip) return false;
+
+  if (deletedAt) return false;
+
+  return true;
+};
+
+const setPath = (path: string, allowed: boolean): string => {
+  if (allowed) return path;
 
-export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  return '';
+};
+
+
+export const ActivityListItem = ({ props }: { props: ActivityListItemProps }): JSX.Element => {
   const { t, i18n } = useTranslation();
   const currentLangCode = i18n.language;
   const dateFnsLocale = getLocale(currentLangCode);
 
+  const { activity } = props;
+
+  const {
+    path, grant, status, wip, deletedAt,
+  } = activity.target;
+
+
+  const allowDisplayPayload: AllowPageDisplayPayload = {
+    grant,
+    status,
+    wip,
+    deletedAt,
+    path,
+  };
+
+  const isPageAllowed = pageAllowedForDisplay(allowDisplayPayload);
+
   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)}
+      <div className="d-flex align-items-center">
+        <span className="material-symbols-outlined me-2 flex-shrink-0">
+          {setIcon(action)}
         </span>
 
-        <span className="text-secondary small ms-3">
-          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
-        </span>
-      </p>
+        <div className="flex-grow-1 ms-2">
+          <div className="activity-path-line mb-0">
+            <a
+              href={setPath(path, isPageAllowed)}
+              className="activity-target-link fw-bold text-wrap d-block"
+            >
+              <span>
+                {setPath(path, isPageAllowed)}
+              </span>
+            </a>
+          </div>
+
+          <div className="activity-details-line d-flex">
+            <span>
+              {t(fullKeyPath)}
+            </span>
+
+            <span className="text-secondary small ms-3 align-self-center">
+              {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+            </span>
+
+          </div>
+        </div>
+      </div>
     </div>
   );
 };

+ 8 - 6
apps/app/src/client/components/RecentActivity/RecentActivity.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import { toastError } from '~/client/util/toastr';
-import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import type { IActivityHasId, ActivityHasTargetPage } from '~/interfaces/activity';
 import { useSWRxRecentActivity } from '~/stores/recent-activity';
 import loggerFactory from '~/utils/logger';
 
@@ -18,15 +18,17 @@ type RecentActivityProps = {
   userId: string,
 }
 
-const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+const hasTargetPage = (activity: IActivityHasId): activity is ActivityHasTargetPage => {
   return activity.user != null
-        && typeof activity.user === 'object';
+         && typeof activity.user === 'object'
+         && activity.target != null
+         && typeof activity.target === 'object';
 };
 
 export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
   const { userId } = props;
 
-  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activities, setActivities] = useState<ActivityHasTargetPage[]>([]);
   const [activePage, setActivePage] = useState(1);
   const [limit] = useState(10);
   const [offset, setOffset] = useState(0);
@@ -49,7 +51,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
 
     if (paginatedData) {
       const activitiesWithPages = paginatedData.docs
-        .filter(hasUser);
+        .filter(hasTargetPage);
 
       setActivities(activitiesWithPages);
     }
@@ -63,7 +65,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
       <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} />
+            <ActivityListItem props={{ activity }} />
           </li>
         ))}
       </ul>

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

@@ -0,0 +1,3 @@
+export const BOOKMARKS_LIST_ID = 'bookmarks-list';
+export const RECENTLY_CREATED_LIST_ID = 'recently-created-list';
+export const RECENT_ACTIVITY_LIST_ID = 'recent-activity-list';

+ 12 - 13
apps/app/src/client/components/UsersHomepageFooter.tsx

@@ -1,4 +1,4 @@
-import React, { type JSX, useState } from 'react';
+import { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
@@ -6,6 +6,11 @@ import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { useCurrentUser } from '~/states/global';
 
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
+import {
+  BOOKMARKS_LIST_ID,
+  RECENT_ACTIVITY_LIST_ID,
+  RECENTLY_CREATED_LIST_ID,
+} from './UsersHomepageFooter.consts';
 
 import styles from './UsersHomepageFooter.module.scss';
 
@@ -28,7 +33,7 @@ export const UsersHomepageFooter = (
     >
       <div className="grw-user-page-list-m d-edit-none">
         <h2
-          id="bookmarks-list"
+          id={BOOKMARKS_LIST_ID}
           className="grw-user-page-header border-bottom pb-2 mb-3 d-flex"
         >
           <span
@@ -65,30 +70,24 @@ export const UsersHomepageFooter = (
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">
         <h2
-          id="recently-created-list"
+          id={RECENTLY_CREATED_LIST_ID}
           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.recently_created')}
         </h2>
-        <div
-          id="user-created-list"
-          className={`page-list ${styles['page-list']}`}
-        >
+        <div className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />
         </div>
 
         <h2
-          id="user-created-list"
+          id={RECENT_ACTIVITY_LIST_ID}
           className="grw-user-page-header border-bottom pb-2 mb-3 d-flex"
         >
-          <span className="growi-custom-icons me-1">recently_created</span>
+          <span className="material-symbols-outlined me-1 fs-1">update</span>
           {t('user_home_page.recent_activity')}
         </h2>
-        <div
-          id="user-created-list"
-          className={`page-list ${styles['page-list']}`}
-        >
+        <div className={`page-list ${styles['page-list']}`}>
           <RecentActivity userId={creatorId} />
         </div>
       </div>

+ 20 - 5
apps/app/src/interfaces/activity.ts

@@ -663,18 +663,33 @@ export type IActivity = {
   snapshot?: ISnapshot;
 };
 
+export type IActivityHasId = IActivity & HasObjectId;
+
 export type ActivityHasUserId = IActivityHasId & {
   user: IUserHasId;
 };
 
-export type IActivityHasId = IActivity & HasObjectId;
+export type ActivityHasTargetPage = IActivityHasId & {
+  user: IUserHasId;
+  target: IPopulatedPageTarget;
+};
+
+import type { PageGrant } from '@growi/core';
+export interface IPopulatedPageTarget {
+  _id: string;
+  path: string;
+  status: string;
+  grant?: PageGrant;
+  wip: boolean;
+  deletedAt: Date;
+}
+
+export interface PopulatedUserActivitiesResult {
+  serializedPaginationResult: PaginateResult<ActivityHasTargetPage>;
+}
 
 export type ISearchFilter = {
   usernames?: string[];
   dates?: { startDate: string | null; endDate: string | null };
   actions?: SupportedActionType[];
 };
-
-export interface UserActivitiesResult {
-  serializedPaginationResult: PaginateResult<IActivityHasId>;
-}

+ 26 - 14
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -2,10 +2,11 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type {
   IDataWithRequiredMeta,
   IPage,
+  IPageInfoBasic,
   IPageNotFoundInfo,
   IUser,
-} from '@growi/core/dist/interfaces';
-import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core/dist/interfaces';
+} from '@growi/core';
+import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core';
 import {
   isPermalink as _isPermalink,
   isTopPage,
@@ -20,6 +21,7 @@ import type {
   IPageRedirect,
   PageRedirectModel,
 } from '~/server/models/page-redirect';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
 
 import type { CommonEachProps } from '../common-props';
 import type {
@@ -131,7 +133,7 @@ export async function getPageDataForInitial(
   let pathFromUrl = `/${pathFromQuery.join('/')}`;
   pathFromUrl = pathFromUrl === '//' ? '/' : pathFromUrl;
 
-  const { pageService, configManager } = crowi;
+  const { pageService, pageGrantService, configManager } = crowi;
 
   const pageId = _isPermalink(pathFromUrl)
     ? removeHeadingSlash(pathFromUrl)
@@ -154,10 +156,10 @@ export async function getPageDataForInitial(
   }
 
   // Get full page data
-  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(
-    pageId,
-    resolvedPagePath,
-    user,
+  const pageWithMeta = await findPageAndMetaDataByViewer(
+    pageService,
+    pageGrantService,
+    { pageId, path: resolvedPagePath, user },
   );
 
   // Handle URL conversion
@@ -249,11 +251,14 @@ export async function getPageDataForSameRoute(
   props: Pick<CommonEachProps, 'currentPathname'> &
     Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>;
   internalProps?: {
-    pageId?: string;
+    pageWithMeta?:
+      | IDataWithRequiredMeta<PageDocument, IPageInfoBasic>
+      | IDataWithRequiredMeta<null, IPageNotFoundInfo>;
   };
 }> {
   const req: CrowiRequest = context.req as CrowiRequest;
-  const { user } = req;
+  const { crowi, user } = req;
+  const { pageService, pageGrantService } = crowi;
 
   const pathname = decodeURIComponent(
     context.resolvedUrl?.split('?')[0] ?? '/',
@@ -275,13 +280,15 @@ export async function getPageDataForSameRoute(
   }
 
   // For same route access, do minimal page lookup
-  const basicPageInfo = await Page.findOne(
-    isPermalink ? { _id: pageId } : { path: resolvedPagePath },
-  ).exec();
+  const pageWithMetaBasicOnly = await findPageAndMetaDataByViewer(
+    pageService,
+    pageGrantService,
+    { pageId, path: resolvedPagePath, user, basicOnly: true },
+  );
 
   const currentPathname = resolveFinalizedPathname(
     resolvedPagePath,
-    basicPageInfo,
+    pageWithMetaBasicOnly.data,
     isPermalink,
   );
 
@@ -292,7 +299,12 @@ export async function getPageDataForSameRoute(
       redirectFrom,
     },
     internalProps: {
-      pageId: basicPageInfo?._id?.toString(),
+      pageWithMeta: pageWithMetaBasicOnly.data?.isEmpty
+        ? {
+            data: null,
+            meta: { isNotFound: true, isForbidden: false },
+          }
+        : pageWithMetaBasicOnly,
     },
   };
 }

+ 69 - 9
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -1,5 +1,11 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+import { isIPageNotFoundInfo } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
 
+import {
+  SupportedAction,
+  type SupportedActionType,
+} from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
@@ -8,7 +14,6 @@ import {
   getServerSideI18nProps,
 } from '../common-props';
 import {
-  getActivityAction,
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
 } from '../general-page';
@@ -52,6 +57,31 @@ function emitPageSeenEvent(
   pageEvent.emit('seen', pageId, user);
 }
 
+function getActivityAction(params: {
+  isIdenticalPathPage: boolean;
+  isForbidden?: boolean;
+  isNotFound?: boolean;
+  path?: string;
+}): SupportedActionType {
+  if (params.isIdenticalPathPage) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+
+  if (params.isForbidden) {
+    return SupportedAction.ACTION_PAGE_FORBIDDEN;
+  }
+
+  if (params.isNotFound) {
+    return SupportedAction.ACTION_PAGE_NOT_FOUND;
+  }
+
+  if (params.path != null && pagePathUtils.isUsersHomepage(params.path)) {
+    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+  }
+
+  return SupportedAction.ACTION_PAGE_VIEW;
+}
+
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -104,16 +134,29 @@ export async function getServerSidePropsForInitial(
   // Add user to seen users
   emitPageSeenEvent(context, mergedProps.pageWithMeta?.data?._id);
 
-  // -- TODO: persist activity
-  // await addActivity(context, getActivityAction(mergedProps));
+  // Persist activity
+  const activityAction = (() => {
+    const meta = mergedProps.pageWithMeta?.meta;
+    if (isIPageNotFoundInfo(meta)) {
+      return getActivityAction({
+        isIdenticalPathPage: mergedProps.isIdenticalPathPage,
+        isForbidden: meta.isForbidden,
+        isNotFound: meta.isNotFound,
+      });
+    }
+    return getActivityAction({
+      isIdenticalPathPage: mergedProps.isIdenticalPathPage,
+      path: mergedProps.pageWithMeta?.data?.path,
+    });
+  })();
+  addActivity(context, activityAction);
+
   return mergedResult;
 }
 
 export async function getServerSidePropsForSameRoute(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
-  // -- TODO: :https://redmine.weseek.co.jp/issues/174725
-  // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
   const [i18nPropsResult, pageDataForSameRouteResult] = await Promise.all([
     getServerSideI18nProps(context, ['translation']),
     getPageDataForSameRoute(context),
@@ -122,11 +165,28 @@ export async function getServerSidePropsForSameRoute(
   const { props: pageDataProps, internalProps } = pageDataForSameRouteResult;
 
   // Add user to seen users
-  emitPageSeenEvent(context, internalProps?.pageId);
+  emitPageSeenEvent(
+    context,
+    internalProps?.pageWithMeta?.data?._id?.toString(),
+  );
+
+  // Persist activity
+  const activityAction = (() => {
+    const meta = internalProps?.pageWithMeta?.meta;
+    if (isIPageNotFoundInfo(meta)) {
+      return getActivityAction({
+        isIdenticalPathPage: pageDataProps.isIdenticalPathPage,
+        isForbidden: meta.isForbidden,
+        isNotFound: meta.isNotFound,
+      });
+    }
+    return getActivityAction({
+      isIdenticalPathPage: pageDataProps.isIdenticalPathPage,
+      path: internalProps?.pageWithMeta?.data?.path,
+    });
+  })();
+  addActivity(context, activityAction);
 
-  // -- TODO: persist activity
-  // const mergedProps = await mergedResult.props;
-  // await addActivity(context, getActivityAction(mergedProps));
   const mergedResult = mergeGetServerSidePropsResults(
     { props: pageDataProps },
     i18nPropsResult,

+ 3 - 7
apps/app/src/pages/common-props/i18n.ts

@@ -1,6 +1,9 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import { AllLang } from '@growi/core';
 import type { SSRConfig } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+
+import nextI18NextConfig from '^/config/next-i18next.config';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { getLangAtServerSide } from '~/pages/utils/locale';
@@ -11,13 +14,6 @@ async function createNextI18NextConfig(
   namespacesRequired?: string[],
   preloadAllLang = false,
 ): Promise<SSRConfig> {
-  const { serverSideTranslations } = await import(
-    'next-i18next/serverSideTranslations'
-  );
-
-  // Import configuration to fix the error
-  const nextI18NextConfig = await import('^/config/next-i18next.config');
-
   // Determine language from request context
   const req: CrowiRequest = context.req as CrowiRequest;
   const lang = getLangAtServerSide(req);

+ 0 - 37
apps/app/src/pages/general-page/get-activity-action.ts

@@ -1,37 +0,0 @@
-import type {
-  IDataWithMeta,
-  IPageNotFoundInfo,
-} from '@growi/core/dist/interfaces';
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import type { SupportedActionType } from '~/interfaces/activity';
-import { SupportedAction } from '~/interfaces/activity';
-
-import type { IPageToShowRevisionWithMeta } from './types';
-
-export const getActivityAction = (props: {
-  isNotCreatable: boolean;
-  isForbidden: boolean;
-  isNotFound: boolean;
-  pageWithMeta?:
-    | IPageToShowRevisionWithMeta
-    | IDataWithMeta<null, IPageNotFoundInfo>
-    | null;
-}): SupportedActionType => {
-  if (props.isNotCreatable) {
-    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
-  }
-  if (props.isForbidden) {
-    return SupportedAction.ACTION_PAGE_FORBIDDEN;
-  }
-  if (props.isNotFound) {
-    return SupportedAction.ACTION_PAGE_NOT_FOUND;
-  }
-
-  // Type-safe access to page data - only access path if data is not null
-  const pagePath = props.pageWithMeta?.data?.path ?? '';
-  if (pagePathUtils.isUsersHomepage(pagePath)) {
-    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
-  }
-  return SupportedAction.ACTION_PAGE_VIEW;
-};

+ 0 - 1
apps/app/src/pages/general-page/index.ts

@@ -2,7 +2,6 @@ export {
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
 } from './configuration-props';
-export { getActivityAction } from './get-activity-action';
 export { isValidGeneralPageInitialProps } from './type-guards';
 export type * from './types';
 export { useInitialCSRFetch } from './use-initial-skip-ssr-fetch';

+ 6 - 6
apps/app/src/pages/share/[[...path]]/page-data-props.ts

@@ -7,6 +7,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { IShareLink } from '~/interfaces/share-link';
 import type { PageModel } from '~/server/models/page';
 import type { ShareLinkModel } from '~/server/models/share-link';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
 
 import type { ShareLinkPageStatesProps } from './types';
 
@@ -34,7 +35,7 @@ export const getPageDataForInitial = async (
 ): Promise<GetServerSidePropsResult<ShareLinkPageStatesProps>> => {
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;
-  const { pageService, configManager } = crowi;
+  const { pageService, pageGrantService, configManager } = crowi;
 
   if (mongooseModel == null) {
     mongooseModel = (await import('mongoose')).model;
@@ -56,11 +57,10 @@ export const getPageDataForInitial = async (
   }
 
   const pageId = getIdStringForRef(shareLink.relatedPage);
-  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(
-    pageId,
-    null,
-    undefined, // no user for share link
-    true, // isSharedPage
+  const pageWithMeta = await findPageAndMetaDataByViewer(
+    pageService,
+    pageGrantService,
+    { pageId, path: null, isSharedPage: true },
   );
 
   // not found

+ 24 - 2
apps/app/src/pages/share/[[...path]]/server-side-props.ts

@@ -1,11 +1,16 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
+import {
+  SupportedAction,
+  type SupportedActionType,
+} from '~/interfaces/activity';
+import type { IShareLinkHasId } from '~/interfaces/share-link';
+
 import {
   getServerSideCommonInitialProps,
   getServerSideI18nProps,
 } from '../../common-props';
 import {
-  getActivityAction,
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
   isValidGeneralPageInitialProps,
@@ -23,6 +28,21 @@ const basisProps = {
   },
 };
 
+function getActivityAction(props: {
+  isExpired: boolean | undefined;
+  shareLink: IShareLinkHasId | undefined;
+}): SupportedActionType {
+  if (props.isExpired) {
+    return SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW;
+  }
+
+  if (props.shareLink == null) {
+    return SupportedAction.ACTION_SHARE_LINK_NOT_FOUND;
+  }
+
+  return SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW;
+}
+
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -67,6 +87,8 @@ export async function getServerSidePropsForInitial(
     throw new Error('Invalid merged props structure');
   }
 
-  await addActivity(context, getActivityAction(mergedProps));
+  // Persist activity
+  addActivity(context, getActivityAction(mergedProps));
+
   return mergedResult;
 }

+ 16 - 12
apps/app/src/server/routes/apiv3/page/index.ts

@@ -39,6 +39,7 @@ import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -94,13 +95,13 @@ module.exports = (crowi: Crowi) => {
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const { pageService } = crowi;
+  const { pageService, pageGrantService } = crowi;
 
   const activityEvent = crowi.event('activity');
 
   const validator = {
     getPage: [
-      query('pageId').optional().isString(),
+      query('pageId').isMongoId().optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
       query('shareLinkId').optional().isMongoId(),
@@ -262,12 +263,12 @@ module.exports = (crowi: Crowi) => {
             return res.apiv3Err('ShareLink is not found', 404);
           }
           return respondWithSinglePage(
-            await pageService.findPageAndMetaDataByViewer(
-              getIdStringForRef(shareLink.relatedPage),
+            await findPageAndMetaDataByViewer(pageService, pageGrantService, {
+              pageId: getIdStringForRef(shareLink.relatedPage),
               path,
               user,
-              true,
-            ),
+              isSharedPage: true,
+            }),
           );
         }
 
@@ -290,7 +291,11 @@ module.exports = (crowi: Crowi) => {
         }
 
         return respondWithSinglePage(
-          await pageService.findPageAndMetaDataByViewer(pageId, path, user),
+          await findPageAndMetaDataByViewer(pageService, pageGrantService, {
+            pageId,
+            path,
+            user,
+          }),
         );
       } catch (err) {
         logger.error('get-page-failed', err);
@@ -583,11 +588,10 @@ module.exports = (crowi: Crowi) => {
       const { pageId } = req.query;
 
       try {
-        const { meta } = await pageService.findPageAndMetaDataByViewer(
-          pageId,
-          null,
-          user,
-          isSharedPage,
+        const { meta } = await findPageAndMetaDataByViewer(
+          pageService,
+          pageGrantService,
+          { pageId, path: null, user, isSharedPage },
         );
 
         if (isIPageNotFoundInfo(meta)) {

+ 222 - 0
apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts

@@ -0,0 +1,222 @@
+import type {
+  IDataWithRequiredMeta,
+  IPageInfo,
+  IPageInfoBasic,
+  IPageInfoExt,
+  IPageInfoForEmpty,
+  IPageInfoForOperation,
+  IPageNotFoundInfo,
+  IUser,
+} from '@growi/core/dist/interfaces';
+import { isIPageInfoForEntity } from '@growi/core/dist/interfaces';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import assert from 'assert';
+import type { HydratedDocument } from 'mongoose';
+import mongoose from 'mongoose';
+
+import type { BookmarkedPage } from '~/interfaces/bookmark-info';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import type { IPageGrantService } from '~/server/service/page-grant';
+
+import Subscription from '../../models/subscription';
+import type { IPageService } from './page-service';
+
+// ============================================================
+// Type Definitions
+// ============================================================
+
+// Shorthand for page document type
+type PageDoc = HydratedDocument<PageDocument>;
+
+// Options
+type BaseOpts = {
+  pageId: string | null;
+  path: string | null;
+  user?: HydratedDocument<IUser>;
+  isSharedPage?: boolean;
+};
+type OptsBasic = BaseOpts & { basicOnly: true };
+type OptsExt = BaseOpts & { basicOnly?: false };
+type OptsImpl = BaseOpts & { basicOnly?: boolean };
+
+// Results
+type FoundResult<T extends IPageInfoBasic | IPageInfoExt> =
+  IDataWithRequiredMeta<PageDoc, T>;
+type NotFoundResult = IDataWithRequiredMeta<null, IPageNotFoundInfo>;
+
+type ResultBasic = FoundResult<IPageInfoBasic> | NotFoundResult;
+type ResultExt = FoundResult<IPageInfoExt> | NotFoundResult;
+type ResultImpl = ResultBasic | ResultExt | NotFoundResult;
+
+// ============================================================
+// Function Overloads
+// ============================================================
+
+// Overload: basicOnly = true returns basic info only
+export async function findPageAndMetaDataByViewer(
+  pageService: IPageService,
+  pageGrantService: IPageGrantService,
+  opts: OptsBasic,
+): Promise<ResultBasic>;
+
+// Overload: basicOnly = false or undefined returns extended info
+export async function findPageAndMetaDataByViewer(
+  pageService: IPageService,
+  pageGrantService: IPageGrantService,
+  opts: OptsExt,
+): Promise<ResultExt>;
+
+// Implementation
+export async function findPageAndMetaDataByViewer(
+  pageService: IPageService,
+  pageGrantService: IPageGrantService,
+  opts: OptsImpl,
+): Promise<ResultImpl> {
+  const { pageId, path, user, isSharedPage = false, basicOnly = false } = opts;
+
+  assert(pageId != null || path != null);
+
+  const Page = mongoose.model<PageDoc, PageModel>('Page');
+
+  let page: PageDoc | null;
+  if (pageId != null) {
+    // prioritized
+    page = await Page.findByIdAndViewer(pageId, user, null, true);
+  } else {
+    page = await Page.findByPathAndViewer(path, user, null, true, true);
+  }
+
+  // not found or forbidden
+  if (page == null) {
+    const count =
+      pageId != null
+        ? await Page.count({ _id: { $eq: pageId } })
+        : await Page.count({ path: { $eq: path } });
+    const isForbidden = count > 0;
+    return {
+      data: null,
+      meta: {
+        isNotFound: true,
+        isForbidden,
+      } satisfies IPageNotFoundInfo,
+    };
+  }
+
+  const isGuestUser = user == null;
+  const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+
+  // Return basic info only without additional DB queries and calculations
+  if (basicOnly) {
+    return {
+      data: page,
+      meta: basicPageInfo,
+    };
+  }
+
+  if (isSharedPage) {
+    return {
+      data: page,
+      meta: {
+        ...basicPageInfo,
+        isMovable: false,
+        isDeletable: false,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+      } satisfies IPageInfo,
+    };
+  }
+
+  const Bookmark = mongoose.model<
+    BookmarkedPage,
+    { countDocuments; findByPageIdAndUserId }
+  >('Bookmark');
+  const bookmarkCount: number = await Bookmark.countDocuments({
+    page: { $eq: pageId },
+  });
+
+  const pageInfo = {
+    ...basicPageInfo,
+    bookmarkCount,
+  };
+
+  if (isGuestUser) {
+    return {
+      data: page,
+      meta: {
+        ...pageInfo,
+        isDeletable: false,
+        isAbleToDeleteCompletely: false,
+      } satisfies IPageInfo,
+    };
+  }
+
+  const creatorId = await pageService.getCreatorIdForCanDelete(page);
+
+  const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
+
+  const canDeleteUserHomepage = await (async () => {
+    // Not a user homepage
+    if (!pagePathUtils.isUsersHomepage(page.path)) {
+      return true;
+    }
+
+    if (!pageService.canDeleteUserHomepageByConfig()) {
+      return false;
+    }
+
+    return await pageService.isUsersHomepageOwnerAbsent(page.path);
+  })();
+
+  const isDeletable =
+    canDeleteUserHomepage &&
+    pageService.canDelete(page, creatorId, user, false);
+
+  const isAbleToDeleteCompletely =
+    canDeleteUserHomepage &&
+    pageService.canDeleteCompletely(
+      page,
+      creatorId,
+      user,
+      false,
+      userRelatedGroups,
+    ); // use normal delete config
+
+  const isBookmarked: boolean = isGuestUser
+    ? false
+    : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
+
+  if (pageInfo.isEmpty) {
+    return {
+      data: page,
+      meta: {
+        ...pageInfo,
+        isDeletable,
+        isAbleToDeleteCompletely,
+        isBookmarked,
+      } satisfies IPageInfoForEmpty,
+    };
+  }
+
+  // IPageInfoForEmpty and IPageInfoForEntity are mutually exclusive
+  // so hereafter we can safely
+  assert(isIPageInfoForEntity(pageInfo));
+
+  const isLiked: boolean = page.isLiked(user);
+  const subscription = await Subscription.findByUserIdAndTargetId(
+    user._id,
+    page._id,
+  );
+
+  return {
+    data: page,
+    meta: {
+      ...pageInfo,
+      isDeletable,
+      isAbleToDeleteCompletely,
+      isBookmarked,
+      isLiked,
+      subscriptionStatus: subscription?.status,
+    } satisfies IPageInfoForOperation,
+  };
+}

+ 7 - 174
apps/app/src/server/service/page/index.ts

@@ -6,23 +6,18 @@ import {
 } from '@growi/core';
 import type {
   HasObjectId,
-  IDataWithRequiredMeta,
   IGrantedGroup,
   IPage,
-  IPageInfo,
-  IPageInfoExt,
-  IPageInfoForEmpty,
-  IPageInfoForEntity,
-  IPageInfoForOperation,
-  IPageNotFoundInfo,
+  IPageInfoBasic,
+  IPageInfoBasicForEmpty,
+  IPageInfoBasicForEntity,
   IRevisionHasId,
   IUser,
   IUserHasId,
   Ref,
 } from '@growi/core/dist/interfaces';
-import { isIPageInfoForEntity, PageGrant } from '@growi/core/dist/interfaces';
+import { PageGrant } from '@growi/core/dist/interfaces';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
-import assert from 'assert';
 import escapeStringRegexp from 'escape-string-regexp';
 import type EventEmitter from 'events';
 import type { Cursor, HydratedDocument } from 'mongoose';
@@ -36,7 +31,6 @@ import type { ExternalUserGroupDocument } from '~/features/external-user-group/s
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { isAiEnabled } from '~/features/openai/server/services';
 import { SupportedAction } from '~/interfaces/activity';
-import type { BookmarkedPage } from '~/interfaces/bookmark-info';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
@@ -104,8 +98,6 @@ const {
   isUsersTopPage,
   isMovablePage,
   isUsersHomepage,
-  hasSlash,
-  generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -531,151 +523,6 @@ class PageService implements IPageService {
     return this.filterPages(pages, user, isRecursively, this.canDelete);
   }
 
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async findPageAndMetaDataByViewer(
-    pageId: string | null, // either pageId or path must be specified
-    path: string | null, // either pageId or path must be specified
-    user?: HydratedDocument<IUser>,
-    isSharedPage = false,
-  ): Promise<
-    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
-  > {
-    assert(pageId != null || path != null);
-
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
-      'Page',
-    );
-
-    let page: HydratedDocument<PageDocument> | null;
-    if (pageId != null) {
-      // prioritized
-      page = await Page.findByIdAndViewer(pageId, user, null, true);
-    } else {
-      page = await Page.findByPathAndViewer(path, user, null, true, true);
-    }
-
-    // not found or forbidden
-    if (page == null) {
-      const count =
-        pageId != null
-          ? await Page.count({ _id: pageId })
-          : await Page.count({ path });
-      const isForbidden = count > 0;
-      return {
-        data: null,
-        meta: {
-          isNotFound: true,
-          isForbidden,
-        } satisfies IPageNotFoundInfo,
-      };
-    }
-
-    const isGuestUser = user == null;
-    const basicPageInfo = this.constructBasicPageInfo(page, isGuestUser);
-
-    if (isSharedPage) {
-      return {
-        data: page,
-        meta: {
-          ...basicPageInfo,
-          isMovable: false,
-          isDeletable: false,
-          isAbleToDeleteCompletely: false,
-          isRevertible: false,
-          bookmarkCount: 0,
-        } satisfies IPageInfo,
-      };
-    }
-
-    const Bookmark = mongoose.model<
-      BookmarkedPage,
-      { countDocuments; findByPageIdAndUserId }
-    >('Bookmark');
-    const bookmarkCount: number = await Bookmark.countDocuments({
-      page: pageId,
-    });
-
-    const pageInfo = {
-      ...basicPageInfo,
-      bookmarkCount,
-    };
-
-    if (isGuestUser) {
-      return {
-        data: page,
-        meta: {
-          ...pageInfo,
-          isDeletable: false,
-          isAbleToDeleteCompletely: false,
-        } satisfies IPageInfo,
-      };
-    }
-
-    const creatorId = await this.getCreatorIdForCanDelete(page);
-
-    const userRelatedGroups =
-      await this.pageGrantService.getUserRelatedGroups(user);
-
-    const canDeleteUserHomepage = await (async () => {
-      // Not a user homepage
-      if (!pagePathUtils.isUsersHomepage(page.path)) {
-        return true;
-      }
-
-      if (!this.canDeleteUserHomepageByConfig()) {
-        return false;
-      }
-
-      return await this.isUsersHomepageOwnerAbsent(page.path);
-    })();
-
-    const isDeletable =
-      canDeleteUserHomepage && this.canDelete(page, creatorId, user, false);
-
-    const isAbleToDeleteCompletely =
-      canDeleteUserHomepage &&
-      this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
-
-    const isBookmarked: boolean = isGuestUser
-      ? false
-      : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
-
-    if (pageInfo.isEmpty) {
-      return {
-        data: page,
-        meta: {
-          ...pageInfo,
-          isDeletable,
-          isAbleToDeleteCompletely,
-          isBookmarked,
-        } satisfies IPageInfoForEmpty,
-      };
-    }
-
-    // IPageInfoForEmpty and IPageInfoForEntity are mutually exclusive
-    // so hereafter we can safely
-    assert(isIPageInfoForEntity(pageInfo));
-
-    const isLiked: boolean = page.isLiked(user);
-    const subscription = await Subscription.findByUserIdAndTargetId(
-      user._id,
-      page._id,
-    );
-
-    return {
-      data: page,
-      meta: {
-        ...pageInfo,
-        isDeletable,
-        isAbleToDeleteCompletely,
-        isBookmarked,
-        isLiked,
-        subscriptionStatus: subscription?.status,
-      } satisfies IPageInfoForOperation,
-    };
-  }
-
   private shouldUseV4ProcessForRevert(page): boolean {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -3414,15 +3261,7 @@ class PageService implements IPageService {
   constructBasicPageInfo(
     page: HydratedDocument<PageDocument>,
     isGuestUser?: boolean,
-  ):
-    | Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >
-    | Omit<
-        IPageInfoForEntity,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      > {
+  ): IPageInfoBasic {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const pageId = page._id.toString();
 
@@ -3434,10 +3273,7 @@ class PageService implements IPageService {
         isEmpty: true,
         isMovable,
         isRevertible: false,
-      } satisfies Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >;
+      } satisfies IPageInfoBasicForEmpty;
     }
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
@@ -3459,10 +3295,7 @@ class PageService implements IPageService {
       // the page must have a revision if it is not empty
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       latestRevisionId: getIdStringForRef(page.revision!),
-    } satisfies Omit<
-      IPageInfoForEntity,
-      'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-    >;
+    } satisfies IPageInfoBasicForEntity;
 
     return infoForEntity;
   }

+ 8 - 32
apps/app/src/server/service/page/page-service.ts

@@ -1,13 +1,9 @@
 import type { EventEmitter } from 'node:events';
 import type {
   HasObjectId,
-  IDataWithRequiredMeta,
   IGrantedGroup,
   IPage,
-  IPageInfoExt,
-  IPageInfoForEmpty,
-  IPageInfoForEntity,
-  IPageNotFoundInfo,
+  IPageInfoBasic,
   IUser,
   IUserHasId,
   PageGrant,
@@ -62,24 +58,6 @@ export interface IPageService {
     pages: ObjectIdLike[],
     user: IUser | undefined,
   ) => Promise<void>;
-  findPageAndMetaDataByViewer(
-    pageId: string,
-    path: string | null,
-    user?: HydratedDocument<IUser>,
-    isSharedPage?: boolean,
-  ): Promise<
-    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
-  >;
-  findPageAndMetaDataByViewer(
-    pageId: string | null,
-    path: string,
-    user?: HydratedDocument<IUser>,
-    isSharedPage?: boolean,
-  ): Promise<
-    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
-  >;
   resumeRenameSubOperation(
     renamedPage: PageDocument,
     pageOp: PageOperationDocument,
@@ -98,15 +76,7 @@ export interface IPageService {
   constructBasicPageInfo(
     page: HydratedDocument<PageDocument>,
     isGuestUser?: boolean,
-  ):
-    | Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >
-    | Omit<
-        IPageInfoForEntity,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >;
+  ): IPageInfoBasic;
   normalizeAllPublicPages(): Promise<void>;
   canDelete(
     page: PageDocument,
@@ -212,4 +182,10 @@ export interface IPageService {
     options: IOptionsForCreate,
     pageOpId: ObjectIdLike,
   ): Promise<void>;
+
+  getCreatorIdForCanDelete(page: PageDocument): Promise<ObjectIdLike | null>;
+
+  canDeleteUserHomepageByConfig(): boolean;
+
+  isUsersHomepageOwnerAbsent(path: string): Promise<boolean>;
 }

+ 4 - 4
apps/app/src/stores/recent-activity.ts

@@ -3,8 +3,8 @@ import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import type {
-  IActivityHasId,
-  UserActivitiesResult,
+  ActivityHasTargetPage,
+  PopulatedUserActivitiesResult,
 } from '~/interfaces/activity';
 import type { PaginateResult } from '~/interfaces/mongoose-utils';
 
@@ -12,14 +12,14 @@ export const useSWRxRecentActivity = (
   limit?: number,
   offset?: number,
   targetUserId?: string,
-): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+): SWRResponse<PaginateResult<ActivityHasTargetPage>, 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, {
+    const promise = apiv3Get<PopulatedUserActivitiesResult>(endpoint, {
       limit: limitParam,
       offset: offsetParam,
       targetUserId: targetUserIdParam,

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

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

+ 1 - 1
package.json

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

+ 12 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,17 @@
 # @growi/core
 
+## 2.0.0
+
+### Major Changes
+
+- [#10474](https://github.com/growilabs/growi/pull/10474) [`3de6953`](https://github.com/growilabs/growi/commit/3de6953c5cf049d8bb070f8cc8c59a85b160a6df) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Remove global socket management and useSWRStatic
+
+- [#10474](https://github.com/growilabs/growi/pull/10474) [`3de6953`](https://github.com/growilabs/growi/commit/3de6953c5cf049d8bb070f8cc8c59a85b160a6df) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Update IPage interfaces family
+
+### Minor Changes
+
+- [#10472](https://github.com/growilabs/growi/pull/10472) [`22fae03`](https://github.com/growilabs/growi/commit/22fae03ce3bc7a530b0d7219c300f9c13f1d65c6) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Add global EventTarget instance provider
+
 ## 1.6.0
 
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "1.6.0",
+  "version": "2.0.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 12 - 0
packages/core/src/interfaces/page.ts

@@ -170,6 +170,18 @@ export const isIPageInfoForEntity = (
   return isIPageInfo(pageInfo) && pageInfo.isEmpty === false;
 };
 
+export type IPageInfoBasicForEmpty = Omit<
+  IPageInfoForEmpty,
+  'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely' | 'isBookmarked'
+>;
+
+export type IPageInfoBasicForEntity = Omit<
+  IPageInfoForEntity,
+  'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+>;
+
+export type IPageInfoBasic = IPageInfoBasicForEmpty | IPageInfoBasicForEntity;
+
 export const isIPageInfoForOperation = (
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   pageInfo: any | undefined,

+ 11 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,16 @@
 # @growi/pluginkit
 
+## 1.2.0
+
+### Minor Changes
+
+- [`2948cf7`](https://github.com/growilabs/growi/commit/2948cf75ea36c6189deae9a41bdac5e56c8b66b4) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Update dependency to use workspace version
+
+### Patch Changes
+
+- Updated dependencies [[`22fae03`](https://github.com/growilabs/growi/commit/22fae03ce3bc7a530b0d7219c300f9c13f1d65c6), [`3de6953`](https://github.com/growilabs/growi/commit/3de6953c5cf049d8bb070f8cc8c59a85b160a6df), [`3de6953`](https://github.com/growilabs/growi/commit/3de6953c5cf049d8bb070f8cc8c59a85b160a6df)]:
+  - @growi/core@2.0.0
+
 ## 1.1.1
 
 ### Patch Changes

+ 2 - 2
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pluginkit",
-  "version": "1.1.1",
+  "version": "1.2.0",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "main": "dist/index.cjs",
@@ -21,7 +21,7 @@
     "test": "vitest run --coverage"
   },
   "dependencies": {
-    "@growi/core": "^1.5.0",
+    "@growi/core": "workspace:^",
     "extensible-custom-error": "^0.0.7"
   },
   "devDependencies": {

Разница между файлами не показана из-за своего большого размера
+ 179 - 180
pnpm-lock.yaml


Некоторые файлы не были показаны из-за большого количества измененных файлов