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

Merge pull request #10874 from growilabs/feat/94790-audit-log-bulk-export

feat: Audit log bulk export
mergify[bot] 1 неделя назад
Родитель
Сommit
6a8ef8fea2
39 измененных файлов с 3264 добавлено и 7 удалено
  1. 7 0
      apps/app/public/static/locales/en_US/admin.json
  2. 5 0
      apps/app/public/static/locales/en_US/translation.json
  3. 7 0
      apps/app/public/static/locales/fr_FR/admin.json
  4. 5 0
      apps/app/public/static/locales/fr_FR/translation.json
  5. 7 0
      apps/app/public/static/locales/ja_JP/admin.json
  6. 5 0
      apps/app/public/static/locales/ja_JP/translation.json
  7. 7 0
      apps/app/public/static/locales/ko_KR/admin.json
  8. 5 0
      apps/app/public/static/locales/ko_KR/translation.json
  9. 7 0
      apps/app/public/static/locales/zh_CN/admin.json
  10. 5 0
      apps/app/public/static/locales/zh_CN/translation.json
  11. 182 0
      apps/app/src/client/components/Admin/AuditLog/AuditLogExportModal.tsx
  12. 39 0
      apps/app/src/client/components/Admin/AuditLog/DuplicateExportConfirmModal.tsx
  13. 67 0
      apps/app/src/client/components/Admin/AuditLog/useAuditLogExport.ts
  14. 19 0
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  15. 98 0
      apps/app/src/client/components/InAppNotification/ModelNotification/AuditLogBulkExportJobModelNotification.tsx
  16. 5 1
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  17. 5 1
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  18. 13 0
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  19. 56 0
      apps/app/src/features/audit-log-bulk-export/interfaces/audit-log-bulk-export.ts
  20. 55 0
      apps/app/src/features/audit-log-bulk-export/server/models/audit-log-bulk-export-job.ts
  21. 299 0
      apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.integ.ts
  22. 117 0
      apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.ts
  23. 1 0
      apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/index.ts
  24. 234 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron.integ.ts
  25. 155 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron.ts
  26. 751 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/audit-log-bulk-export-job-cron-service.integ.ts
  27. 11 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/errors.ts
  28. 297 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/index.ts
  29. 104 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/steps/compress-and-upload.ts
  30. 139 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/steps/exportAuditLogsToFsAsync.ts
  31. 335 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.integ.ts
  32. 135 0
      apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.ts
  33. 42 0
      apps/app/src/features/audit-log-bulk-export/server/service/check-audit-log-bulk-export-job-in-progress-cron.ts
  34. 17 0
      apps/app/src/interfaces/activity.ts
  35. 17 0
      apps/app/src/server/crowi/index.ts
  36. 2 0
      apps/app/src/server/interfaces/attachment.ts
  37. 2 0
      apps/app/src/server/routes/apiv3/index.js
  38. 3 2
      apps/app/src/server/service/in-app-notification.ts
  39. 4 3
      apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts

+ 7 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -881,6 +881,13 @@
     "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
     "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "export": "Export",
+    "export_audit_log": "Export Audit Log",
+    "export_requested": "Export request accepted. You will be notified when the export is complete.",
+    "export_failed": "Failed to start export",
+    "duplicate_export_confirm": "An export with the same conditions is already in progress. Do you want to restart it?",
+    "restart_export": "Restart Export",
+    "confirm_export": "Confirm Export",
     "disable_mode_explanation_cloud": "Audit log is currently disabled. To enable it, please update the app settings from the GROWI.cloud management screen.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"

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

@@ -861,6 +861,11 @@
     "started_on": "Started on",
     "file_upload_not_configured": "File upload settings are not configured"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "Download period has expired",
+    "job_expired": "Export process was canceled because it took too long",
+    "no_results": "No audit logs matched the specified filters"
+  },
   "message": {
     "successfully_connected": "Successfully Connected!",
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",

+ 7 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -880,6 +880,13 @@
     "available_action_list_explanation": "Liste des actions pouvant être recherchées/vues",
     "action_list": "Liste d'actions",
     "disable_mode_explanation": "Cette fonctionnalité est désactivée. Afin de l'activer, mettre à jour <code>AUDIT_LOG_ENABLED</code> pour true.",
+    "export": "Exporter",
+    "export_audit_log": "Exporter le journal d'audit",
+    "export_requested": "Demande d'exportation acceptée. Vous serez averti lorsque l'exportation sera terminée.",
+    "export_failed": "Échec du démarrage de l'exportation",
+    "duplicate_export_confirm": "Une exportation avec les mêmes conditions est déjà en cours. Voulez-vous la redémarrer ?",
+    "restart_export": "Redémarrer l'exportation",
+    "confirm_export": "Confirmer l'exportation",
     "disable_mode_explanation_cloud": "Le journal d'audit est actuellement désactivé. Pour l'activer, veuillez modifier les paramètres de l'application depuis l'écran de gestion GROWI.cloud.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"

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

@@ -856,6 +856,11 @@
     "started_on": "Commencé le",
     "file_upload_not_configured": "Les paramètres de téléchargement de fichiers ne sont pas configurés"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "La période de téléchargement a expiré",
+    "job_expired": "Le processus d'exportation a été annulé car il a pris trop de temps",
+    "no_results": "Aucun journal d'audit ne correspondait aux filtres spécifiés"
+  },
   "message": {
     "successfully_connected": "Connecté!",
     "fail_to_save_access_token": "Échec de la sauvegarde de access_token.",

+ 7 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -890,6 +890,13 @@
     "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
     "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "export": "エクスポート",
+    "export_audit_log": "監査ログのエクスポート",
+    "export_requested": "エクスポートリクエストを受け付けました。完了後に通知されます。",
+    "export_failed": "エクスポートの開始に失敗しました",
+    "duplicate_export_confirm": "同じ条件のエクスポートが進行中です。やり直しますか?",
+    "restart_export": "やり直す",
+    "confirm_export": "エクスポートの確認",
     "disable_mode_explanation_cloud": "現在、監査ログは無効になっています。有効にするには、GROWI.cloud の管理画面からアプリの設定を変更してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"

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

@@ -894,6 +894,11 @@
     "started_on": "開始日時",
     "file_upload_not_configured": "ファイルアップロード設定が完了していません"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "ダウンロード期限が切れました",
+    "job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
+    "no_results": "指定されたフィルターに一致する監査ログはありませんでした"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",

+ 7 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -881,6 +881,13 @@
     "available_action_list_explanation": "현재 설정에서 검색/볼 수 있는 작업 목록",
     "action_list": "작업 목록",
     "disable_mode_explanation": "감사 로그가 현재 비활성화되어 있습니다. 활성화하려면 환경 변수 <code>AUDIT_LOG_ENABLED</code>를 true로 설정하십시오.",
+    "export": "내보내기",
+    "export_audit_log": "감사 로그 내보내기",
+    "export_requested": "내보내기 요청이 접수되었습니다. 내보내기가 완료되면 알림을 받게 됩니다.",
+    "export_failed": "내보내기 시작에 실패했습니다",
+    "duplicate_export_confirm": "동일한 조건의 내보내기가 이미 진행 중입니다. 다시 시작하시겠습니까?",
+    "restart_export": "내보내기 다시 시작",
+    "confirm_export": "내보내기 확인",
     "disable_mode_explanation_cloud": "현재 감사 로그가 비활성화되어 있습니다. 활성화하려면 GROWI.cloud 관리 화면에서 앱 설정을 변경하십시오.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"

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

@@ -821,6 +821,11 @@
     "started_on": "시작일",
     "file_upload_not_configured": "파일 업로드 설정이 구성되지 않았습니다."
   },
+  "audit_log_bulk_export": {
+    "download_expired": "다운로드 기간이 만료되었습니다",
+    "job_expired": "수출 프로세스가 너무 오래 걸려 취소되었습니다",
+    "no_results": "지정된 필터에 일치하는 감사 로그가 없습니다"
+  },
   "message": {
     "successfully_connected": "성공적으로 연결되었습니다!",
     "fail_to_save_access_token": "액세스 토큰 저장 실패. 다시 시도하십시오.",

+ 7 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -890,6 +890,13 @@
     "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
     "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "export": "导出",
+    "export_audit_log": "导出审核日志",
+    "export_requested": "导出请求已接受。导出完成后将通知您。",
+    "export_failed": "导出启动失败",
+    "duplicate_export_confirm": "已有相同条件的导出正在进行中。是否要重新启动它?",
+    "restart_export": "重新启动导出",
+    "confirm_export": "确认导出",
     "disable_mode_explanation_cloud": "审计日志当前已禁用。要启用它,请从 GROWI.cloud 管理界面更改应用程序设置。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"

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

@@ -866,6 +866,11 @@
     "started_on": "开始于",
     "file_upload_not_configured": "未配置文件上传设置"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "下载期限已过期",
+    "job_expired": "导出过程因耗时过长被取消",
+    "no_results": "没有审计日志符合指定筛选条件"
+  },
   "message": {
     "successfully_connected": "连接成功!",
     "fail_to_save_access_token": "无法保存访问令牌。请再试一次。",

+ 182 - 0
apps/app/src/client/components/Admin/AuditLog/AuditLogExportModal.tsx

@@ -0,0 +1,182 @@
+import { useCallback, useState } from 'react';
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
+
+import type { IAuditLogBulkExportRequestFilters } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { auditLogAvailableActionsAtom } from '~/states/server-configurations';
+
+import { DateRangePicker } from './DateRangePicker';
+import { DuplicateExportConfirmModal } from './DuplicateExportConfirmModal';
+import { SearchUsernameTypeahead } from './SearchUsernameTypeahead';
+import { SelectActionDropdown } from './SelectActionDropdown';
+import { useAuditLogExport } from './useAuditLogExport';
+
+type Props = {
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+const AuditLogExportModalSubstance = ({
+  onClose,
+}: {
+  onClose: () => void;
+}): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const auditLogAvailableActionsData = useAtomValue(
+    auditLogAvailableActionsAtom,
+  );
+
+  const [startDate, setStartDate] = useState<Date | null>(null);
+  const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
+  const [actionMap, setActionMap] = useState(
+    () =>
+      new Map<SupportedActionType, boolean>(
+        auditLogAvailableActionsData?.map((action) => [action, true]) ?? [],
+      ),
+  );
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback(
+    (action: SupportedActionType) => {
+      setActionMap((prev) => {
+        const next = new Map(prev);
+        next.set(action, !next.get(action));
+        return next;
+      });
+    },
+    [],
+  );
+
+  const multipleActionCheckboxChangedHandler = useCallback(
+    (actions: SupportedActionType[], isChecked: boolean) => {
+      setActionMap((prev) => {
+        const next = new Map(prev);
+        actions.forEach((action) => {
+          next.set(action, isChecked);
+        });
+        return next;
+      });
+    },
+    [],
+  );
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const buildFilters = useCallback(() => {
+    const selectedActionList = Array.from(actionMap.entries())
+      .filter((v) => v[1])
+      .map((v) => v[0]);
+
+    const filters: IAuditLogBulkExportRequestFilters = {};
+
+    if (selectedUsernames.length > 0) {
+      filters.usernames = selectedUsernames;
+    }
+    if (selectedActionList.length > 0) {
+      filters.actions = selectedActionList;
+    }
+    if (startDate != null) {
+      filters.dateFrom = startDate;
+    }
+    if (endDate != null) {
+      const endOfDay = new Date(endDate);
+      endOfDay.setHours(23, 59, 59, 999);
+      filters.dateTo = endOfDay;
+    }
+
+    return filters;
+  }, [actionMap, selectedUsernames, startDate, endDate]);
+
+  const {
+    isExporting,
+    isDuplicateConfirmOpen,
+    exportHandler,
+    restartExportHandler,
+    closeDuplicateConfirm,
+  } = useAuditLogExport(buildFilters, onClose);
+
+  return (
+    <>
+      <ModalHeader tag="h4" toggle={onClose}>
+        {t('audit_log_management.export_audit_log')}
+      </ModalHeader>
+
+      <ModalBody>
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.username')}</div>
+          <SearchUsernameTypeahead onChange={setUsernamesHandler} />
+        </div>
+
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.date')}</div>
+          <DateRangePicker
+            startDate={startDate}
+            endDate={endDate}
+            onChange={datePickerChangedHandler}
+          />
+        </div>
+
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.action')}</div>
+          <SelectActionDropdown
+            actionMap={actionMap}
+            availableActions={auditLogAvailableActionsData || []}
+            onChangeAction={actionCheckboxChangedHandler}
+            onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+          />
+        </div>
+      </ModalBody>
+
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('export_management.cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={exportHandler}
+          disabled={isExporting}
+        >
+          {isExporting ? (
+            <LoadingSpinner className="me-1 fs-3" />
+          ) : (
+            <span className="material-symbols-outlined me-1">download</span>
+          )}
+          {t('audit_log_management.export')}
+        </button>
+      </ModalFooter>
+
+      <DuplicateExportConfirmModal
+        isOpen={isDuplicateConfirmOpen}
+        onClose={closeDuplicateConfirm}
+        onRestart={restartExportHandler}
+      />
+    </>
+  );
+};
+
+export const AuditLogExportModal = ({
+  isOpen,
+  onClose,
+}: Props): JSX.Element => {
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      {isOpen && <AuditLogExportModalSubstance onClose={onClose} />}
+    </Modal>
+  );
+};

+ 39 - 0
apps/app/src/client/components/Admin/AuditLog/DuplicateExportConfirmModal.tsx

@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
+
+type Props = {
+  isOpen: boolean;
+  onClose: () => void;
+  onRestart: () => void;
+};
+
+export const DuplicateExportConfirmModal = ({
+  isOpen,
+  onClose,
+  onRestart,
+}: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose}>
+        {t('audit_log_management.confirm_export')}
+      </ModalHeader>
+      <ModalBody>
+        {t('audit_log_management.duplicate_export_confirm')}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('export_management.cancel')}
+        </button>
+        <button type="button" className="btn btn-primary" onClick={onRestart}>
+          {t('audit_log_management.restart_export')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 67 - 0
apps/app/src/client/components/Admin/AuditLog/useAuditLogExport.ts

@@ -0,0 +1,67 @@
+import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IAuditLogBulkExportFilters } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+
+export const useAuditLogExport = (
+  buildFilters: () => IAuditLogBulkExportFilters,
+  onClose: () => void,
+) => {
+  const { t } = useTranslation('admin');
+
+  const [isExporting, setIsExporting] = useState(false);
+  const [isDuplicateConfirmOpen, setIsDuplicateConfirmOpen] = useState(false);
+
+  const exportHandler = useCallback(async () => {
+    setIsExporting(true);
+    try {
+      const filters = buildFilters();
+      await apiv3Post('/audit-log-bulk-export', { filters });
+      toastSuccess(t('audit_log_management.export_requested'));
+      onClose();
+    } catch (errs) {
+      const isDuplicate =
+        Array.isArray(errs) &&
+        errs.some(
+          (e) => e.code === 'audit_log_bulk_export.duplicate_export_job_error',
+        );
+
+      if (isDuplicate) {
+        setIsDuplicateConfirmOpen(true);
+      } else {
+        toastError(t('audit_log_management.export_failed'));
+      }
+    } finally {
+      setIsExporting(false);
+    }
+  }, [buildFilters, t, onClose]);
+
+  const restartExportHandler = useCallback(async () => {
+    setIsDuplicateConfirmOpen(false);
+    setIsExporting(true);
+    try {
+      const filters = buildFilters();
+      await apiv3Post('/audit-log-bulk-export', { filters, restartJob: true });
+      toastSuccess(t('audit_log_management.export_requested'));
+      onClose();
+    } catch {
+      toastError(t('audit_log_management.export_failed'));
+    } finally {
+      setIsExporting(false);
+    }
+  }, [buildFilters, t, onClose]);
+
+  const closeDuplicateConfirm = useCallback(() => {
+    setIsDuplicateConfirmOpen(false);
+  }, []);
+
+  return {
+    isExporting,
+    isDuplicateConfirmOpen,
+    exportHandler,
+    restartExportHandler,
+    closeDuplicateConfirm,
+  };
+};

