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

Merge pull request #10818 from growilabs/feat/177877-activity-create-update-logic

feat: Define actions that result in Create vs Edit activities
mergify[bot] 2 дней назад
Родитель
Сommit
ced383b863

+ 1 - 0
apps/app/src/server/models/activity.ts

@@ -32,6 +32,7 @@ export interface ActivityDocument extends Document {
   event: Types.ObjectId;
   action: SupportedActionType;
   snapshot: ISnapshot;
+  createdAt: Date;
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {

+ 45 - 20
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,5 +1,5 @@
 import type { IPage, IRevisionHasId, IUserHasId } from '@growi/core';
-import { allOrigin, getIdForRef, Origin } from '@growi/core';
+import { allOrigin, getIdForRef, getIdStringForRef, Origin } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
@@ -32,6 +32,7 @@ import {
   serializePageSecurely,
   serializeRevisionSecurely,
 } from '~/server/models/serializers';
+import { shouldGenerateUpdate } from '~/server/service/activity/update-activity-logic';
 import { configManager } from '~/server/service/config-manager/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -118,24 +119,49 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
       await yjsService.syncWithTheLatestRevisionForce(req.body.pageId);
     }
 
-    // persist activity
-    const creator =
-      updatedPage.creator != null
-        ? getIdForRef(updatedPage.creator)
-        : undefined;
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: updatedPage,
-      action: SupportedAction.ACTION_PAGE_UPDATE,
-    };
-    const activityEvent = crowi.events.activity;
-    activityEvent.emit(
-      'update',
-      res.locals.activity._id,
-      parameters,
-      { path: updatedPage.path, creator },
-      preNotifyService.generatePreNotify,
-    );
+    // Decide if update activity should generate
+    let shouldGenerateUpdateActivity = false;
+    try {
+      const targetPageId = getIdStringForRef(updatedPage);
+      const currentActivityId = getIdStringForRef(res.locals.activity);
+      const currentUserId = req.user ? getIdStringForRef(req.user) : undefined;
+
+      shouldGenerateUpdateActivity = await shouldGenerateUpdate({
+        currentUserId,
+        targetPageId,
+        currentActivityId,
+      });
+    } catch (err) {
+      logger.error(
+        'Failed to determine whether to generate update activity.',
+        err,
+      );
+    }
+
+    if (shouldGenerateUpdateActivity) {
+      try {
+        // persist activity
+        const creator =
+          updatedPage.creator != null
+            ? getIdForRef(updatedPage.creator)
+            : undefined;
+        const parameters = {
+          targetModel: SupportedTargetModel.MODEL_PAGE,
+          target: updatedPage,
+          action: SupportedAction.ACTION_PAGE_UPDATE,
+        };
+        const activityEvent = crowi.events.activity;
+        activityEvent.emit(
+          'update',
+          res.locals.activity._id,
+          parameters,
+          { path: updatedPage.path, creator },
+          preNotifyService.generatePreNotify,
+        );
+      } catch (err) {
+        logger.error('Failed to generate update activity', err);
+      }
+    }
 
     // global notification
     try {
@@ -283,7 +309,6 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           409,
         );
       }
