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

Merge pull request #10487 from growilabs/feat/171499-activity-log

feat: Activity Log on the user page for viewing recent activity
mergify[bot] 4 месяцев назад
Родитель
Сommit
3e7e7b87d1

+ 1 - 1
apps/app/package.json

@@ -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",
     "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:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "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:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "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",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",

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

@@ -999,7 +999,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Bookmarks",
     "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": "bookmark folder",
     "bookmark_folder": "bookmark folder",

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

@@ -993,7 +993,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Favoris",
     "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": {
     "bookmark_folder": "dossier de favoris",
     "bookmark_folder": "dossier de favoris",

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

@@ -1032,7 +1032,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "ブックマーク",
     "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": {
     "bookmark_folder": "ブックマークフォルダ",
     "bookmark_folder": "ブックマークフォルダ",

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

@@ -959,7 +959,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "북마크",
     "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": {
     "bookmark_folder": "북마크 폴더",
     "bookmark_folder": "북마크 폴더",

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

@@ -1004,7 +1004,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "书签",
     "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": {
     "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 { useTranslation } from 'next-i18next';
 
 
+import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { RecentCreated } from '~/client/components/RecentCreated/RecentCreated';
 import { useCurrentUser } from '~/stores-universal/context';
 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']}`}>
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />
           <RecentCreated userId={creatorId} />
         </div>
         </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>
     </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
 // Model
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
@@ -377,6 +385,7 @@ export const SupportedAction = {
 
 
 // Action required for notification
 // Action required for notification
 export const EssentialActionGroup = {
 export const EssentialActionGroup = {
+  ACTION_PAGE_CREATE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_UPDATE,
@@ -568,6 +577,18 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
 } 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
  * Array
  */
  */
@@ -645,7 +666,8 @@ export type SupportedActionType =
   (typeof SupportedAction)[keyof typeof SupportedAction];
   (typeof SupportedAction)[keyof typeof SupportedAction];
 export type SupportedActionCategoryType =
 export type SupportedActionCategoryType =
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
   (typeof SupportedActionCategory)[keyof typeof SupportedActionCategory];
-
+export type SupportedActivityActionType =
+  (typeof ActivityLogActions)[keyof typeof ActivityLogActions];
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 export type ISnapshot = Partial<Pick<IUser, 'username'>>;
 
 
 export type IActivity = {
 export type IActivity = {
@@ -661,6 +683,10 @@ export type IActivity = {
   snapshot?: ISnapshot;
   snapshot?: ISnapshot;
 };
 };
 
 
+export type ActivityHasUserId = IActivityHasId & {
+  user: IUserHasId;
+};
+
 export type IActivityHasId = IActivity & HasObjectId;
 export type IActivityHasId = IActivity & HasObjectId;
 
 
 export type ISearchFilter = {
 export type ISearchFilter = {
@@ -668,3 +694,7 @@ export type ISearchFilter = {
   dates?: { startDate: string | null; endDate: string | null };
   dates?: { startDate: string | null; endDate: string | null };
   actions?: SupportedActionType[];
   actions?: SupportedActionType[];
 };
 };
+
+export interface UserActivitiesResult {
+  serializedPaginationResult: PaginateResult<IActivityHasId>;
+}

+ 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('/in-app-notification', require('./in-app-notification')(crowi));
 
 
   router.use('/personal-setting', require('./personal-setting')(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('/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));
   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 { Lang } from '@growi/core/dist/interfaces';
+import { enUS, fr, ja, ko, type Locale, zhCN } from 'date-fns/locale';
 import type { IncomingHttpHeaders } from 'http';
 import type { IncomingHttpHeaders } from 'http';
 
 
 import * as i18nextConfig from '^/config/i18next.config';
 import * as i18nextConfig from '^/config/i18next.config';
@@ -11,6 +12,44 @@ const ACCEPT_LANG_MAP = {
   ko: Lang.ko_KR,
   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
  * It return the first language that matches ACCEPT_LANG_MAP keys from sorted accept languages array
  * @param sortedAcceptLanguagesArray
  * @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);
+};