+ 19 - 0
apps/app/src/client/components/Admin/AuditLogManagement.tsx

@@ -19,6 +19,7 @@ import { useSWRxActivity } from '~/stores/activity';
 import PaginationWrapper from '../PaginationWrapper';
 import { ActivityTable } from './AuditLog/ActivityTable';
 import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
+import { AuditLogExportModal } from './AuditLog/AuditLogExportModal';
 import { AuditLogSettings } from './AuditLog/AuditLogSettings';
 import { DateRangePicker } from './AuditLog/DateRangePicker';
 import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
@@ -191,6 +192,8 @@ export const AuditLogManagement: FC = () => {
     setActivePageNumber(jumpPageNumber);
   }, [jumpPageNumber]);
 
+  const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
+
   const startIndex = activityList.length === 0 ? 0 : offset + 1;
   const endIndex = activityList.length === 0 ? 0 : offset + activityList.length;
 
@@ -283,6 +286,17 @@ export const AuditLogManagement: FC = () => {
                 {t('admin:audit_log_management.clear')}
               </button>
             </div>
+
+            <div className="col-12">
+              <button
+                type="button"
+                className="btn btn-outline-secondary"
+                onClick={() => setIsExportModalOpen(true)}
+              >
+                <span className="material-symbols-outlined me-1">download</span>
+                {t('admin:audit_log_management.export')}
+              </button>
+            </div>
           </div>
 
           <p className="ms-2">
@@ -331,6 +345,11 @@ export const AuditLogManagement: FC = () => {
               </button>
             </div>
           </div>
+
+          <AuditLogExportModal
+            isOpen={isExportModalOpen}
+            onClose={() => setIsExportModalOpen(false)}
+          />
         </>
       )}
     </div>

+ 98 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/AuditLogBulkExportJobModelNotification.tsx

@@ -0,0 +1,98 @@
+import React from 'react';
+import { type HasObjectId, isPopulated } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+
+import type { IAuditLogBulkExportJobHasId } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+import type { ModelNotificationUtils } from '.';
+import { ModelNotification } from './ModelNotification';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
+
+export const useAuditLogBulkExportJobModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
+  const { t } = useTranslation();
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
+
+  const isAuditLogBulkExportJobModelNotification = (
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IAuditLogBulkExportJobHasId> &
+    HasObjectId => {
+    return (
+      notification.targetModel ===
+      SupportedTargetModel.MODEL_AUDIT_LOG_BULK_EXPORT_JOB
+    );
+  };
+
+  if (!isAuditLogBulkExportJobModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = notification.user.username;
+
+  const getSubMsg = (): JSX.Element => {
+    if (
+      notification.action ===
+        SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED &&
+      notification.target == null
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('audit_log_bulk_export.download_expired')}</small>
+        </div>
+      );
+    }
+    if (
+      notification.action ===
+      SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('audit_log_bulk_export.job_expired')}</small>
+        </div>
+      );
+    }
+    if (
+      notification.action ===
+      SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('audit_log_bulk_export.no_results')}</small>
+        </div>
+      );
+    }
+    return <></>;
+  };
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+        hideActionUsers
+        hidePath
+        subMsg={getSubMsg()}
+      />
+    );
+  };
+
+  const clickLink =
+    notification.action ===
+      SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED &&
+    notification.target?.attachment != null &&
+    isPopulated(notification.target?.attachment)
+      ? notification.target.attachment.downloadPathProxied
+      : undefined;
+
+  return {
+    Notification,
+    clickLink,
+    isDisabled: notification.target == null,
+  };
+};

+ 5 - 1
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx

@@ -15,6 +15,7 @@ type Props = {
   actionIcon: string;
   actionUsers: string;
   hideActionUsers?: boolean;
+  hidePath?: boolean;
   subMsg?: JSX.Element;
 };
 
