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

Merge pull request #10393 from growilabs/feat/94790-172026-add-audit-log-bulk-export-job

feat: creade AuditLogBulkExportJob
Yuki Takei 5 месяцев назад
Родитель
Сommit
8c1f895f65

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

@@ -39,6 +39,7 @@ module.exports = {
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
     'src/features/page-bulk-export/**',
+    'src/features/audit-log-bulk-export/**',
     'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
     'src/features/rate-limiter/**',

+ 2 - 0
apps/app/package.json

@@ -288,6 +288,7 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
@@ -337,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"

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

@@ -0,0 +1,48 @@
+import type { HasObjectId, 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 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;
+}
+
+export interface IAuditLogBulkExportJobHasId
+  extends IAuditLogBulkExportJob,
+    HasObjectId {}

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

@@ -0,0 +1,53 @@
+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 },
+  },
+  { timestamps: true },
+);
+
+export default getOrCreateModel<
+  AuditLogBulkExportJobDocument,
+  AuditLogBulkExportJobModel
+>('AuditLogBulkExportJob', auditLogBulkExportJobSchema);

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

@@ -0,0 +1,300 @@
+import express, {
+  type NextFunction,
+  type Request,
+  type Response,
+} from 'express';
+import mockRequire from 'mock-require';
+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';
+
+mockRequire('~/server/middlewares/login-required', () => {
+  return (_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 users contains invalid ObjectId', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            users: ['invalid-objectid'],
+          },
+        });
+
+      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 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: {
+    users?: 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 = require('~/server/middlewares/login-required')(
+    crowi,
+  );
+
+  const validators = {
+    auditLogBulkExport: [
+      body('filters').exists({ checkFalsy: true }).isObject(),
+      body('filters.users').optional({ nullable: true }).isArray(),
+      body('filters.users.*').optional({ nullable: true }).isMongoId(),
+      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';

+ 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_CREATE', 'PAGE_VIEW'],
+          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: { users: string[]; actions: SupportedActionType[] } = {
+          users: [user._id.toString()],
+          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)).toEqual([
+          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 validUserId1 = new mongoose.Types.ObjectId().toString();
+      const validUserId2 = new mongoose.Types.ObjectId().toString();
+
+      const filters1: { actions: SupportedActionType[]; users: string[] } = {
+        actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+        users: [validUserId1, validUserId2],
+      };
+
+      const filters2: { actions: SupportedActionType[]; users: string[] } = {
+        actions: ['PAGE_CREATE', 'PAGE_VIEW'],
+        users: [validUserId2, validUserId1],
+      };
+
+      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);
+    });
+  });
+});

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

@@ -0,0 +1,120 @@
+import { createHash } from 'node:crypto';
+
+import type {
+  AuditLogBulkExportFormat,
+  IAuditLogBulkExportFilters,
+} 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: (
+    filters: IAuditLogBulkExportFilters,
+    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(
+    filters: IAuditLogBulkExportFilters,
+    format: AuditLogBulkExportFormat,
+    currentUser,
+    restartJob?: boolean,
+  ): Promise<string> {
+    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: normalizedFilters,
+      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

+ 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';
@@ -125,6 +126,7 @@ module.exports = (crowi, app) => {
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
   router.use('/page-bulk-export', require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(crowi));
+  router.use('/audit-log-bulk-export', auditLogBulkExportRouteFactory(crowi));
 
   router.use('/openai', openaiRouteFactory(crowi));
 

+ 36 - 0
pnpm-lock.yaml

@@ -860,6 +860,9 @@ importers:
       '@types/react-stickynode':
         specifier: ^4.0.3
         version: 4.0.3
+      '@types/supertest':
+        specifier: ^6.0.3
+        version: 6.0.3
       '@types/testing-library__dom':
         specifier: ^7.5.0
         version: 7.5.0
@@ -1007,6 +1010,9 @@ importers:
       source-map-loader:
         specifier: ^4.0.1
         version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+      supertest:
+        specifier: ^7.1.4
+        version: 7.1.4
       swagger2openapi:
         specifier: ^7.0.8
         version: 7.0.8(encoding@0.1.13)
@@ -10850,6 +10856,7 @@ packages:
 
   mathjax-full@3.2.2:
     resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
+    deprecated: Version 4 replaces this package with the scoped package @mathjax/src
 
   mathml-tag-names@2.1.3:
     resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -13714,6 +13721,10 @@ packages:
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
 
+  superagent@10.2.3:
+    resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==}
+    engines: {node: '>=14.18.0'}
+
   superjson@1.13.3:
     resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
     engines: {node: '>=10'}
@@ -13723,6 +13734,10 @@ packages:
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
 
+  supertest@7.1.4:
+    resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
+    engines: {node: '>=14.18.0'}
+
   supports-color@10.0.0:
     resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}
     engines: {node: '>=18'}
@@ -30651,6 +30666,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  superagent@10.2.3:
+    dependencies:
+      component-emitter: 1.3.1
+      cookiejar: 2.1.4
+      debug: 4.4.1(supports-color@5.5.0)
+      fast-safe-stringify: 2.1.1
+      form-data: 4.0.4
+      formidable: 3.5.4
+      methods: 1.1.2
+      mime: 2.6.0
+      qs: 6.13.0
+    transitivePeerDependencies:
+      - supports-color
+
   superjson@1.13.3:
     dependencies:
       copy-anything: 3.0.5
@@ -30662,6 +30691,13 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  supertest@7.1.4:
+    dependencies:
+      methods: 1.1.2
+      superagent: 10.2.3
+    transitivePeerDependencies:
+      - supports-color
+
   supports-color@10.0.0: {}
 
   supports-color@2.0.0: {}