-
       let updatedPage: HydratedDocument<PageDocument>;
       let previousRevision: IRevisionHasId | null;
       try {

+ 1 - 0
apps/app/src/server/service/activity/index.ts

@@ -0,0 +1 @@
+export * from './update-activity-logic';

+ 59 - 0
apps/app/src/server/service/activity/update-activity-logic.ts

@@ -0,0 +1,59 @@
+import type { IRevisionHasId } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+
+type GenerateUpdatePayload = {
+  currentUserId: string | undefined;
+  targetPageId: string;
+  currentActivityId: string;
+};
+
+const MINIMUM_REVISION_FOR_ACTIVITY = 2;
+const SUPPRESION_UPDATE_WINDOW_MS = 5 * 60 * 1000; // 5 min
+
+export const shouldGenerateUpdate = async (payload: GenerateUpdatePayload) => {
+  const { targetPageId, currentActivityId, currentUserId } = payload;
+
+  if (currentUserId == null) {
+    return false;
+  }
+
+  // Get most recent update or create activity on the page
+  const lastContentActivity = await Activity.findOne({
+    target: targetPageId,
+    action: {
+      $in: [
+        SupportedAction.ACTION_PAGE_CREATE,
+        SupportedAction.ACTION_PAGE_UPDATE,
+      ],
+    },
+    _id: { $ne: currentActivityId },
+  }).sort({ createdAt: -1 });
+
+  const isLastActivityByMe =
+    lastContentActivity != null &&
+    getIdStringForRef(lastContentActivity?.user) === currentUserId;
+  const lastActivityTime = lastContentActivity?.createdAt?.getTime?.() ?? 0;
+  const timeSinceLastActivityMs = Date.now() - lastActivityTime;
+
+  // Decide if update activity should generate
+  let shouldGenerateUpdateActivity: boolean;
+  if (!isLastActivityByMe) {
+    shouldGenerateUpdateActivity = true;
+  } else if (timeSinceLastActivityMs < SUPPRESION_UPDATE_WINDOW_MS) {
+    shouldGenerateUpdateActivity = false;
+  } else {
+    const Revision = mongoose.model<IRevisionHasId>('Revision');
+    const revisionCount = await Revision.countDocuments({
+      pageId: targetPageId,
+    });
+
+    shouldGenerateUpdateActivity =
+      revisionCount > MINIMUM_REVISION_FOR_ACTIVITY;
+  }
+
+  return shouldGenerateUpdateActivity;
+};

+ 460 - 0
apps/app/src/server/service/activity/update-activity.spec.ts

@@ -0,0 +1,460 @@
+import { MongoMemoryServer } from 'mongodb-memory-server-core';
+import mongoose from 'mongoose';
+
+import { SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import { Revision } from '~/server/models/revision';
+
+import { shouldGenerateUpdate } from './update-activity-logic';
+
+describe('shouldGenerateUpdate()', () => {
+  let mongoServer: MongoMemoryServer;
+
+  let date = new Date();
+  const TWO_HOURS = 2 * 60 * 60 * 1000;
+  const ONE_HOUR = 60 * 60 * 1000;
+  const ONE_MINUTE = 1 * 60 * 1000;
+
+  let targetPageId: mongoose.Types.ObjectId;
+  let currentUserId: mongoose.Types.ObjectId;
+  let otherUserId: mongoose.Types.ObjectId;
+  let currentActivityId: mongoose.Types.ObjectId;
+  let olderActivityId: mongoose.Types.ObjectId;
+  let createActivityId: mongoose.Types.ObjectId;
+
+  let targetPageIdStr: string;
+  let currentUserIdStr: string;
+  let currentActivityIdStr: string;
+
+  beforeAll(async () => {
+    mongoServer = await MongoMemoryServer.create();
+    await mongoose.connect(mongoServer.getUri());
+  });
+
+  afterAll(async () => {
+    await mongoose.disconnect();
+    await mongoServer.stop();
+  });
+
+  beforeEach(async () => {
+    await Activity.deleteMany({});
+    await Revision.deleteMany({});
+
+    // Reset date and IDs between tests
+    date = new Date();
+    targetPageId = new mongoose.Types.ObjectId();
+    currentUserId = new mongoose.Types.ObjectId();
+    otherUserId = new mongoose.Types.ObjectId();
+    currentActivityId = new mongoose.Types.ObjectId();
+    olderActivityId = new mongoose.Types.ObjectId();
+    createActivityId = new mongoose.Types.ObjectId();
+
+    targetPageIdStr = targetPageId.toString();
+    currentUserIdStr = currentUserId.toString();
+    currentActivityIdStr = currentActivityId.toString();
+  });
+
+  it('should not generate update activity if: a create was performed but no update made', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should generate update activity if: latest update is by another user, not first update', async () => {
+    await Activity.insertMany([
+      // Create activity
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      // Latest activity
+      {
+        user: otherUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      // Current activity
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    // More than 2 revisions means it is NOT the first update
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+
+  it('should generate update activity if: page created by another user, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: otherUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+    expect(result).toBe(true);
+  });
+
+  it('should not generate update activity if: update is made by the page creator, outside suppression window, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should not generate update activity if: update is made by the page creator, within suppression window, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_MINUTE),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should not generate update activity if: update is made by the same user, within suppression window, not first update', async () => {
+    const FOUR_MINUTES = 4 * 60 * 1000;
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - FOUR_MINUTES),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should generate update activity if: update is made by the same user, outside suppression window, not first update', async () => {
+    const SIX_MINUTES = 6 * 60 * 1000;
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - SIX_MINUTES),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+
+  it('should not care about edits on other pages', async () => {
+    const otherPageId = new mongoose.Types.ObjectId();
+
+    await Activity.insertMany([
+      // Create page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      // Update other page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - ONE_MINUTE),
+        target: otherPageId,
+        _id: new mongoose.Types.ObjectId(),
+      },
+      // Update previously created page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+});