@@ -24,6 +25,7 @@ export const ModelNotification: FC<Props> = ({
   actionIcon,
   actionUsers,
   hideActionUsers = false,
+  hidePath = false,
   subMsg,
 }: Props) => {
   return (
@@ -31,7 +33,9 @@ export const ModelNotification: FC<Props> = ({
       <div className="text-truncate page-title">
         {hideActionUsers ? <></> : <b>{actionUsers}</b>}
         {` ${actionMsg}`}
-        <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
+        {!hidePath && (
+          <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
+        )}
       </div>
       {subMsg}
       <span className="material-symbols-outlined me-2">{actionIcon}</span>

+ 5 - 1
apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx

@@ -3,6 +3,7 @@ import type { HasObjectId } from '@growi/core';
 
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
+import { useAuditLogBulkExportJobModelNotification } from './AuditLogBulkExportJobModelNotification';
 import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
 import { usePageModelNotification } from './PageModelNotification';
 import { useUserModelNotification } from './UserModelNotification';
@@ -23,11 +24,14 @@ export const useModelNotification = (
   const userModelNotificationUtils = useUserModelNotification(notification);
   const pageBulkExportResultModelNotificationUtils =
     usePageBulkExportJobModelNotification(notification);
+  const auditLogBulkExportJobModelNotificationUtils =
+    useAuditLogBulkExportJobModelNotification(notification);
 
   const modelNotificationUtils =
     pageModelNotificationUtils ??
     userModelNotificationUtils ??
-    pageBulkExportResultModelNotificationUtils;
+    pageBulkExportResultModelNotificationUtils ??
+    auditLogBulkExportJobModelNotificationUtils;
 
   return modelNotificationUtils;
 };

+ 13 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts

@@ -81,6 +81,19 @@ export const useActionMsgAndIconForModelNotification = (
       actionMsg = 'export failed for';
       actionIcon = 'error';
       break;
+    case SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED:
+      actionMsg = 'audit log export completed';
+      actionIcon = 'download';
+      break;
+    case SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_FAILED:
+    case SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED:
+      actionMsg = 'audit log export failed';
+      actionIcon = 'error';
+      break;
+    case SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS:
+      actionMsg = 'audit log export had no results';
+      actionIcon = 'error';
+      break;
     default:
       actionMsg = '';
       actionIcon = '';

+ 56 - 0
apps/app/src/features/audit-log-bulk-export/interfaces/audit-log-bulk-export.ts

@@ -0,0 +1,56 @@
+import type { HasObjectId, IAttachment, IUser, Ref } from '@growi/core';
+
+import type { SupportedActionType } from '~/interfaces/activity';
+
+export const AuditLogBulkExportFormat = {
+  json: 'json',
+} as const;
+
+export type AuditLogBulkExportFormat =
+  (typeof AuditLogBulkExportFormat)[keyof typeof AuditLogBulkExportFormat];
+
+export const AuditLogBulkExportJobInProgressJobStatus = {
+  exporting: 'exporting',
+  uploading: 'uploading',
+} as const;
+
+export const AuditLogBulkExportJobStatus = {
+  ...AuditLogBulkExportJobInProgressJobStatus,
+  completed: 'completed',
+  failed: 'failed',
+} as const;
+
+export type AuditLogBulkExportJobStatus =
+  (typeof AuditLogBulkExportJobStatus)[keyof typeof AuditLogBulkExportJobStatus];
+
+export interface IAuditLogBulkExportRequestFilters {
+  usernames?: string[];
+  actions?: SupportedActionType[];
+  dateFrom?: Date;
+  dateTo?: Date;
+}
+export interface IAuditLogBulkExportFilters {
+  users?: Array<Ref<IUser>>;
+  actions?: SupportedActionType[];
+  dateFrom?: Date;
+  dateTo?: Date;
+}
+
+export interface IAuditLogBulkExportJob {
+  user: Ref<IUser>; // user who initiated the audit log export job
+  filters: IAuditLogBulkExportFilters; // filter conditions used for export (e.g. user, action, date range)
+  filterHash: string; // hash string generated from the filter set to detect duplicate export jobs
+  format: AuditLogBulkExportFormat; // export file format (currently only 'json' is supported)
+  status: AuditLogBulkExportJobStatus; // current status of the export job
+  lastExportedId?: string; // ID of the last exported audit log record
+  completedAt?: Date | null; // the date when the job was completed
+  restartFlag: boolean; // flag indicating whether this job is a restarted one
+  totalExportedCount?: number; // total number of exported audit log entries
+  createdAt?: Date;
+  updatedAt?: Date;
+  attachment?: Ref<IAttachment>;
+}
+
+export interface IAuditLogBulkExportJobHasId
+  extends IAuditLogBulkExportJob,
+    HasObjectId {}

+ 55 - 0
apps/app/src/features/audit-log-bulk-export/server/models/audit-log-bulk-export-job.ts

@@ -0,0 +1,55 @@
+import type { HydratedDocument } from 'mongoose';
+import { type Model, Schema } from 'mongoose';
+
+import { AllSupportedActions } from '~/interfaces/activity';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { IAuditLogBulkExportJob } from '../../interfaces/audit-log-bulk-export';
+import {
+  AuditLogBulkExportFormat,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+
+export type AuditLogBulkExportJobDocument =
+  HydratedDocument<IAuditLogBulkExportJob>;
+
+export type AuditLogBulkExportJobModel = Model<AuditLogBulkExportJobDocument>;
+
+const auditLogBulkExportJobSchema = new Schema<IAuditLogBulkExportJob>(
+  {
+    user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+    filters: {
+      type: {
+        users: [{ type: Schema.Types.ObjectId, ref: 'User' }],
+        actions: [{ type: String, enum: AllSupportedActions }],
+        dateFrom: { type: Date },
+        dateTo: { type: Date },
+      },
+      required: true,
+    },
+    filterHash: { type: String, required: true, index: true },
+    format: {
+      type: String,
+      enum: Object.values(AuditLogBulkExportFormat),
+      required: true,
+      default: AuditLogBulkExportFormat.json,
+    },
+    status: {
+      type: String,
+      enum: Object.values(AuditLogBulkExportJobStatus),
+      required: true,
+      default: AuditLogBulkExportJobStatus.exporting,
+    },
+    lastExportedId: { type: String },
+    completedAt: { type: Date },
+    restartFlag: { type: Boolean, required: true, default: false },
+    totalExportedCount: { type: Number, default: 0 },
+    attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
+  },
+  { timestamps: true },
+);
+
+export default getOrCreateModel<
+  AuditLogBulkExportJobDocument,
+  AuditLogBulkExportJobModel
+>('AuditLogBulkExportJob', auditLogBulkExportJobSchema);

+ 299 - 0
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.integ.ts

@@ -0,0 +1,299 @@
+import express, {
+  type NextFunction,
+  type Request,
+  type Response,
+} from 'express';
+import request from 'supertest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+import * as ServiceModule from '../../service/audit-log-bulk-export';
+import { auditLogBulkExportService } from '../../service/audit-log-bulk-export';
+import { factory } from './audit-log-bulk-export';
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: () => (_req: Request, _res: Response, next: NextFunction) => {
+    next();
+  },
+}));
+
+vi.mock('~/server/middlewares/apiv3-form-validator', () => {
+  const { validationResult } = require('express-validator');
+  return {
+    apiV3FormValidator: (req: Request, res: Response, next: NextFunction) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        const validationErrors = errors
+          .array()
+          .map((err: { param: string; msg: string }) => ({
+            message: `${err.param}: ${err.msg}`,
+            code: 'validation_failed',
+          }));
+        return (res as ApiV3Response).apiv3Err(validationErrors, 400);
+      }
+      return next();
+    },
+  };
+});
+
+vi.mock('../../service/audit-log-bulk-export', async () => {
+  const actual = await import('../../service/audit-log-bulk-export');
+  return {
+    ...actual,
+    auditLogBulkExportService: {
+      createOrResetExportJob: vi.fn(),
+    },
+  };
+});
+
+function buildCrowi(): Crowi {
+  const accessTokenParser =
+    () =>
+    (
+      req: Request & { user?: { _id: string } },
+      _res: Response,
+      next: NextFunction,
+    ) => {
+      req.user = { _id: '6561a1a1a1a1a1a1a1a1a1a1' };
+      next();
+    };
+
+  return { accessTokenParser } as unknown as Crowi;
+}
+
+function withApiV3Helpers(app: express.Express) {
+  app.use((_req, res, next) => {
+    (res as ApiV3Response).apiv3 = (body: unknown, status = 200) =>
+      res.status(status).json(body);
+
+    (res as ApiV3Response).apiv3Err = (
+      _err: unknown,
+      status = 500,
+      info?: unknown,
+    ) => {
+      let errors = Array.isArray(_err) ? _err : [_err];
+
+      errors = errors.map((e: unknown) => {
+        if (e && typeof e === 'object' && 'message' in e && 'code' in e) {
+          return e;
+        }
+        return e;
+      });
+
+      return res.status(status).json({ errors, info });
+    };
+
+    next();
+  });
+}
+
+function buildApp() {
+  const app = express();
+  app.use(express.json());
+  withApiV3Helpers(app);
+  const crowi = buildCrowi();
+  const router = factory(crowi);
+  app.use('/_api/v3/audit-log-bulk-export', router);
+  return app;
+}
+
+describe('POST /_api/v3/audit-log-bulk-export', () => {
+  const createOrReset =
+    auditLogBulkExportService.createOrResetExportJob as unknown as ReturnType<
+      typeof vi.fn
+    >;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('returns 201 with jobId on success', async () => {
+    createOrReset.mockResolvedValueOnce('job-123');
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+        restartJob: false,
+      });
+
+    expect(res.status).toBe(201);
+    expect(res.body).toEqual({ jobId: 'job-123' });
+
+    expect(createOrReset).toHaveBeenCalledTimes(1);
+    const [filters, format, userId, restartJob] = createOrReset.mock.calls[0];
+
+    expect(filters).toEqual({ actions: ['PAGE_VIEW'] });
+    expect(format).toBe('json');
+    expect(userId).toBeDefined();
+    expect(restartJob).toBe(false);
+  });
+
+  it('returns 409 with proper error code when DuplicateAuditLogBulkExportJobError is thrown', async () => {
+    const DuplicateErrCtor =
+      (
+        ServiceModule as {
+          DuplicateAuditLogBulkExportJobError?: new (
+            ...args: unknown[]
+          ) => Error;
+        }
+      ).DuplicateAuditLogBulkExportJobError ?? (() => {});
+    const err = Object.create(DuplicateErrCtor.prototype);
+    err.message = 'Duplicate audit-log bulk export job is in progress';
+    err.code = 'audit_log_bulk_export.duplicate_export_job_error';
+    err.duplicateJob = { createdAt: new Date('2025-10-01T00:00:00Z') };
+
+    createOrReset.mockRejectedValueOnce(err);
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+      });
+
+    expect(res.status).toBe(409);
+    expect(res.body?.errors).toBeDefined();
+    expect(res.body?.errors?.[0]?.code).toBe(
+      'audit_log_bulk_export.duplicate_export_job_error',
+    );
+    expect(res.body?.errors?.[0]?.args?.duplicateJob?.createdAt).toBeDefined();
+  });
+
+  it('returns 500 with proper error code when unexpected error occurs', async () => {
+    createOrReset.mockRejectedValueOnce(new Error('boom'));
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+      });
+
+    expect(res.status).toBe(500);
+    expect(res.body?.errors).toBeDefined();
+    expect(res.body?.errors?.[0]?.code).toBe(
+      'audit_log_bulk_export.failed_to_export',
+    );
+  });
+
+  describe('validation tests', () => {
+    it('returns 400 when filters is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when filters is not an object', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: 'invalid',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when usernames contains non-string values', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            usernames: [123, 456],
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when actions contains invalid action', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            actions: ['invalid-action'],
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when dateFrom is not a valid ISO date', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            dateFrom: 'invalid-date',
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when format is invalid', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: { actions: ['PAGE_VIEW'] },
+          format: 'invalid-format',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when restartJob is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: { actions: ['PAGE_VIEW'] },
+          restartJob: 'not-boolean',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('accepts valid request with all optional fields', async () => {
+      createOrReset.mockResolvedValueOnce('job-456');
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            users: ['6561a1a1a1a1a1a1a1a1a1a1'],
+            actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+            dateFrom: '2023-01-01T00:00:00Z',
+            dateTo: '2023-12-31T23:59:59Z',
+          },
+          format: 'json',
+          restartJob: true,
+        });
+
+      expect(res.status).toBe(201);
+      expect(res.body?.jobId).toBe('job-456');
+    });
+  });
+});

+ 117 - 0
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.ts

@@ -0,0 +1,117 @@
+import type { IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request } from 'express';
+import { Router } from 'express';
+import { body } from 'express-validator';
+
+import { AuditLogBulkExportFormat } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { AllSupportedActions } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import {
+  auditLogBulkExportService,
+  DuplicateAuditLogBulkExportJobError,
+} from '../../service/audit-log-bulk-export';
+
+const logger = loggerFactory('growi:routes:apiv3:audit-log-bulk-export');
+
+const router = Router();
+
+interface AuditLogExportReqBody {
+  filters: {
+    usernames?: string[];
+    actions?: SupportedActionType[];
+    dateFrom?: Date;
+    dateTo?: Date;
+  };
+  format?: (typeof AuditLogBulkExportFormat)[keyof typeof AuditLogBulkExportFormat];
+  restartJob?: boolean;
+}
+interface AuthorizedRequest
+  extends Request<undefined, ApiV3Response, AuditLogExportReqBody> {
+  user?: IUserHasId;
+}
+
+export const factory = (crowi: Crowi): Router => {
+  const accessTokenParser = crowi.accessTokenParser;
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+
+  const validators = {
+    auditLogBulkExport: [
+      body('filters').exists({ checkFalsy: true }).isObject(),
+      body('filters.usernames').optional({ nullable: true }).isArray(),
+      body('filters.usernames.*').optional({ nullable: true }).isString(),
+      body('filters.actions').optional({ nullable: true }).isArray(),
+      body('filters.actions.*')
+        .optional({ nullable: true })
+        .isString()
+        .isIn(AllSupportedActions),
+      body('filters.dateFrom')
+        .optional({ nullable: true })
+        .isISO8601()
+        .toDate(),
+      body('filters.dateTo').optional({ nullable: true }).isISO8601().toDate(),
+      body('format')
+        .optional({ nullable: true })
+        .isString()
+        .isIn(Object.values(AuditLogBulkExportFormat)),
+      body('restartJob').isBoolean().optional(),
+    ],
+  };
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.AUDIT_LOG]),
+    loginRequiredStrictly,
+    validators.auditLogBulkExport,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
+      const {
+        filters,
+        format = AuditLogBulkExportFormat.json,
+        restartJob,
+      } = req.body;
+
+      try {
+        const jobId = await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          format,
+          req.user?._id,
+          restartJob,
+        );
+        return res.apiv3({ jobId }, 201);
+      } catch (err) {
+        logger.error(err);
+
+        if (err instanceof DuplicateAuditLogBulkExportJobError) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Duplicate audit-log bulk export job is in progress',
+              'audit_log_bulk_export.duplicate_export_job_error',
+              undefined,
+              {
+                duplicateJob: {
+                  createdAt: err.duplicateJob.createdAt,
+                },
+              },
+            ),
+            409,
+          );
+        }
+
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to start audit-log bulk export',
+            'audit_log_bulk_export.failed_to_export',
+          ),
+        );
+      }
+    },
+  );
+  return router;
+};

+ 1 - 0
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/index.ts

@@ -0,0 +1 @@
+export { factory } from './audit-log-bulk-export';

+ 234 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron.integ.ts

@@ -0,0 +1,234 @@
+import type { IUser } from '@growi/core';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+
+import {
+  AuditLogBulkExportFormat,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+import AuditLogBulkExportJob from '../models/audit-log-bulk-export-job';
+import instantiateAuditLogBulkExportJobCleanUpCronService, {
+  auditLogBulkExportJobCleanUpCronService,
+} from './audit-log-bulk-export-job-clean-up-cron';
+
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
+const User = mongoose.model<IUser>('User', userSchema);
+
+vi.mock('./audit-log-bulk-export-job-cron', () => {
+  return {
+    auditLogBulkExportJobCronService: {
+      cleanUpExportJobResources: vi.fn(() => Promise.resolve()),
+      notifyExportResultAndCleanUp: vi.fn(() => Promise.resolve()),
+    },
+  };
+});
+
+describe('AuditLogBulkExportJobCleanUpCronService', () => {
+  const crowi = {} as Crowi;
+  let user: IUser;
+
+  beforeAll(async () => {
+    await configManager.loadConfigs();
+    user = await User.create({
+      name: 'Example for AuditLogBulkExportJobCleanUpCronService Test',
+      username: 'audit log bulk export job cleanup cron test user',
+      email: 'auditLogBulkExportCleanUpCronTestUser@example.com',
+    });
+    instantiateAuditLogBulkExportJobCleanUpCronService(crowi);
+  });
+
+  beforeEach(async () => {
+    await AuditLogBulkExportJob.deleteMany();
+  });
+
+  describe('deleteExpiredExportJobs', () => {
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    const jobId4 = new mongoose.Types.ObjectId();
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportJobExpirationSeconds',
+        86400,
+      );
+
+      await AuditLogBulkExportJob.insertMany([
+        {
+          _id: jobId1,
+          user,
+          filters: {},
+          filterHash: 'hash1',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          restartFlag: false,
+          createdAt: new Date(Date.now()),
+        },
+        {
+          _id: jobId2,
+          user,
+          filters: {},
+          filterHash: 'hash2',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          restartFlag: false,
+          createdAt: new Date(Date.now() - 86400 * 1000 - 1),
+        },
+        {
+          _id: jobId3,
+          user,
+          filters: {},
+          filterHash: 'hash3',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.uploading,
+          restartFlag: false,
+          createdAt: new Date(Date.now() - 86400 * 1000 - 2),
+        },
+        {
+          _id: jobId4,
+          user,
+          filters: {},
+          filterHash: 'hash4',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.failed,
+          restartFlag: false,
+        },
+      ]);
+    });
+
+    test('should delete expired jobs', async () => {
+      expect(await AuditLogBulkExportJob.find()).toHaveLength(4);
+
+      await auditLogBulkExportJobCleanUpCronService?.deleteExpiredExportJobs();
+      const jobs = await AuditLogBulkExportJob.find();
+
+      expect(jobs).toHaveLength(2);
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId4].sort(),
+      );
+    });
+  });
+
+  describe('deleteDownloadExpiredExportJobs', () => {
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    const jobId4 = new mongoose.Types.ObjectId();
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportDownloadExpirationSeconds',
+        86400,
+      );
+
+      await AuditLogBulkExportJob.insertMany([
+        {
+          _id: jobId1,
+          user,
+          filters: {},
+          filterHash: 'hash1',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.completed,
+          restartFlag: false,
+          completedAt: new Date(Date.now()),
+        },
+        {
+          _id: jobId2,
+          user,
+          filters: {},
+          filterHash: 'hash2',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.completed,
+          restartFlag: false,
+          completedAt: new Date(Date.now() - 86400 * 1000 - 1),
+        },
+        {
+          _id: jobId3,
+          user,
+          filters: {},
+          filterHash: 'hash3',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          restartFlag: false,
+        },
+        {
+          _id: jobId4,
+          user,
+          filters: {},
+          filterHash: 'hash4',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.failed,
+          restartFlag: false,
+        },
+      ]);
+    });
+
+    test('should delete download expired jobs', async () => {
+      expect(await AuditLogBulkExportJob.find()).toHaveLength(4);
+
+      await auditLogBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+      const jobs = await AuditLogBulkExportJob.find();
+
+      expect(jobs).toHaveLength(3);
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId3, jobId4].sort(),
+      );
+    });
+  });
+
+  describe('deleteFailedExportJobs', () => {
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    beforeEach(async () => {
+      await AuditLogBulkExportJob.insertMany([
+        {
+          _id: jobId1,
+          user,
+          filters: {},
+          filterHash: 'hash1',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.failed,
+          restartFlag: false,
+        },
+        {
+          _id: jobId2,
+          user,
+          filters: {},
+          filterHash: 'hash2',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          restartFlag: false,
+        },
+        {
+          _id: jobId3,
+          user,
+          filters: {},
+          filterHash: 'hash3',
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.failed,
+          restartFlag: false,
+        },
+      ]);
+    });
+
+    test('should delete failed export jobs', async () => {
+      expect(await AuditLogBulkExportJob.find()).toHaveLength(3);
+
+      await auditLogBulkExportJobCleanUpCronService?.deleteFailedExportJobs();
+      const jobs = await AuditLogBulkExportJob.find();
+
+      expect(jobs).toHaveLength(1);
+      expect(jobs.map((job) => job._id)).toStrictEqual([jobId2]);
+    });
+  });
+});

+ 155 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron.ts

@@ -0,0 +1,155 @@
+import type { HydratedDocument } from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import {
+  AuditLogBulkExportJobInProgressJobStatus,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+import type { AuditLogBulkExportJobDocument } from '../models/audit-log-bulk-export-job';
+import AuditLogBulkExportJob from '../models/audit-log-bulk-export-job';
+import { auditLogBulkExportJobCronService } from './audit-log-bulk-export-job-cron';
+
+const logger = loggerFactory(
+  'growi:service:audit-log-bulk-export-job-clean-up-cron',
+);
+
+/**
+ * Manages cronjob which deletes unnecessary audit log bulk export jobs
+ */
+class AuditLogBulkExportJobCleanUpCronService extends CronService {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  override getCronSchedule(): string {
+    return '0 */6 * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    await this.deleteExpiredExportJobs();
+    await this.deleteDownloadExpiredExportJobs();
+    await this.deleteFailedExportJobs();
+  }
+
+  /**
+   * Delete audit log bulk export jobs which are on-going and has passed the limit time for execution
+   */
+  async deleteExpiredExportJobs() {
+    const exportJobExpirationSeconds = configManager.getConfig(
+      'app:bulkExportJobExpirationSeconds',
+    );
+
+    const thresholdDate = new Date(
+      Date.now() - exportJobExpirationSeconds * 1000,
+    );
+
+    const expiredExportJobs = await AuditLogBulkExportJob.find({
+      $or: Object.values(AuditLogBulkExportJobInProgressJobStatus).map(
+        (status) => ({
+          status,
+        }),
+      ),
+      createdAt: {
+        $lt: thresholdDate,
+      },
+    });
+
+    if (auditLogBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(
+        expiredExportJobs,
+        auditLogBulkExportJobCronService.cleanUpExportJobResources.bind(
+          auditLogBulkExportJobCronService,
+        ),
+      );
+    }
+  }
+
+  /**
+   * Delete audit log bulk export jobs which have completed but the due time for downloading has passed
+   */
+  async deleteDownloadExpiredExportJobs() {
+    const downloadExpirationSeconds = configManager.getConfig(
+      'app:bulkExportDownloadExpirationSeconds',
+    );
+    const thresholdDate = new Date(
+      Date.now() - downloadExpirationSeconds * 1000,
+    );
+
+    const downloadExpiredExportJobs = await AuditLogBulkExportJob.find({
+      status: AuditLogBulkExportJobStatus.completed,
+      completedAt: { $lt: thresholdDate },
+    });
+
+    const cleanUp = async (job: AuditLogBulkExportJobDocument) => {
+      await auditLogBulkExportJobCronService?.cleanUpExportJobResources(job);
+
+      const hasSameAttachmentAndDownloadNotExpired =
+        await AuditLogBulkExportJob.findOne({
+          attachment: job.attachment,
+          _id: { $ne: job._id },
+          completedAt: { $gte: thresholdDate },
+        });
+      if (hasSameAttachmentAndDownloadNotExpired == null) {
+        await this.crowi.attachmentService?.removeAttachment(job.attachment);
+      }
+    };
+
+    await this.cleanUpAndDeleteBulkExportJobs(
+      downloadExpiredExportJobs,
+      cleanUp,
+    );
+  }
+
+  /**
+   * Delete audit log bulk export jobs which have failed
+   */
+  async deleteFailedExportJobs() {
+    const failedExportJobs = await AuditLogBulkExportJob.find({
+      status: AuditLogBulkExportJobStatus.failed,
+    });
+
+    if (auditLogBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(
+        failedExportJobs,
+        auditLogBulkExportJobCronService.cleanUpExportJobResources.bind(
+          auditLogBulkExportJobCronService,
+        ),
+      );
+    }
+  }
+
+  async cleanUpAndDeleteBulkExportJobs(
+    auditLogBulkExportJobs: HydratedDocument<AuditLogBulkExportJobDocument>[],
+    cleanUp: (job: AuditLogBulkExportJobDocument) => Promise<void>,
+  ): Promise<void> {
+    const results = await Promise.allSettled(
+      auditLogBulkExportJobs.map((job) => cleanUp(job)),
+    );
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+
+    const cleanedUpJobs = auditLogBulkExportJobs.filter(
+      (_, index) => results[index].status === 'fulfilled',
+    );
+    if (cleanedUpJobs.length > 0) {
+      const cleanedUpJobIds = cleanedUpJobs.map((job) => job._id);
+      await AuditLogBulkExportJob.deleteMany({ _id: { $in: cleanedUpJobIds } });
+    }
+  }
+}
+
+export let auditLogBulkExportJobCleanUpCronService:
+  | AuditLogBulkExportJobCleanUpCronService
+  | undefined;
+export default function instantiate(crowi: Crowi): void {
+  auditLogBulkExportJobCleanUpCronService =
+    new AuditLogBulkExportJobCleanUpCronService(crowi);
+}

+ 751 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/audit-log-bulk-export-job-cron-service.integ.ts

@@ -0,0 +1,751 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { PassThrough } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import type { IUser } from '@growi/core';
+import mongoose from 'mongoose';
+import type { MockedFunction } from 'vitest';
+import {
+  afterAll,
+  afterEach,
+  beforeAll,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from 'vitest';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { ResponseMode } from '~/server/interfaces/attachment';
+import Activity, { type ActivityDocument } from '~/server/models/activity';
+import type { IAttachmentDocument } from '~/server/models/attachment';
+import { Attachment } from '~/server/models/attachment';
+import { configManager } from '~/server/service/config-manager';
+import type { FileUploader } from '~/server/service/file-uploader/file-uploader';
+import { MultipartUploader } from '~/server/service/file-uploader/multipart-uploader';
+
+import {
+  AuditLogBulkExportFormat,
+  AuditLogBulkExportJobStatus,
+} from '../../../interfaces/audit-log-bulk-export';
+import AuditLogBulkExportJob, {
+  type AuditLogBulkExportJobDocument,
+} from '../../models/audit-log-bulk-export-job';
+import {
+  AuditLogBulkExportJobExpiredError,
+  AuditLogBulkExportJobRestartedError,
+} from './errors';
+import instanciateAuditLogBulkExportJobCronService, {
+  auditLogBulkExportJobCronService,
+} from './index';
+
+type ExportedActivityData = Pick<
+  ActivityDocument,
+  '_id' | 'action' | 'user'
+> & {
+  createdAt: Date;
+};
+
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
+const User = mongoose.model<IUser>('User', userSchema);
+
+async function waitForCondition(
+  condition: () => boolean | Promise<boolean>,
+  {
+    timeoutMs = 2000,
+    intervalMs = 50,
+  }: { timeoutMs?: number; intervalMs?: number } = {},
+): Promise<void> {
+  const start = Date.now();
+
+  while (true) {
+    if (await condition()) return;
+
+    if (Date.now() - start > timeoutMs) {
+      throw new Error('waitForCondition: timeout exceeded');
+    }
+
+    await new Promise((resolve) => setTimeout(resolve, intervalMs));
+  }
+}
+
+async function waitForJobStatus(
+  jobId: mongoose.Types.ObjectId,
+  status: AuditLogBulkExportJobStatus,
+): Promise<AuditLogBulkExportJobDocument> {
+  let latest: AuditLogBulkExportJobDocument | null = null;
+
+  await waitForCondition(async () => {
+    latest = await AuditLogBulkExportJob.findById(jobId);
+    return latest?.status === status;
+  });
+
+  if (!latest) {
+    throw new Error('Job not found after waitForCondition succeeded');
+  }
+  return latest;
+}
+
+class MockMultipartUploader extends MultipartUploader {
+  override get uploadId(): string {
+    return 'mock-upload-id';
+  }
+
+  override async initUpload(): Promise<void> {}
+  override async uploadPart(
+    _part: Buffer,
+    _partNumber: number,
+  ): Promise<void> {}
+  override async completeUpload(): Promise<void> {}
+  override async abortUpload(): Promise<void> {}
+  override async getUploadedFileSize(): Promise<number> {
+    return 0;
+  }
+}
+
+const mockFileUploadService: FileUploader = {
+  uploadAttachment: vi.fn(),
+  getIsUploadable: vi.fn(() => true),
+  isWritable: vi.fn(() => Promise.resolve(true)),
+  getIsReadable: vi.fn(() => true),
+  isValidUploadSettings: vi.fn(() => true),
+  getFileUploadEnabled: vi.fn(() => true),
+  listFiles: vi.fn(() => []),
+  saveFile: vi.fn(() => Promise.resolve()),
+  deleteFile: vi.fn(),
+  deleteFiles: vi.fn(),
+  getFileUploadTotalLimit: vi.fn(() => 1024 * 1024 * 1024),
+  getTotalFileSize: vi.fn(() => Promise.resolve(0)),
+  checkLimit: vi.fn(() => Promise.resolve({ isUploadable: true })),
+  determineResponseMode: vi.fn(() => ResponseMode.REDIRECT),
+  respond: vi.fn(),
+  findDeliveryFile: vi.fn(() => Promise.resolve(new PassThrough())),
+  generateTemporaryUrl: vi.fn(() =>
+    Promise.resolve({ url: 'mock-url', lifetimeSec: 3600 }),
+  ),
+  createMultipartUploader: vi.fn(
+    (uploadKey: string, maxPartSize: number) =>
+      new MockMultipartUploader(uploadKey, maxPartSize),
+  ),
+  abortPreviousMultipartUpload: vi.fn(() => Promise.resolve()),
+};
+
+const mockActivityService = {
+  createActivity: vi.fn(() => Promise.resolve({ _id: 'mock-activity-id' })),
+};
+
+const mockEventEmitter = {
+  emit: vi.fn(),
+};
+
+type MockCrowi = Pick<Crowi, 'fileUploadService'> & {
+  events: { activity: typeof mockEventEmitter };
+  activityService: typeof mockActivityService;
+};
+
+const createMockCrowi = (): MockCrowi => ({
+  fileUploadService: mockFileUploadService,
+  events: { activity: mockEventEmitter },
+  activityService: mockActivityService,
+});
+
+describe('AuditLogBulkExportJobCronService Integration Test', () => {
+  let cronService: NonNullable<typeof auditLogBulkExportJobCronService>;
+  let crowi: MockCrowi;
+  let testUser: IUser & mongoose.Document;
+  let testTmpDir: string;
+  let uploadAttachmentSpy: MockedFunction<
+    (
+      readable: NodeJS.ReadableStream,
+      attachment: IAttachmentDocument,
+    ) => Promise<void>
+  >;
+
+  const testActivities = [
+    {
+      action: SupportedAction.ACTION_PAGE_CREATE,
+      user: null,
+      createdAt: new Date('2023-01-01T10:00:00Z'),
+      snapshot: { username: 'testuser' },
+    },
+    {
+      action: SupportedAction.ACTION_PAGE_UPDATE,
+      user: null,
+      createdAt: new Date('2023-01-02T10:00:00Z'),
+      snapshot: { username: 'testuser' },
+    },
+    {
+      action: SupportedAction.ACTION_PAGE_DELETE,
+      user: null,
+      createdAt: new Date('2023-01-03T10:00:00Z'),
+      snapshot: { username: 'testuser' },
+    },
+    ...Array.from({ length: 50 }, (_, i) => {
+      const baseDate = new Date('2023-01-04T10:00:00Z');
+      const activityDate = new Date(baseDate.getTime() + i * 60000);
+      return {
+        action: SupportedAction.ACTION_PAGE_VIEW,
+        user: null,
+        createdAt: activityDate,
+        snapshot: { username: 'testuser' },
+      };
+    }),
+  ];
+
+  beforeAll(async () => {
+    await configManager.loadConfigs();
+
+    testUser = await User.create({
+      name: 'Test User for Audit Log Export',
+      username: 'auditlogexportcrontest',
+      email: 'auditlogexportcrontest@example.com',
+    });
+
+    testActivities.forEach((activity) => {
+      activity.user = testUser._id;
+    });
+  });
+
+  beforeEach(async () => {
+    crowi = createMockCrowi();
+    instanciateAuditLogBulkExportJobCronService(crowi as unknown as Crowi);
+    if (!auditLogBulkExportJobCronService) {
+      throw new Error('auditLogBulkExportJobCronService was not initialized');
+    }
+    cronService = auditLogBulkExportJobCronService;
+
+    testTmpDir = fs.mkdtempSync(path.join('/tmp', 'audit-log-export-test-'));
+    cronService.tmpOutputRootDir = testTmpDir;
+
+    cronService.maxLogsPerFile = 10;
+    cronService.pageBatchSize = 5;
+
+    uploadAttachmentSpy = vi
+      .fn()
+      .mockImplementation(
+        async (
+          readable: NodeJS.ReadableStream,
+          attachment: IAttachmentDocument,
+        ) => {
+          const passThrough = new PassThrough();
+          let totalSize = 0;
+
+          passThrough.on('data', (chunk) => {
+            totalSize += chunk.length;
+          });
+
+          await pipeline(readable, passThrough);
+
+          attachment.fileSize = totalSize;
+        },
+      );
+    mockFileUploadService.uploadAttachment = uploadAttachmentSpy;
+
+    await Activity.insertMany(testActivities);
+  });
+
+  afterEach(async () => {
+    await Activity.deleteMany({});
+    await AuditLogBulkExportJob.deleteMany({});
+    await Attachment.deleteMany({});
+
+    if (fs.existsSync(testTmpDir)) {
+      fs.rmSync(testTmpDir, { recursive: true, force: true });
+    }
+
+    vi.clearAllMocks();
+  });
+
+  afterAll(async () => {
+    await User.deleteOne({ _id: testUser._id });
+  });
+
+  describe('1. Basic Operations (Happy Path)', () => {
+    describe('1-1. No Filter → Export → ZIP → Upload', () => {
+      it('should export all activities, create JSON files, and upload ZIP', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'test-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        const outputDir = cronService.getTmpOutputDir(afterExport);
+        let hasFiles = false;
+        let jsonFiles: string[] = [];
+
+        if (fs.existsSync(outputDir)) {
+          const files = fs.readdirSync(outputDir);
+          jsonFiles = files.filter((file) => file.endsWith('.json'));
+          hasFiles = jsonFiles.length > 0;
+        }
+
+        if (hasFiles) {
+          expect(jsonFiles.length).toBeGreaterThan(0);
+
+          const firstFile = path.join(outputDir, jsonFiles[0]);
+          const content = JSON.parse(fs.readFileSync(firstFile, 'utf8'));
+          expect(Array.isArray(content)).toBe(true);
+          expect(content.length).toBeLessThanOrEqual(
+            cronService.maxLogsPerFile,
+          );
+        }
+
+        await cronService.proceedBulkExportJob(afterExport);
+        await waitForCondition(() => uploadAttachmentSpy.mock.calls.length > 0);
+
+        expect(uploadAttachmentSpy).toHaveBeenCalledTimes(1);
+        const [readable, attachment] = uploadAttachmentSpy.mock.calls[0];
+        expect(readable).toBeDefined();
+        expect(attachment.originalName).toMatch(/audit-logs-.*\.zip$/);
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+        expect([
+          AuditLogBulkExportJobStatus.uploading,
+          AuditLogBulkExportJobStatus.completed,
+        ]).toContain(updatedJob?.status);
+        expect(updatedJob?.totalExportedCount).toBeGreaterThan(0);
+      });
+    });
+
+    describe('1-2. With Filters (actions / dateFrom / dateTo / users)', () => {
+      it('should export only filtered activities', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {
+            actions: [
+              SupportedAction.ACTION_PAGE_CREATE,
+              SupportedAction.ACTION_PAGE_UPDATE,
+            ],
+            dateFrom: new Date('2023-01-01T00:00:00Z'),
+            dateTo: new Date('2023-01-02T23:59:59Z'),
+            users: [testUser._id.toString()],
+          },
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'filtered-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        const outputDir = cronService.getTmpOutputDir(afterExport);
+        const files = fs.readdirSync(outputDir);
+        const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+        if (jsonFiles.length > 0) {
+          const content = JSON.parse(
+            fs.readFileSync(path.join(outputDir, jsonFiles[0]), 'utf8'),
+          );
+
+          content.forEach((activity: ExportedActivityData) => {
+            expect([
+              SupportedAction.ACTION_PAGE_CREATE,
+              SupportedAction.ACTION_PAGE_UPDATE,
+            ]).toContain(activity.action);
+            expect(new Date(activity.createdAt)).toBeInstanceOf(Date);
+            expect(activity.user).toBe(testUser._id.toString());
+          });
+        }
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+        expect(updatedJob?.totalExportedCount).toBeLessThanOrEqual(2);
+      });
+    });
+
+    describe('1-3. Zero Results', () => {
+      it('should handle cases with no matching activities', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {
+            actions: [SupportedAction.ACTION_USER_LOGOUT],
+          },
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'no-match-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        const notifySpy = vi.spyOn(cronService, 'notifyExportResultAndCleanUp');
+
+        await cronService.proceedBulkExportJob(job);
+        await waitForCondition(async () => {
+          const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+          return updatedJob?.status !== AuditLogBulkExportJobStatus.exporting;
+        });
+
+        const afterExport = await AuditLogBulkExportJob.findById(job._id);
+        if (!afterExport) {
+          throw new Error('Job not found after export phase');
+        }
+
+        const outputDir = cronService.getTmpOutputDir(afterExport);
+        const files = fs.existsSync(outputDir) ? fs.readdirSync(outputDir) : [];
+        const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+        expect(jsonFiles.length).toBeLessThanOrEqual(1);
+
+        expect(afterExport.totalExportedCount).toBe(0);
+
+        expect(notifySpy).toHaveBeenCalledWith(
+          SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS,
+          expect.objectContaining({ _id: job._id }),
+        );
+      });
+    });
+  });
+
+  describe('2. Resumability', () => {
+    describe('2-1. Resume from lastExportedId', () => {
+      it('should resume export from the last exported ID without duplicates', async () => {
+        const activities = await Activity.find({}).sort({ _id: 1 });
+        const middleIndex = Math.floor(activities.length / 2);
+        const lastExportedId = activities[middleIndex]._id.toString();
+
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'resume-hash',
+          restartFlag: false,
+          totalExportedCount: middleIndex,
+          lastExportedId: lastExportedId,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        const outputDir = cronService.getTmpOutputDir(afterExport);
+        const files = fs.readdirSync(outputDir);
+        const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+        if (jsonFiles.length > 0) {
+          const allExportedActivities: ExportedActivityData[] = [];
+
+          for (const file of jsonFiles) {
+            const content = JSON.parse(
+              fs.readFileSync(path.join(outputDir, file), 'utf8'),
+            );
+            allExportedActivities.push(...content);
+          }
+
+          allExportedActivities.forEach((activity) => {
+            expect(activity._id).not.toBe(lastExportedId);
+            expect(
+              new mongoose.Types.ObjectId(activity._id).getTimestamp(),
+            ).toBeInstanceOf(Date);
+          });
+        }
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+        expect(updatedJob?.totalExportedCount).toBeGreaterThan(middleIndex);
+      });
+    });
+
+    describe('2-2. totalExportedCount and lastExportedId Updates', () => {
+      it('should properly update totalExportedCount and lastExportedId', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'count-test-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        const initialCount = job.totalExportedCount ?? 0;
+
+        await cronService.proceedBulkExportJob(job);
+        const updatedJob = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+        expect(updatedJob?.totalExportedCount).toBeGreaterThan(initialCount);
+        expect(updatedJob?.lastExportedId).toBeDefined();
+
+        const totalActivities = await Activity.countDocuments({});
+        expect(updatedJob?.totalExportedCount).toBeLessThanOrEqual(
+          totalActivities,
+        );
+      });
+    });
+  });
+
+  describe('3. Upload and Compression', () => {
+    describe('3-1. ZIP Content Validity', () => {
+      it('should create valid ZIP with JSON files in root', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'zip-test-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        await cronService.proceedBulkExportJob(afterExport);
+        await waitForCondition(() => uploadAttachmentSpy.mock.calls.length > 0);
+
+        expect(uploadAttachmentSpy).toHaveBeenCalledTimes(1);
+        const [readable, attachment] = uploadAttachmentSpy.mock.calls[0];
+        expect(readable).toBeDefined();
+        expect(attachment.fileName).toMatch(/\.zip$/);
+      });
+    });
+
+    describe('3-2. Upload Failure Handling', () => {
+      it('should handle upload failures gracefully', async () => {
+        uploadAttachmentSpy.mockImplementationOnce(async (readable) => {
+          readable.on('error', () => {});
+          readable.resume();
+          throw new Error('Upload failed');
+        });
+
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.uploading,
+          filterHash: 'upload-fail-hash',
+          restartFlag: false,
+          totalExportedCount: 10,
+        });
+
+        const notifySpy = vi.spyOn(cronService, 'notifyExportResultAndCleanUp');
+        const cleanSpy = vi.spyOn(cronService, 'cleanUpExportJobResources');
+        const handleSpy = vi.spyOn(cronService, 'handleError');
+
+        await expect(
+          cronService.proceedBulkExportJob(job),
+        ).resolves.toBeUndefined();
+
+        expect(uploadAttachmentSpy).toHaveBeenCalledTimes(1);
+        expect(handleSpy).toHaveBeenCalledTimes(1);
+        expect(notifySpy).toHaveBeenCalledWith(
+          expect.anything(),
+          expect.objectContaining({ _id: job._id }),
+        );
+        expect(cleanSpy).toHaveBeenCalledWith(
+          expect.objectContaining({ _id: job._id }),
+        );
+
+        const reloaded = await AuditLogBulkExportJob.findById(job._id).lean();
+        expect(reloaded?.status).toBe(AuditLogBulkExportJobStatus.failed);
+
+        const s = cronService.getStreamInExecution(job._id);
+        expect(s).toBeUndefined();
+      });
+    });
+  });
+
+  describe('4. Error Handling', () => {
+    describe('4-1. Nonexistent Users Filter', () => {
+      it('should fail with no results for nonexistent usernames', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {
+            users: [new mongoose.Types.ObjectId()],
+          },
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'bad-user-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        await waitForCondition(async () => {
+          const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+          return updatedJob?.status === AuditLogBulkExportJobStatus.failed;
+        });
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+        expect(updatedJob?.status).toBe(AuditLogBulkExportJobStatus.failed);
+      });
+    });
+
+    describe('4-2. Stream/FS Errors', () => {
+      it('should handle filesystem errors', async () => {
+        cronService.tmpOutputRootDir = '/invalid/path/that/does/not/exist';
+
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'fs-error-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await expect(async () => {
+          await cronService.proceedBulkExportJob(job);
+        }).not.toThrow();
+      });
+    });
+
+    describe('4-3. Job Expiry and Restart Errors', () => {
+      it('should handle AuditLogBulkExportJobExpiredError', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'expired-error-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        const expiredError = new AuditLogBulkExportJobExpiredError();
+
+        await cronService.handleError(expiredError, job);
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+        expect(updatedJob?.status).toBe(AuditLogBulkExportJobStatus.failed);
+      });
+
+      it('should handle AuditLogBulkExportJobRestartedError', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'restarted-error-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        const restartedError = new AuditLogBulkExportJobRestartedError();
+
+        await cronService.handleError(restartedError, job);
+      });
+    });
+  });
+
+  describe('5. State Transitions and Execution Control', () => {
+    describe('5-1. State Flow', () => {
+      it('should follow correct state transitions: exporting → uploading → completed', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'state-flow-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        expect(job.status).toBe(AuditLogBulkExportJobStatus.exporting);
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        expect(afterExport?.status).toBe(AuditLogBulkExportJobStatus.uploading);
+
+        await cronService.proceedBulkExportJob(afterExport);
+        await waitForCondition(() => uploadAttachmentSpy.mock.calls.length > 0);
+
+        await cronService.notifyExportResultAndCleanUp(
+          SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED,
+          afterExport,
+        );
+
+        const finalJob = await AuditLogBulkExportJob.findById(job._id);
+        expect(finalJob?.status).toBe(AuditLogBulkExportJobStatus.completed);
+      });
+    });
+
+    describe('5-2. Stream Lifecycle', () => {
+      it('should properly manage stream execution lifecycle', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'stream-lifecycle-hash',
+          restartFlag: false,
+          totalExportedCount: 0,
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        const afterExport = await waitForJobStatus(
+          job._id,
+          AuditLogBulkExportJobStatus.uploading,
+        );
+
+        await cronService.cleanUpExportJobResources(afterExport);
+        const streamAfterCleanup = cronService.getStreamInExecution(job._id);
+        expect(streamAfterCleanup).toBeUndefined();
+      });
+    });
+
+    describe('5-3. Restart Flag Handling', () => {
+      it('should handle restartFlag correctly', async () => {
+        const job = await AuditLogBulkExportJob.create({
+          user: testUser._id,
+          filters: {},
+          format: AuditLogBulkExportFormat.json,
+          status: AuditLogBulkExportJobStatus.exporting,
+          filterHash: 'restart-flag-hash',
+          restartFlag: true,
+          totalExportedCount: 50,
+          lastExportedId: 'some-previous-id',
+        });
+
+        await cronService.proceedBulkExportJob(job);
+        await waitForCondition(async () => {
+          const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+          return updatedJob?.restartFlag === false;
+        });
+
+        const updatedJob = await AuditLogBulkExportJob.findById(job._id);
+
+        expect(updatedJob?.restartFlag).toBe(false);
+        expect(updatedJob?.totalExportedCount).toBe(0);
+        expect(updatedJob?.lastExportedId).toBeUndefined();
+        expect(updatedJob?.status).toBe(AuditLogBulkExportJobStatus.exporting);
+      });
+    });
+  });
+});

+ 11 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/errors.ts

@@ -0,0 +1,11 @@
+export class AuditLogBulkExportJobExpiredError extends Error {
+  constructor() {
+    super('Audit-log-bulk-export job has expired');
+  }
+}
+
+export class AuditLogBulkExportJobRestartedError extends Error {
+  constructor() {
+    super('Audit-log-bulk-export job has restarted');
+  }
+}

+ 297 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/index.ts

@@ -0,0 +1,297 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { Readable } from 'node:stream';
+import type { IUser } from '@growi/core';
+import { getIdForRef, isPopulated } from '@growi/core';
+import type archiver from 'archiver';
+import mongoose from 'mongoose';
+
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import {
+  AuditLogBulkExportJobInProgressJobStatus,
+  AuditLogBulkExportJobStatus,
+} from '../../../interfaces/audit-log-bulk-export';
+import type { AuditLogBulkExportJobDocument } from '../../models/audit-log-bulk-export-job';
+import AuditLogBulkExportJob from '../../models/audit-log-bulk-export-job';
+import {
+  AuditLogBulkExportJobExpiredError,
+  AuditLogBulkExportJobRestartedError,
+} from './errors';
+
+const logger = loggerFactory('growi:service:audit-log-export-job-cron');
+
+export interface IAuditLogBulkExportJobCronService {
+  crowi: Crowi;
+  activityEvent: NodeJS.EventEmitter;
+  tmpOutputRootDir: string;
+  pageBatchSize: number;
+  maxLogsPerFile: number;
+  compressFormat: archiver.Format;
+  compressLevel: number;
+  proceedBulkExportJob(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ): Promise<void>;
+  getTmpOutputDir(auditLogBulkExportJob: AuditLogBulkExportJobDocument): string;
+  getStreamInExecution(jobId: ObjectIdLike): Readable | undefined;
+  setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
+  removeStreamInExecution(jobId: ObjectIdLike): void;
+  notifyExportResultAndCleanUp(
+    action: SupportedActionType,
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ): Promise<void>;
+  handleError(
+    err: Error | null,
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ): Promise<void>;
+  cleanUpExportJobResources(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+    restarted?: boolean,
+  ): Promise<void>;
+}
+
+import type { ActivityDocument } from '~/server/models/activity';
+import { preNotifyService } from '~/server/service/pre-notify';
+
+import { compressAndUpload } from './steps/compress-and-upload';
+import { exportAuditLogsToFsAsync } from './steps/exportAuditLogsToFsAsync';
+
+/**
+ * Manages cronjob which proceeds AuditLogBulkExportJobs in progress.
+ * If AuditLogBulkExportJob finishes the current step, the next step will be started on the next cron execution.
+ */
+class AuditLogBulkExportJobCronService
+  extends CronService
+  implements IAuditLogBulkExportJobCronService
+{
+  crowi: Crowi;
+
+  activityEvent: NodeJS.EventEmitter;
+
+  private parallelExecLimit: number;
+
+  tmpOutputRootDir = '/tmp/audit-log-bulk-export';
+
+  pageBatchSize = 100;
+
+  maxLogsPerFile = 50;
+
+  compressFormat: archiver.Format = 'zip';
+
+  compressLevel = 6;
+
+  private streamInExecutionMemo: { [key: string]: Readable } = {};
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+    this.activityEvent = crowi.events.activity;
+    this.parallelExecLimit = 1;
+  }
+
+  override getCronSchedule(): string {
+    return '*/10 * * * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    const auditLogBulkExportJobInProgress = await AuditLogBulkExportJob.find({
+      $or: Object.values(AuditLogBulkExportJobInProgressJobStatus).map(
+        (status) => ({
+          status,
+        }),
+      ),
+    })
+      .sort({ createdAt: 1 })
+      .limit(this.parallelExecLimit);
+    await Promise.all(
+      auditLogBulkExportJobInProgress.map((auditLogBulkExportJob) =>
+        this.proceedBulkExportJob(auditLogBulkExportJob),
+      ),
+    );
+  }
+
+  async proceedBulkExportJob(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ) {
+    try {
+      if (auditLogBulkExportJob.restartFlag) {
+        await this.cleanUpExportJobResources(auditLogBulkExportJob, true);
+        auditLogBulkExportJob.restartFlag = false;
+        auditLogBulkExportJob.status = AuditLogBulkExportJobStatus.exporting;
+        auditLogBulkExportJob.lastExportedId = undefined;
+        auditLogBulkExportJob.totalExportedCount = 0;
+        await auditLogBulkExportJob.save();
+        return;
+      }
+      const User = mongoose.model<IUser>('User');
+      const user = await User.findById(getIdForRef(auditLogBulkExportJob.user));
+
+      if (!user) {
+        throw new Error(
+          `User not found for audit log export job: ${auditLogBulkExportJob._id}`,
+        );
+      }
+
+      if (
+        auditLogBulkExportJob.status === AuditLogBulkExportJobStatus.exporting
+      ) {
+        await exportAuditLogsToFsAsync.bind(this)(auditLogBulkExportJob);
+      } else if (
+        auditLogBulkExportJob.status === AuditLogBulkExportJobStatus.uploading
+      ) {
+        await compressAndUpload.bind(this)(user, auditLogBulkExportJob);
+      }
+    } catch (err) {
+      logger.error(err);
+    }
+  }
+
+  getTmpOutputDir(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ): string {
+    const jobId = auditLogBulkExportJob._id.toString();
+    return path.join(this.tmpOutputRootDir, jobId);
+  }
+
+  /**
+   * Get the stream in execution for a job.
+   * A getter method that includes "undefined" in the return type
+   */
+  getStreamInExecution(jobId: ObjectIdLike): Readable | undefined {
+    return this.streamInExecutionMemo[jobId.toString()];
+  }
+
+  /**
+   * Set the stream in execution for a job
+   */
+  setStreamInExecution(jobId: ObjectIdLike, stream: Readable) {
+    this.streamInExecutionMemo[jobId.toString()] = stream;
+  }
+
+  /**
+   * Remove the stream in execution for a job
+   */
+  removeStreamInExecution(jobId: ObjectIdLike) {
+    delete this.streamInExecutionMemo[jobId.toString()];
+  }
+
+  async notifyExportResultAndCleanUp(
+    action: SupportedActionType,
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ): Promise<void> {
+    auditLogBulkExportJob.status =
+      action === SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED
+        ? AuditLogBulkExportJobStatus.completed
+        : AuditLogBulkExportJobStatus.failed;
+
+    try {
+      await auditLogBulkExportJob.save();
+      await this.notifyExportResult(auditLogBulkExportJob, action);
+    } catch (err) {
+      logger.error(err);
+    }
+    await this.cleanUpExportJobResources(auditLogBulkExportJob);
+  }
+
+  private async notifyExportResult(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+    action: SupportedActionType,
+  ) {
+    logger.debug(
+      'Creating activity with targetModel:',
+      SupportedTargetModel.MODEL_AUDIT_LOG_BULK_EXPORT_JOB,
+    );
+    const activity = await this.crowi.activityService.createActivity({
+      action,
+      targetModel: SupportedTargetModel.MODEL_AUDIT_LOG_BULK_EXPORT_JOB,
+      target: auditLogBulkExportJob,
+      user: auditLogBulkExportJob.user,
+      snapshot: {
+        username: isPopulated(auditLogBulkExportJob.user)
+          ? auditLogBulkExportJob.user.username
+          : '',
+      },
+    });
+    const getAdditionalTargetUsers = async (activity: ActivityDocument) => [
+      activity.user,
+    ];
+    const preNotify = preNotifyService.generatePreNotify(
+      activity,
+      getAdditionalTargetUsers,
+    );
+    this.activityEvent.emit(
+      'updated',
+      activity,
+      auditLogBulkExportJob,
+      preNotify,
+    );
+  }
+
+  async handleError(
+    err: Error | null,
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  ) {
+    if (err == null) return;
+
+    if (err instanceof AuditLogBulkExportJobExpiredError) {
+      logger.error(err);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED,
+        auditLogBulkExportJob,
+      );
+    } else if (err instanceof AuditLogBulkExportJobRestartedError) {
+      logger.info(err.message);
+      await this.cleanUpExportJobResources(auditLogBulkExportJob);
+    } else {
+      logger.error(err);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_FAILED,
+        auditLogBulkExportJob,
+      );
+    }
+  }
+
+  async cleanUpExportJobResources(
+    auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+    restarted = false,
+  ) {
+    const streamInExecution = this.getStreamInExecution(
+      auditLogBulkExportJob._id,
+    );
+    if (streamInExecution != null) {
+      if (restarted) {
+        streamInExecution.destroy(new AuditLogBulkExportJobRestartedError());
+      } else {
+        streamInExecution.destroy(new AuditLogBulkExportJobExpiredError());
+      }
+      this.removeStreamInExecution(auditLogBulkExportJob._id);
+    }
+
+    const promises = [
+      fs.promises.rm(this.getTmpOutputDir(auditLogBulkExportJob), {
+        recursive: true,
+        force: true,
+      }),
+    ];
+
+    const results = await Promise.allSettled(promises);
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+  }
+}
+
+// eslint-disable-next-line import/no-mutable-exports
+export let auditLogBulkExportJobCronService:
+  | AuditLogBulkExportJobCronService
+  | undefined;
+export default function instantiate(crowi: Crowi): void {
+  auditLogBulkExportJobCronService = new AuditLogBulkExportJobCronService(
+    crowi,
+  );
+}

+ 104 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -0,0 +1,104 @@
+import type { IUser } from '@growi/core';
+import type { Archiver } from 'archiver';
+import archiver from 'archiver';
+
+import { AuditLogBulkExportJobStatus } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
+import {
+  Attachment,
+  type IAttachmentDocument,
+} from '~/server/models/attachment';
+import type { FileUploader } from '~/server/service/file-uploader';
+import loggerFactory from '~/utils/logger';
+
+import type { AuditLogBulkExportJobDocument } from '../../../models/audit-log-bulk-export-job';
+import type { IAuditLogBulkExportJobCronService } from '..';
+
+const logger = loggerFactory(
+  'growi:service:audit-log-export-job-cron:compress-and-upload-async',
+);
+
+function setUpAuditLogArchiver(
+  this: IAuditLogBulkExportJobCronService,
+): Archiver {
+  const auditLogArchiver = archiver(this.compressFormat, {
+    zlib: { level: this.compressLevel },
+  });
+
+  // good practice to catch warnings (ie stat failures and other non-blocking errors)
+  auditLogArchiver.on('warning', (err) => {
+    if (err.code === 'ENOENT') {
+      logger.error(err);
+    } else {
+      auditLogArchiver.emit('error', err);
+    }
+  });
+
+  return auditLogArchiver;
+}
+
+async function postProcess(
+  this: IAuditLogBulkExportJobCronService,
+  auditLogBulkExportJob: AuditLogBulkExportJobDocument,
+  attachment: IAttachmentDocument,
+  fileSize: number,
+): Promise<void> {
+  attachment.fileSize = fileSize;
+  await attachment.save();
+
+  auditLogBulkExportJob.completedAt = new Date();
+  auditLogBulkExportJob.attachment = attachment._id;
+  auditLogBulkExportJob.status = AuditLogBulkExportJobStatus.completed;
+  await auditLogBulkExportJob.save();
+
+  this.removeStreamInExecution(auditLogBulkExportJob._id);
+  await this.notifyExportResultAndCleanUp(
+    SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED,
+    auditLogBulkExportJob,
+  );
+}
+
+/**
+ * Execute a pipeline that reads the audit log files from the temporal fs directory,
+ * compresses them into a zip file, and uploads to the cloud storage.
+ */
+export async function compressAndUpload(
+  this: IAuditLogBulkExportJobCronService,
+  user: IUser,
+  job: AuditLogBulkExportJobDocument,
+): Promise<void> {
+  const auditLogArchiver = setUpAuditLogArchiver.bind(this)();
+
+  if (job.filterHash == null) throw new Error('filterHash is not set');
+
+  const originalName = `audit-logs-${job.filterHash}.zip`;
+  const attachment = Attachment.createWithoutSave(
+    null,
+    user,
+    originalName,
+    this.compressFormat,
+    0,
+    AttachmentType.AUDIT_LOG_BULK_EXPORT,
+  );
+  const fileUploadService: FileUploader = this.crowi.fileUploadService;
+
+  auditLogArchiver.directory(this.getTmpOutputDir(job), false);
+  auditLogArchiver.finalize();
+
+  this.setStreamInExecution(job._id, auditLogArchiver);
+  try {
+    await fileUploadService.uploadAttachment(auditLogArchiver, attachment);
+  } catch (e) {
+    logger.error(e);
+    try {
+      await this.handleError(e as Error, job);
+    } catch (handleErrorErr) {
+      logger.error('Error in handleError:', handleErrorErr);
+    }
+    job.status = AuditLogBulkExportJobStatus.failed;
+    await job.save();
+    return;
+  }
+  await postProcess.bind(this)(job, attachment, auditLogArchiver.pointer());
+}

+ 139 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron/steps/exportAuditLogsToFsAsync.ts

@@ -0,0 +1,139 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { Readable } from 'node:stream';
+import { pipeline, Writable } from 'node:stream';
+import type { FilterQuery } from 'mongoose';
+
+import { AuditLogBulkExportJobStatus } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import { SupportedAction } from '~/interfaces/activity';
+import Activity, { type ActivityDocument } from '~/server/models/activity';
+
+import type { AuditLogBulkExportJobDocument } from '../../../models/audit-log-bulk-export-job';
+import type { IAuditLogBulkExportJobCronService } from '..';
+
+/**
+ * Get a Writable that writes audit logs to JSON files
+ */
+function getAuditLogWritable(
+  this: IAuditLogBulkExportJobCronService,
+  job: AuditLogBulkExportJobDocument,
+): Writable {
+  const outputDir = this.getTmpOutputDir(job);
+  let buffer: ActivityDocument[] = [];
+  let fileIndex = 0;
+  return new Writable({
+    objectMode: true,
+    write: async (log: ActivityDocument, _encoding, callback) => {
+      try {
+        buffer.push(log);
+
+        // Update lastExportedId for resumability
+        job.lastExportedId = log._id.toString();
+        job.totalExportedCount = (job.totalExportedCount || 0) + 1;
+
+        if (buffer.length >= this.maxLogsPerFile) {
+          const filePath = path.join(
+            outputDir,
+            `audit-logs-${job._id.toString()}-${String(fileIndex).padStart(2, '0')}.json`,
+          );
+          await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
+          await fs.promises.writeFile(
+            filePath,
+            JSON.stringify(buffer, null, 2),
+          );
+
+          await job.save();
+
+          buffer = [];
+          fileIndex++;
+        }
+      } catch (err) {
+        callback(err as Error);
+        return;
+      }
+      callback();
+    },
+    final: async (callback) => {
+      try {
+        if (buffer.length > 0) {
+          const filePath = path.join(
+            outputDir,
+            `audit-logs-${job._id.toString()}-${String(fileIndex).padStart(2, '0')}.json`,
+          );
+          await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
+          await fs.promises.writeFile(
+            filePath,
+            JSON.stringify(buffer, null, 2),
+          );
+        }
+        job.status = AuditLogBulkExportJobStatus.uploading;
+        await job.save();
+      } catch (err) {
+        callback(err as Error);
+        return;
+      }
+      callback();
+    },
+  });
+}
+
+/**
+ * Export audit logs to the file system before compressing and uploading.
+ */
+export async function exportAuditLogsToFsAsync(
+  this: IAuditLogBulkExportJobCronService,
+  job: AuditLogBulkExportJobDocument,
+): Promise<void> {
+  const filters = job.filters ?? {};
+  const query: FilterQuery<ActivityDocument> = {};
+
+  // Build query filters for searching activity logs based on user-defined filters
+  if (filters.actions && filters.actions.length > 0) {
+    query.action = { $in: filters.actions };
+  }
+  if (filters.dateFrom || filters.dateTo) {
+    query.createdAt = {};
+    if (filters.dateFrom) {
+      query.createdAt.$gte = new Date(filters.dateFrom);
+    }
+    if (filters.dateTo) {
+      query.createdAt.$lte = new Date(filters.dateTo);
+    }
+  }
+  if (filters.users && filters.users.length > 0) {
+    query.user = { $in: filters.users };
+  }
+
+  // If the previous export was incomplete, resume from the last exported ID by adding it to the query filter
+  if (job.lastExportedId) {
+    query._id = { $gt: job.lastExportedId };
+  }
+
+  const hasAny = await Activity.exists(query);
+  if (!hasAny) {
+    job.totalExportedCount = 0;
+    job.status = AuditLogBulkExportJobStatus.completed;
+    job.lastExportedId = undefined;
+    await job.save();
+
+    await this.notifyExportResultAndCleanUp(
+      SupportedAction.ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS,
+      job,
+    );
+    return;
+  }
+
+  const logsCursor = Activity.find(query)
+
+    .sort({ _id: 1 })
+    .lean()
+    .cursor({ batchSize: this.pageBatchSize });
+
+  const writable = getAuditLogWritable.bind(this)(job);
+
+  this.setStreamInExecution(job._id, logsCursor as unknown as Readable);
+
+  pipeline(logsCursor, writable, (err) => {
+    this.handleError(err, job);
+  });
+}

+ 335 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.integ.ts

@@ -0,0 +1,335 @@
+import mongoose from 'mongoose';
+
+import type { SupportedActionType } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
+
+import {
+  AuditLogBulkExportFormat,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+import AuditLogBulkExportJob from '../models/audit-log-bulk-export-job';
+import {
+  auditLogBulkExportService,
+  DuplicateAuditLogBulkExportJobError,
+} from './audit-log-bulk-export';
+
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
+const User = mongoose.model('User', userSchema);
+
+describe('AuditLogBulkExportService', () => {
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
+  let user;
+
+  beforeAll(async () => {
+    await configManager.loadConfigs();
+    user = await User.create({
+      name: 'Example for AuditLogBulkExportService Test',
+      username: 'audit bulk export test user',
+      email: 'auditBulkExportTestUser@example.com',
+    });
+  });
+
+  afterEach(async () => {
+    await AuditLogBulkExportJob.deleteMany({});
+  });
+
+  afterAll(async () => {
+    await User.deleteOne({ _id: user._id });
+  });
+
+  describe('createOrResetExportJob', () => {
+    describe('normal cases', () => {
+      it('should create a new export job with valid parameters', async () => {
+        const filters: {
+          actions: SupportedActionType[];
+          dateFrom: Date;
+          dateTo: Date;
+        } = {
+          actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+          dateFrom: new Date('2023-01-01'),
+          dateTo: new Date('2023-12-31'),
+        };
+
+        const jobId = await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        );
+
+        expect(jobId).toMatch(/^[0-9a-fA-F]{24}$/);
+
+        const createdJob = await AuditLogBulkExportJob.findById(jobId);
+        expect(createdJob).toBeDefined();
+        expect(createdJob?.user).toEqual(user._id);
+        expect(createdJob?.format).toBe(AuditLogBulkExportFormat.json);
+        expect(createdJob?.status).toBe(AuditLogBulkExportJobStatus.exporting);
+        expect(createdJob?.totalExportedCount).toBe(0);
+        expect(createdJob?.filters).toMatchObject({
+          actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+          dateFrom: new Date('2023-01-01T00:00:00.000Z'),
+          dateTo: new Date('2023-12-31T00:00:00.000Z'),
+        });
+      });
+
+      it('should create a job with minimal filters', async () => {
+        const filters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+
+        const jobId = await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        );
+
+        const createdJob = await AuditLogBulkExportJob.findById(jobId);
+        expect(createdJob).toBeDefined();
+        expect(createdJob?.format).toBe(AuditLogBulkExportFormat.json);
+        expect(createdJob?.filters).toMatchObject({
+          actions: ['PAGE_VIEW'],
+        });
+      });
+
+      it('should create a job with user filters', async () => {
+        const filters: { usernames: string[]; actions: SupportedActionType[] } =
+          {
+            usernames: [user.username],
+            actions: ['PAGE_CREATE'],
+          };
+
+        const jobId = await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        );
+
+        const createdJob = await AuditLogBulkExportJob.findById(jobId);
+        expect(createdJob?.filters.actions).toEqual(['PAGE_CREATE']);
+        expect(createdJob?.filters.users?.map(String)).toContain(
+          user._id.toString(),
+        );
+      });
+
+      it('should reset existing job when restartJob is true', async () => {
+        const filters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+
+        const firstJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        const secondJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+            true,
+          );
+
+        expect(secondJobId).toBe(firstJobId);
+
+        const job = await AuditLogBulkExportJob.findById(firstJobId);
+        expect(job?.restartFlag).toBe(true);
+      });
+    });
+
+    describe('error cases', () => {
+      it('should throw DuplicateAuditLogBulkExportJobError when duplicate job exists', async () => {
+        const filters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+
+        await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        );
+
+        await expect(
+          auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          ),
+        ).rejects.toThrow(DuplicateAuditLogBulkExportJobError);
+      });
+
+      it('should allow creating job with same filters for different user', async () => {
+        const anotherUser = await User.create({
+          name: 'Another User',
+          username: 'another user',
+          email: 'another@example.com',
+        });
+
+        const filters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+
+        const firstJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        const secondJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            anotherUser._id,
+          );
+
+        expect(firstJobId).not.toBe(secondJobId);
+
+        await User.deleteOne({ _id: anotherUser._id });
+      });
+
+      it('should allow creating job with different filters for same user', async () => {
+        const firstFilters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+        const secondFilters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_CREATE'],
+        };
+
+        const firstJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            firstFilters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        const secondJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            secondFilters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        expect(firstJobId).not.toBe(secondJobId);
+      });
+
+      it('should not throw error if previous job is completed', async () => {
+        const filters: { actions: SupportedActionType[] } = {
+          actions: ['PAGE_VIEW'],
+        };
+
+        const firstJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        const firstJob = await AuditLogBulkExportJob.findById(firstJobId);
+        if (firstJob) {
+          firstJob.status = AuditLogBulkExportJobStatus.completed;
+          await firstJob.save();
+        }
+
+        const secondJobId =
+          await auditLogBulkExportService.createOrResetExportJob(
+            filters,
+            AuditLogBulkExportFormat.json,
+            user._id,
+          );
+
+        expect(secondJobId).not.toBe(firstJobId);
+      });
+    });
+  });
+
+  describe('resetExportJob', () => {
+    it('should set restartFlag to true', async () => {
+      const filters = { actions: ['PAGE_VIEW'] as SupportedActionType[] };
+
+      const jobId = await auditLogBulkExportService.createOrResetExportJob(
+        filters,
+        AuditLogBulkExportFormat.json,
+        user._id,
+      );
+
+      const job = await AuditLogBulkExportJob.findById(jobId);
+      expect(job?.restartFlag).toBeFalsy();
+
+      if (job) {
+        await auditLogBulkExportService.resetExportJob(job);
+      }
+
+      const updatedJob = await AuditLogBulkExportJob.findById(jobId);
+      expect(updatedJob?.restartFlag).toBe(true);
+    });
+  });
+
+  describe('filter canonicalization', () => {
+    it('should generate same job for logically equivalent filters', async () => {
+      const filters1: { actions: SupportedActionType[]; usernames: string[] } =
+        {
+          actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+          usernames: ['alice', 'bob'],
+        };
+
+      const filters2: { actions: SupportedActionType[]; usernames: string[] } =
+        {
+          actions: ['PAGE_CREATE', 'PAGE_VIEW'],
+          usernames: ['bob', 'alice'],
+        };
+
+      await auditLogBulkExportService.createOrResetExportJob(
+        filters1,
+        AuditLogBulkExportFormat.json,
+        user._id,
+      );
+
+      await expect(
+        auditLogBulkExportService.createOrResetExportJob(
+          filters2,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        ),
+      ).rejects.toThrow(DuplicateAuditLogBulkExportJobError);
+    });
+
+    it('should normalize date formats consistently', async () => {
+      const dateString = '2023-01-01T00:00:00.000Z';
+      const dateObject = new Date(dateString);
+
+      const filters1: { actions: SupportedActionType[]; dateFrom: Date } = {
+        actions: ['PAGE_VIEW'],
+        dateFrom: new Date(dateString),
+      };
+
+      const filters2: { actions: SupportedActionType[]; dateFrom: Date } = {
+        actions: ['PAGE_VIEW'],
+        dateFrom: dateObject,
+      };
+
+      await auditLogBulkExportService.createOrResetExportJob(
+        filters1,
+        AuditLogBulkExportFormat.json,
+        user._id,
+      );
+
+      await expect(
+        auditLogBulkExportService.createOrResetExportJob(
+          filters2,
+          AuditLogBulkExportFormat.json,
+          user._id,
+        ),
+      ).rejects.toThrow(DuplicateAuditLogBulkExportJobError);
+    });
+  });
+});

+ 135 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.ts

@@ -0,0 +1,135 @@
+import { createHash } from 'node:crypto';
+import mongoose from 'mongoose';
+
+import type {
+  AuditLogBulkExportFormat,
+  IAuditLogBulkExportFilters,
+  IAuditLogBulkExportRequestFilters,
+} from '../../interfaces/audit-log-bulk-export';
+import {
+  AuditLogBulkExportJobInProgressJobStatus,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+import type { AuditLogBulkExportJobDocument } from '../models/audit-log-bulk-export-job';
+import AuditLogBulkExportJob from '../models/audit-log-bulk-export-job';
+
+export interface IAuditLogBulkExportService {
+  createOrResetExportJob: (
+    requestFilters: IAuditLogBulkExportRequestFilters,
+    format: AuditLogBulkExportFormat,
+    currentUser,
+    restartJob?: boolean,
+  ) => Promise<string>;
+  resetExportJob: (job: AuditLogBulkExportJobDocument) => Promise<void>;
+}
+
+/** ============================== utils ============================== */
+
+/**
+ * Normalizes filter values to ensure that logically equivalent filters,
+ * regardless of order or formatting differences, generate the same hash.
+ */
+function canonicalizeFilters(filters: IAuditLogBulkExportFilters) {
+  const normalized: Record<string, unknown> = {};
+
+  if (filters.users?.length) {
+    normalized.users = filters.users.map(String).sort();
+  }
+  if (filters.actions?.length) {
+    normalized.actions = [...filters.actions].sort();
+  }
+  if (filters.dateFrom) {
+    normalized.dateFrom = new Date(filters.dateFrom).toISOString();
+  }
+  if (filters.dateTo) {
+    normalized.dateTo = new Date(filters.dateTo).toISOString();
+  }
+  return normalized;
+}
+
+/**
+ * Generates a SHA-256 hash used to uniquely identify a set of filters.
+ * Requests with the same input produce the same hash value,
+ * preventing duplicate audit-log export jobs from being executed.
+ */
+function sha256(input: string): string {
+  return createHash('sha256').update(input).digest('hex');
+}
+
+/** ============================== error ============================== */
+
+export class DuplicateAuditLogBulkExportJobError extends Error {
+  duplicateJob: AuditLogBulkExportJobDocument;
+
+  constructor(duplicateJob: AuditLogBulkExportJobDocument) {
+    super('Duplicate audit-log bulk export job is in progress');
+    this.duplicateJob = duplicateJob;
+  }
+}
+
+/** ============================== service ============================== */
+
+class AuditLogBulkExportService implements IAuditLogBulkExportService {
+  /**
+   * Create a new audit-log bulk export job or reset the existing one
+   */
+  async createOrResetExportJob(
+    requestFilters: IAuditLogBulkExportRequestFilters,
+    format: AuditLogBulkExportFormat,
+    currentUser,
+    restartJob?: boolean,
+  ): Promise<string> {
+    const filters: IAuditLogBulkExportFilters = {
+      actions: requestFilters.actions,
+      dateFrom: requestFilters.dateFrom,
+      dateTo: requestFilters.dateTo,
+    };
+    if (requestFilters.usernames?.length) {
+      const User = mongoose.model('User');
+      const userIds = await User.find({
+        username: { $in: requestFilters.usernames },
+      }).distinct('_id');
+      filters.users = userIds;
+    }
+
+    const normalizedFilters = canonicalizeFilters(filters);
+    const filterHash = sha256(JSON.stringify(normalizedFilters));
+
+    const duplicateInProgress: AuditLogBulkExportJobDocument | null =
+      await AuditLogBulkExportJob.findOne({
+        user: { $eq: currentUser },
+        filterHash,
+        $or: Object.values(AuditLogBulkExportJobInProgressJobStatus).map(
+          (status) => ({ status }),
+        ),
+      });
+
+    if (duplicateInProgress != null) {
+      if (restartJob) {
+        await this.resetExportJob(duplicateInProgress);
+        return duplicateInProgress._id.toString();
+      }
+      throw new DuplicateAuditLogBulkExportJobError(duplicateInProgress);
+    }
+
+    const createdJob = await AuditLogBulkExportJob.create({
+      user: currentUser,
+      filters,
+      filterHash,
+      format,
+      status: AuditLogBulkExportJobStatus.exporting,
+      totalExportedCount: 0,
+    });
+    return createdJob._id.toString();
+  }
+
+  /**
+   * Reset audit-log export job in progress
+   */
+  async resetExportJob(job: AuditLogBulkExportJobDocument): Promise<void> {
+    job.restartFlag = true;
+    await job.save();
+  }
+}
+
+export const auditLogBulkExportService = new AuditLogBulkExportService(); // singleton

+ 42 - 0
apps/app/src/features/audit-log-bulk-export/server/service/check-audit-log-bulk-export-job-in-progress-cron.ts

@@ -0,0 +1,42 @@
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+
+import { AuditLogBulkExportJobInProgressJobStatus } from '../../interfaces/audit-log-bulk-export';
+import AuditLogExportJob from '../models/audit-log-bulk-export-job';
+import { auditLogBulkExportJobCronService } from './audit-log-bulk-export-job-cron';
+
+/**
+ * Manages cronjob which checks if AuditLogExportJob in progress exists.
+ * If it does, and AuditLogExportJobCronService is not running, start AuditLogExportJobCronService
+ */
+class CheckAuditLogBulkExportJobInProgressCronService extends CronService {
+  override getCronSchedule(): string {
+    return '*/3 * * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    const isAuditLogEnabled = configManager.getConfig('app:auditLogEnabled');
+    if (!isAuditLogEnabled) return;
+
+    const auditLogExportJobInProgress = await AuditLogExportJob.findOne({
+      $or: Object.values(AuditLogBulkExportJobInProgressJobStatus).map(
+        (status) => ({
+          status,
+        }),
+      ),
+    });
+    const auditLogExportInProgressExists = auditLogExportJobInProgress != null;
+
+    if (
+      auditLogExportInProgressExists &&
+      !auditLogBulkExportJobCronService?.isJobRunning()
+    ) {
+      auditLogBulkExportJobCronService?.startCron();
+    } else if (!auditLogExportInProgressExists) {
+      auditLogBulkExportJobCronService?.stopCron();
+    }
+  }
+}
+
+export const checkAuditLogExportJobInProgressCronService =
+  new CheckAuditLogBulkExportJobInProgressCronService();

+ 17 - 0
apps/app/src/interfaces/activity.ts

@@ -13,6 +13,7 @@ const MODEL_PAGE = 'Page';
 const MODEL_USER = 'User';
 const MODEL_COMMENT = 'Comment';
 const MODEL_PAGE_BULK_EXPORT_JOB = 'PageBulkExportJob';
+const MODEL_AUDIT_LOG_BULK_EXPORT_JOB = 'AuditLogBulkExportJob';
 
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
@@ -67,6 +68,13 @@ const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
 const ACTION_PAGE_BULK_EXPORT_COMPLETED = 'PAGE_BULK_EXPORT_COMPLETED';
 const ACTION_PAGE_BULK_EXPORT_FAILED = 'PAGE_BULK_EXPORT_FAILED';
 const ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED = 'PAGE_BULK_EXPORT_JOB_EXPIRED';
+const ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED =
+  'AUDIT_LOG_BULK_EXPORT_COMPLETED';
+const ACTION_AUDIT_LOG_BULK_EXPORT_FAILED = 'AUDIT_LOG_BULK_EXPORT_FAILED';
+const ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED =
+  'AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED';
+const ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS =
+  'ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS';
 const ACTION_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN =
   'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
@@ -198,6 +206,7 @@ export const SupportedTargetModel = {
   MODEL_PAGE,
   MODEL_USER,
   MODEL_PAGE_BULK_EXPORT_JOB,
+  MODEL_AUDIT_LOG_BULK_EXPORT_JOB,
 } as const;
 
 export const SupportedEventModel = {
@@ -372,6 +381,10 @@ export const SupportedAction = {
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
   ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_FAILED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS,
 } as const;
 
 // Action required for notification
@@ -394,6 +407,10 @@ export const EssentialActionGroup = {
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
   ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_COMPLETED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_FAILED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_JOB_EXPIRED,
+  ACTION_AUDIT_LOG_BULK_EXPORT_NO_RESULTS,
 } as const;
 
 export const ActionGroupSize = {

+ 17 - 0
apps/app/src/server/crowi/index.ts

@@ -7,6 +7,9 @@ import lsxRoutes from '@growi/remark-lsx/dist/server/index.cjs';
 import type { Express } from 'express';
 import mongoose from 'mongoose';
 
+import instantiateAuditLogBulkExportJobCleanUpCronService from '~/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron';
+import instantiateAuditLogBulkExportJobCronService from '~/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron';
+import { checkAuditLogExportJobInProgressCronService } from '~/features/audit-log-bulk-export/server/service/check-audit-log-bulk-export-job-in-progress-cron';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
@@ -436,6 +439,20 @@ class Crowi {
     }
     pageBulkExportJobCleanUpCronService.startCron();
 
+    instantiateAuditLogBulkExportJobCronService(this);
+    checkAuditLogExportJobInProgressCronService.startCron();
+
+    instantiateAuditLogBulkExportJobCleanUpCronService(this);
+    const { auditLogBulkExportJobCleanUpCronService } = await import(
+      '~/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron'
+    );
+    if (auditLogBulkExportJobCleanUpCronService == null) {
+      throw new Error(
+        'auditLogBulkExportJobCleanUpCronService is not initialized',
+      );
+    }
+    auditLogBulkExportJobCleanUpCronService.startCron();
+
     startOpenaiCronIfEnabled();
     startAccessTokenCron();
   }

+ 2 - 0
apps/app/src/server/interfaces/attachment.ts

@@ -3,6 +3,7 @@ export const AttachmentType = {
   WIKI_PAGE: 'WIKI_PAGE',
   PROFILE_IMAGE: 'PROFILE_IMAGE',
   PAGE_BULK_EXPORT: 'PAGE_BULK_EXPORT',
+  AUDIT_LOG_BULK_EXPORT: 'AUDIT_LOG_BULK_EXPORT',
 } as const;
 
 export type AttachmentType =
@@ -35,4 +36,5 @@ export const FilePathOnStoragePrefix = {
   attachment: 'attachment',
   user: 'user',
   pageBulkExport: 'page-bulk-export',
+  auditLogBulkExport: 'audit-log-bulk-export',
 } as const;

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

@@ -1,3 +1,4 @@
+import { factory as auditLogBulkExportRouteFactory } from '~/features/audit-log-bulk-export/server/routes/apiv3';
 import growiPlugin from '~/features/growi-plugin/server/routes/apiv3/admin';
 import { factory as openaiRouteFactory } from '~/features/openai/server/routes';
 import { allreadyInstalledMiddleware } from '~/server/middlewares/application-not-installed';
@@ -185,6 +186,7 @@ module.exports = (crowi, app) => {
       crowi,
     ),
   );
+  router.use('/audit-log-bulk-export', auditLogBulkExportRouteFactory(crowi));
 
   router.use('/openai', openaiRouteFactory(crowi));
 

+ 3 - 2
apps/app/src/server/service/in-app-notification.ts

@@ -3,6 +3,7 @@ import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns/subDays';
 import type { FilterQuery, Types, UpdateQuery } from 'mongoose';
 
+import type { IAuditLogBulkExportJob } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { AllEssentialActions } from '~/interfaces/activity';
 import type { PaginateResult } from '~/interfaces/in-app-notification';
@@ -48,7 +49,7 @@ export default class InAppNotificationService {
       'updated',
       async (
         activity: ActivityDocument,
-        target: IUser | IPage | IPageBulkExportJob,
+        target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
         preNotify: PreNotify,
       ) => {
         try {
@@ -224,7 +225,7 @@ export default class InAppNotificationService {
 
   createInAppNotification = async function (
     activity: ActivityDocument,
-    target: IUser | IPage | IPageBulkExportJob,
+    target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
     preNotify: PreNotify,
   ): Promise<void> {
     const shouldNotification =

+ 4 - 3
apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts

@@ -1,5 +1,6 @@
 import type { IPage, IUser } from '@growi/core';
 
+import type { IAuditLogBulkExportJob } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedTargetModel } from '~/interfaces/activity';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
@@ -7,14 +8,14 @@ import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notif
 
 const isIPage = (
   targetModel: string,
-  target: IUser | IPage | IPageBulkExportJob,
+  target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
 ): target is IPage => {
   return targetModel === SupportedTargetModel.MODEL_PAGE;
 };
 
 const isIPageBulkExportJob = (
   targetModel: string,
-  target: IUser | IPage | IPageBulkExportJob,
+  target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
 ): target is IPageBulkExportJob => {
   return targetModel === SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB;
 };
@@ -22,7 +23,7 @@ const isIPageBulkExportJob = (
 // snapshots are infos about the target that are displayed in the notification, which should not change on target update/deletion
 export const generateSnapshot = async (
   targetModel: string,
-  target: IUser | IPage | IPageBulkExportJob,
+  target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
 ): Promise<string | undefined> => {
   let snapshot: string | undefined;