Taichi Masuyama 4 лет назад
Родитель
Сommit
c18583fad1

+ 43 - 7
packages/app/src/components/PageEditor/InAppNotificationDropdown.tsx

@@ -1,5 +1,7 @@
 import React, { useState, useEffect, FC } from 'react';
-import { Dropdown, DropdownToggle, DropdownMenu } from 'reactstrap';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 // import DropdownMenu from './InAppNotificationDropdown/DropdownMenu';
@@ -9,7 +11,6 @@ import SocketIoContainer from '~/client/services/SocketIoContainer';
 
 
 const InAppNotificationDropdown: FC = (props) => {
-  console.log('propsHoge', props);
 
   const [count, setCount] = useState(0);
   const [isLoaded, setIsLoaded] = useState(false);
@@ -22,9 +23,6 @@ const InAppNotificationDropdown: FC = (props) => {
     // fetchNotificationStatus();
   }, []);
 
-  /**
-    * TODO: Listen to socket on the client side by GW-7402
-    */
   const initializeSocket = (props) => {
     console.log(props);
 
@@ -111,16 +109,54 @@ const InAppNotificationDropdown: FC = (props) => {
 
   const badge = count > 0 ? <span className="badge badge-pill badge-danger notification-badge">{count}</span> : '';
 
+
+  const RenderUnLoadedInAppNotification = (): JSX.Element => {
+    return (
+      <i className="fa fa-spinner"></i>
+    );
+  };
+
+  const RenderEmptyInAppNotification = (): JSX.Element => {
+    return (
+      // TODO: apply i18n by GW-7536
+      <>You had no notifications, yet.</>
+    );
+  };
+
+  // TODO: improve renderInAppNotificationList by GW-7535
+  // refer to https://github.com/crowi/crowi/blob/eecf2bc821098d2516b58104fe88fae81497d3ea/client/components/Notification/Notification.tsx
+  const RenderInAppNotificationList = (): JSX.Element => {
+    // notifications.map((notification) =>
+    return (
+      // <Notification key={notification._id} notification={notification} onClick={notificationClickHandler} />)
+      <>fuga</>
+    );
+  };
+
+  function renderInAppNotificationContents(): JSX.Element {
+    if (isLoaded === false) {
+      return <RenderUnLoadedInAppNotification />;
+    }
+    if (notifications.length === 0) {
+      return <RenderEmptyInAppNotification />;
+    }
+    return <RenderInAppNotificationList />;
+  }
+
   return (
     <Dropdown className="notification-wrapper" isOpen={isOpen} toggle={toggleDropdownHandler}>
       <DropdownToggle tag="a" className="nav-link">
         <i className="icon-bell mr-2"></i>
         {badge}
       </DropdownToggle>
-      <DropdownMenu>hoge</DropdownMenu>
+      <DropdownMenu right>
+        {renderInAppNotificationContents}
+        <DropdownItem divider />
+        {/* TODO: Able to show all notifications by GW-7534 */}
+        <a>See All</a>
+      </DropdownMenu>
     </Dropdown>
   );
-
 };
 
 /**

+ 21 - 1
packages/app/src/server/crowi/index.js

@@ -64,6 +64,8 @@ function Crowi() {
   this.interceptorManager = new InterceptorManager();
   this.slackIntegrationService = null;
   this.inAppNotificationService = null;
+  this.ActivityService = null;
+  this.CommentService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -82,6 +84,7 @@ function Crowi() {
     tag: new (require('../events/tag'))(this),
     admin: new (require('../events/admin'))(this),
     comment: new (require('../events/comment'))(this),
+    activity: new (require('../events/activity'))(),
   };
 }
 
@@ -122,6 +125,8 @@ Crowi.prototype.init = async function() {
     this.setupImport(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
+    this.setupActivityService(),
+    this.setupCommentService(),
     this.setupSyncPageStatusService(),
   ]);
 
@@ -160,7 +165,8 @@ Crowi.prototype.initForTest = async function() {
     // this.setupExport(),
     // this.setupImport(),
     this.setupPageService(),
-    this.setupInAppNotificationService(),
+    // this.setupInAppNotificationService(),
+    // this.setupActivityService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -648,6 +654,20 @@ Crowi.prototype.setupInAppNotificationService = async function() {
   }
 };
 
+Crowi.prototype.setupActivityService = async function() {
+  const ActivityService = require('../service/activity');
+  if (this.activityService == null) {
+    this.activityService = new ActivityService(this);
+  }
+};
+
+Crowi.prototype.setupCommentService = async function() {
+  const CommentService = require('../service/comment');
+  if (this.commentService == null) {
+    this.commentService = new CommentService(this);
+  }
+};
+
 Crowi.prototype.setupSyncPageStatusService = async function() {
   const SyncPageStatusService = require('../service/system-events/sync-page-status');
   if (this.syncPageStatusService == null) {

+ 19 - 0
packages/app/src/server/events/activity.ts

@@ -0,0 +1,19 @@
+import { EventEmitter } from 'events';
+import loggerFactory from '../../utils/logger';
+
+const logger = loggerFactory('growi:events:activity');
+
+
+export default class ActivityEvent extends EventEmitter {
+
+  onRemove(action: string, activity: any): void {
+    logger.info('onRemove activity event fired');
+  }
+
+  onCreate(action: string, activity: any): void {
+    logger.info('onCreate activity event fired');
+  }
+
+}
+
+module.exports = ActivityEvent;

+ 4 - 0
packages/app/src/server/events/comment.ts

@@ -22,5 +22,9 @@ CommentEvent.prototype.onUpdate = function() {
   logger.info('onUpdate comment event fired');
 };
 
+CommentEvent.prototype.onRemove = function() {
+  logger.info('onRemove comment event fired');
+};
+
 
 module.exports = CommentEvent;

+ 164 - 177
packages/app/src/server/models/activity.ts

@@ -2,6 +2,7 @@ import { DeleteWriteOpResultObject } from 'mongodb';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
+import Crowi from '../crowi';
 
 import { getOrCreateModel, getModelSafely } from '../util/mongoose-utils';
 import loggerFactory from '../../utils/logger';
@@ -9,10 +10,11 @@ import ActivityDefine from '../util/activityDefine';
 
 import Watcher from './watcher';
 // import { InAppNotification } from './in-app-notification';
-// import activityEvent from '../events/activity';
 
 const logger = loggerFactory('growi:models:activity');
 
+const mongoose = require('mongoose');
+
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   user: Types.ObjectId | any
@@ -37,195 +39,180 @@ export interface ActivityModel extends Model<ActivityDocument> {
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
 }
 
-// const activityEvent = crowi.event('Activity');
-
-// TODO: add revision id
-const activitySchema = new Schema<ActivityDocument, ActivityModel>({
-  user: {
-    type: Schema.Types.ObjectId,
-    ref: 'User',
-    index: true,
-    require: true,
-  },
-  targetModel: {
-    type: String,
-    require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
-  },
-  target: {
-    type: Schema.Types.ObjectId,
-    refPath: 'targetModel',
-    require: true,
-  },
-  action: {
-    type: String,
-    require: true,
-    enum: ActivityDefine.getSupportActionNames(),
-  },
-  event: {
-    type: Schema.Types.ObjectId,
-    refPath: 'eventModel',
-  },
-  eventModel: {
-    type: String,
-    enum: ActivityDefine.getSupportEventModelNames(),
-  },
-  createdAt: {
-    type: Date,
-    default: Date.now,
-  },
-});
-activitySchema.index({ target: 1, action: 1 });
-activitySchema.index({
-  user: 1, target: 1, action: 1, createdAt: 1,
-}, { unique: true });
-
-/**
-   * @param {object} parameters
-   * @return {Promise}
-   */
-activitySchema.statics.createByParameters = function(parameters) {
-  return this.create(parameters);
-};
+module.exports = function(crowi: Crowi) {
+  const activityEvent = crowi.event('activity');
+
+  // TODO: add revision id
+  const activitySchema = new Schema<ActivityDocument, ActivityModel>({
+    user: {
+      type: Schema.Types.ObjectId,
+      ref: 'User',
+      index: true,
+      require: true,
+    },
+    targetModel: {
+      type: String,
+      require: true,
+      enum: ActivityDefine.getSupportTargetModelNames(),
+    },
+    target: {
+      type: Schema.Types.ObjectId,
+      refPath: 'targetModel',
+      require: true,
+    },
+    action: {
+      type: String,
+      require: true,
+      enum: ActivityDefine.getSupportActionNames(),
+    },
+    event: {
+      type: Schema.Types.ObjectId,
+      refPath: 'eventModel',
+    },
+    eventModel: {
+      type: String,
+      enum: ActivityDefine.getSupportEventModelNames(),
+    },
+    createdAt: {
+      type: Date,
+      default: Date.now,
+    },
+  });
+  activitySchema.index({ target: 1, action: 1 });
+  activitySchema.index({
+    user: 1, target: 1, action: 1, createdAt: 1,
+  }, { unique: true });
+
+  /**
+     * @param {object} parameters
+     * @return {Promise}
+     */
+  activitySchema.statics.createByParameters = function(parameters) {
+    return this.create(parameters);
+  };
 
-/**
-   * @param {object} parameters
-   */
-activitySchema.statics.removeByParameters = async function(parameters) {
-  const activity = await this.findOne(parameters);
-  // activityEvent.emit('remove', activity);
+  /**
+     * @param {object} parameters
+     */
+  activitySchema.statics.removeByParameters = async function(parameters) {
+    const activity = await this.findOne(parameters);
+    activityEvent.emit('remove', activity);
 
-  return this.deleteMany(parameters).exec();
-};
+    return this.deleteMany(parameters).exec();
+  };
 
-/**
-   * @param {Comment} comment
-   * @return {Promise}
-   */
-activitySchema.statics.createByPageComment = function(comment) {
-  const parameters = {
-    user: comment.creator,
-    targetModel: ActivityDefine.MODEL_PAGE,
-    target: comment.page,
-    eventModel: ActivityDefine.MODEL_COMMENT,
-    event: comment._id,
-    action: ActivityDefine.ACTION_COMMENT,
+  /**
+     * @param {Comment} comment
+     * @return {Promise}
+     */
+  activitySchema.statics.createByPageComment = function(comment) {
+    const parameters = {
+      user: comment.creator,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: comment.page,
+      eventModel: ActivityDefine.MODEL_COMMENT,
+      event: comment._id,
+      action: ActivityDefine.ACTION_COMMENT,
+    };
+
+    return this.createByParameters(parameters);
   };
 
-  return this.createByParameters(parameters);
-};
+  /**
+     * @param {Page} page
+     * @param {User} user
+     * @return {Promise}
+     */
+  activitySchema.statics.createByPageLike = function(page, user) {
+    const parameters = {
+      user: user._id,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: page,
+      action: ActivityDefine.ACTION_LIKE,
+    };
+
+    return this.createByParameters(parameters);
+  };
 
-/**
-   * @param {Page} page
-   * @param {User} user
-   * @return {Promise}
-   */
-activitySchema.statics.createByPageLike = function(page, user) {
-  const parameters = {
-    user: user._id,
-    targetModel: ActivityDefine.MODEL_PAGE,
-    target: page,
-    action: ActivityDefine.ACTION_LIKE,
+  /**
+     * @param {Page} page
+     * @param {User} user
+     * @return {Promise}
+     */
+  activitySchema.statics.removeByPageUnlike = function(page, user) {
+    const parameters = {
+      user,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: page,
+      action: ActivityDefine.ACTION_LIKE,
+    };
+
+    return this.removeByParameters(parameters);
   };
 
-  return this.createByParameters(parameters);
-};
+  /**
+     * @param {Page} page
+     *
+     * @return {Promise}
+     */
+  activitySchema.statics.removeByPage = async function(page) {
+    // const activityEvent = new ActivityEvent();
+    const activities = await this.find({ target: page });
+    for (const activity of activities) {
+      // TODO: implement removeActivity when page deleted by GW-7481
+      // activityEvent.emit('remove', activity);
+    }
+    return this.deleteMany({ target: page }).exec();
+  };
 
-/**
-   * @param {Page} page
-   * @param {User} user
-   * @return {Promise}
-   */
-activitySchema.statics.removeByPageUnlike = function(page, user) {
-  const parameters = {
-    user,
-    targetModel: ActivityDefine.MODEL_PAGE,
-    target: page,
-    action: ActivityDefine.ACTION_LIKE,
+  /**
+     * @param {User} user
+     * @return {Promise}
+     */
+  activitySchema.statics.findByUser = function(user) {
+    return this.find({ user }).sort({ createdAt: -1 }).exec();
   };
 
-  return this.removeByParameters(parameters);
-};
+  activitySchema.statics.getActionUsersFromActivities = function(activities) {
+    return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
+  };
 
-/**
-   * @param {Page} page
-   * @return {Promise}
-   */
-activitySchema.statics.removeByPage = async function(page) {
-  const activities = await this.find({ target: page });
-  for (const activity of activities) {
-    // activityEvent.emit('remove', activity);
-  }
-  return this.deleteMany({ target: page }).exec();
-};
+  activitySchema.methods.getNotificationTargetUsers = async function() {
+    const User = getModelSafely('User') || require('~/server/models/user')();
+    const { user: actionUser, targetModel, target } = this;
+
+    const model: any = await this.model(targetModel).findById(target);
+    const [targetUsers, watchUsers, ignoreUsers] = await Promise.all([
+      model.getNotificationTargetUsers(),
+      Watcher.getWatchers((target as any) as Types.ObjectId),
+      Watcher.getIgnorers((target as any) as Types.ObjectId),
+    ]);
+
+    const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
+    const filter = (array, pull) => {
+      const ids = pull.map(object => object.toString());
+      return array.filter(object => !ids.includes(object.toString()));
+    };
+    const notificationUsers = filter(unique([...targetUsers, ...watchUsers]), [...ignoreUsers, actionUser]);
+    const activeNotificationUsers = await User.find({
+      _id: { $in: notificationUsers },
+      status: User.STATUS_ACTIVE,
+    }).distinct('_id');
+    return activeNotificationUsers;
+  };
 
-/**
-   * @param {User} user
-   * @return {Promise}
-   */
-activitySchema.statics.findByUser = function(user) {
-  return this.find({ user }).sort({ createdAt: -1 }).exec();
-};
+  activitySchema.post('save', async(savedActivity: ActivityDocument) => {
+    let targetUsers: Types.ObjectId[] = [];
+    try {
+      targetUsers = await savedActivity.getNotificationTargetUsers();
+    }
+    catch (err) {
+      logger.error(err);
+    }
 
-activitySchema.statics.getActionUsersFromActivities = function(activities) {
-  return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
-};
+    activityEvent.emit('create', targetUsers, savedActivity);
+  });
 
-activitySchema.methods.getNotificationTargetUsers = async function() {
-  const User = getModelSafely('User') || require('~/server/models/user')();
-  const { user: actionUser, targetModel, target } = this;
-
-  const model: any = await this.model(targetModel).findById(target);
-  const [targetUsers, watchUsers, ignoreUsers] = await Promise.all([
-    model.getNotificationTargetUsers(),
-    Watcher.getWatchers((target as any) as Types.ObjectId),
-    Watcher.getIgnorers((target as any) as Types.ObjectId),
-  ]);
-
-  const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
-  const filter = (array, pull) => {
-    const ids = pull.map(object => object.toString());
-    return array.filter(object => !ids.includes(object.toString()));
-  };
-  const notificationUsers = filter(unique([...targetUsers, ...watchUsers]), [...ignoreUsers, actionUser]);
-  const activeNotificationUsers = await User.find({
-    _id: { $in: notificationUsers },
-    status: User.STATUS_ACTIVE,
-  }).distinct('_id');
-  return activeNotificationUsers;
-};
+  return getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);
 
-/**
-   * saved hook   TODO: getNotificationTargetUsers by GW-7346
-   */
-activitySchema.post('save', async(savedActivity: ActivityDocument) => {
-  try {
-    // const notificationUsers = await savedActivity.getNotificationTargetUsers();
-
-    // await Promise.all(notificationUsers.map(user => InAppNotification.upsertByActivity(user, savedActivity)));
-    return;
-  }
-  catch (err) {
-    logger.error(err);
-  }
-});
-
-
-/**
- * TODO: improve removeActivity that decleard in InAppNotificationService by GW-7481
- */
-
-// because mongoose's 'remove' hook fired only when remove by a method of Document (not by a Model method)
-// move 'save' hook from mongoose's events to activityEvent if I have a time.
-// activityEvent.on('remove', async(activity: ActivityDocument) => {
-
-//   try {
-//     await InAppNotification.removeActivity(activity);
-//   }
-//   catch (err) {
-//     logger.error(err);
-//   }
-// });
-
-const Activity = getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);
-export { Activity };
+};

+ 32 - 16
packages/app/src/server/models/comment.js

@@ -1,6 +1,4 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-import { Activity } from './activity';
+import { getModelSafely } from '~/server/util/mongoose-utils';
 
 module.exports = function(crowi) {
   const debug = require('debug')('growi:models:comment');
@@ -80,26 +78,41 @@ module.exports = function(crowi) {
     return commentData;
   };
 
-  commentSchema.statics.removeCommentsByPageId = function(pageId) {
-    const Comment = this;
 
-    return new Promise(((resolve, reject) => {
-      Comment.remove({ page: pageId }, (err, done) => {
-        if (err) {
-          return reject(err);
-        }
+  /**
+   * post remove hook
+   */
+  commentSchema.post('reomove', async(savedComment) => {
+    const Page = crowi.model('Page');
+    const commentEvent = crowi.event('comment');
 
-        resolve(done);
-      });
-    }));
-  };
+    try {
+      // TODO: move Page.updateCommentCount to commentService by GW7532
+      const page = await Page.updateCommentCount(savedComment.page);
+      debug('CommentCount Updated', page);
+    }
+    catch (err) {
+      throw err;
+    }
 
-  commentSchema.methods.removeWithReplies = async function() {
+    await commentEvent.emit('remove', savedComment);
+  });
+
+  commentSchema.methods.removeWithReplies = async function(comment) {
     const Comment = crowi.model('Comment');
-    return Comment.remove({
+    const commentEvent = crowi.event('comment');
+
+    await Comment.remove({
       $or: (
         [{ replyTo: this._id }, { _id: this._id }]),
     });
+
+    await commentEvent.emit('remove', comment);
+    return;
+  };
+
+  commentSchema.statics.findCreatorsByPage = async function(page) {
+    return this.distinct('creator', { page }).exec();
   };
 
   /**
@@ -111,6 +124,7 @@ module.exports = function(crowi) {
     await commentEvent.emit('create', savedComment.creator);
 
     try {
+      // TODO: move Page.updateCommentCount to commentService by GW7532
       const page = await Page.updateCommentCount(savedComment.page);
       debug('CommentCount Updated', page);
     }
@@ -119,12 +133,14 @@ module.exports = function(crowi) {
     }
 
     try {
+      const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
       const activityLog = await Activity.createByPageComment(savedComment);
       debug('Activity created', activityLog);
     }
     catch (err) {
       throw err;
     }
+    await commentEvent.emit('create', savedComment);
   });
 
   return mongoose.model('Comment', commentSchema);

+ 15 - 11
packages/app/src/server/models/in-app-notification.ts

@@ -5,14 +5,14 @@ import { subDays } from 'date-fns';
 import ActivityDefine from '../util/activityDefine';
 import { getOrCreateModel, getModelSafely } from '../util/mongoose-utils';
 import loggerFactory from '../../utils/logger';
-import { Activity, ActivityDocument } from '~/server/models/activity';
+import { ActivityDocument } from './activity';
 
 const logger = loggerFactory('growi:models:inAppNotification');
-const User = getModelSafely('User') || require('~/server/models/user')();
+const mongoose = require('mongoose');
 
-const STATUS_UNREAD = 'UNREAD';
-const STATUS_UNOPENED = 'UNOPENED';
-const STATUS_OPENED = 'OPENED';
+export const STATUS_UNREAD = 'UNREAD';
+export const STATUS_UNOPENED = 'UNOPENED';
+export const STATUS_OPENED = 'OPENED';
 const STATUSES = [STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED];
 
 export interface InAppNotificationDocument extends Document {
@@ -28,12 +28,12 @@ export interface InAppNotificationDocument extends Document {
 
 export interface InAppNotificationModel extends Model<InAppNotificationDocument> {
   findLatestInAppNotificationsByUser(user: Types.ObjectId, skip: number, offset: number): Promise<InAppNotificationDocument[]>
-  upsertByActivity(user: Types.ObjectId, activity: ActivityDocument, createdAt?: Date | null): Promise<InAppNotificationDocument | null>
+
   // commented out type 'Query' temporary to avoid ts error
   removeEmpty()/* : Query<any> */
-  read(user: typeof User) /* : Promise<Query<any>> */
+  read(user) /* : Promise<Query<any>> */
 
-  open(user: typeof User, id: Types.ObjectId): Promise<InAppNotificationDocument | null>
+  open(user, id: Types.ObjectId): Promise<InAppNotificationDocument | null>
   getUnreadCountByUser(user: Types.ObjectId): Promise<number | undefined>
 
   STATUS_UNREAD: string
@@ -81,9 +81,13 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     default: Date.now,
   },
 });
-inAppNotificationSchema.virtual('actionUsers').get(function(this: InAppNotificationDocument) {
-  return Activity.getActionUsersFromActivities((this.activities as any) as ActivityDocument[]);
-});
+
+// TODO: move this virtual property getter to the service layer if necessary 77893
+// inAppNotificationSchema.virtual('actionUsers').get(function(this: InAppNotificationDocument) {
+//   const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
+//   return Activity.getActionUsersFromActivities((this.activities as any) as ActivityDocument[]);
+// });
+
 const transform = (doc, ret) => {
   // delete ret.activities
 };

+ 10 - 0
packages/app/src/server/models/page.js

@@ -1149,6 +1149,16 @@ module.exports = function(crowi) {
     return pageData.save();
   };
 
+  pageSchema.methods.getNotificationTargetUsers = async function() {
+    const Comment = mongoose.model('Comment');
+    const Revision = mongoose.model('Revision');
+
+    const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);
+
+    const targetUsers = new Set([this.creator].concat(commentCreators, revisionAuthors));
+    return Array.from(targetUsers);
+  };
+
   pageSchema.statics.getHistories = function() {
     // TODO
 

+ 5 - 0
packages/app/src/server/models/revision.js

@@ -90,5 +90,10 @@ module.exports = function(crowi) {
     }));
   };
 
+  revisionSchema.statics.findAuthorsByPage = async function(page) {
+    const result = await this.distinct('author', { path: page.path }).exec();
+    return result;
+  };
+
   return mongoose.model('Revision', revisionSchema);
 };

+ 1 - 1
packages/app/src/server/routes/comment.js

@@ -439,7 +439,7 @@ module.exports = function(crowi, app) {
         throw new Error('Current user is not accessible to this page.');
       }
 
-      await comment.removeWithReplies();
+      await comment.removeWithReplies(comment);
       await Page.updateCommentCount(comment.page);
     }
     catch (err) {

+ 68 - 0
packages/app/src/server/service/activity.ts

@@ -0,0 +1,68 @@
+import { Types } from 'mongoose';
+import Crowi from '../crowi';
+import loggerFactory from '../../utils/logger';
+
+import { ActivityDocument } from '../models/activity';
+
+import ActivityDefine from '../util/activityDefine';
+import { getModelSafely } from '../util/mongoose-utils';
+
+
+const logger = loggerFactory('growi:service:ActivityService');
+
+class ActivityService {
+
+  crowi!: Crowi;
+
+  inAppNotificationService!: any;
+
+  activityEvent!: any;
+
+  // commentEvent!: any;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.inAppNotificationService = crowi.inAppNotificationService;
+    this.activityEvent = crowi.event('activity');
+
+    this.setUpEventListeners();
+  }
+
+  setUpEventListeners() {
+    this.initActivityEventListeners();
+  }
+
+  initActivityEventListeners() {
+    this.activityEvent.on('create', async(targetUsers: Types.ObjectId[], activity: ActivityDocument) => {
+      try {
+        await this.inAppNotificationService.upsertByActivity(targetUsers, activity);
+      }
+      catch (err) {
+        logger.error('Error occurred while saving InAppNotification');
+        throw err;
+      }
+    });
+  }
+
+  /**
+   * @param {Comment} comment
+   * @return {Promise}
+   */
+  removeByPageCommentDelete = async function(comment) {
+    const parameters = await {
+      user: comment.creator,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: comment.page,
+      eventModel: ActivityDefine.MODEL_COMMENT,
+      event: comment._id,
+      action: ActivityDefine.ACTION_COMMENT,
+    };
+
+    const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
+    await Activity.removeByParameters(parameters);
+    return;
+  };
+
+}
+
+module.exports = ActivityService;

+ 68 - 0
packages/app/src/server/service/comment.ts

@@ -0,0 +1,68 @@
+import loggerFactory from '../../utils/logger';
+import { getModelSafely } from '../util/mongoose-utils';
+
+const InAppNotificationService = require('./in-app-notification');
+const ActivityService = require('./activity');
+
+const logger = loggerFactory('growi:service:CommentService');
+
+
+class CommentService {
+
+  crowi!: any;
+
+  commentEvent!: any;
+
+  constructor(crowi: any) {
+    this.crowi = crowi;
+
+    this.commentEvent = crowi.event('comment');
+
+    // init
+    this.initCommentEvent();
+  }
+
+
+  initCommentEvent(): void {
+    // create
+    this.commentEvent.on('create', async(savedComment) => {
+      this.commentEvent.onCreate();
+
+      try {
+        const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
+        const activityLog = await Activity.createByPageComment(savedComment);
+        logger.info('Activity created', activityLog);
+      }
+      catch (err) {
+        throw err;
+      }
+
+    });
+
+    // update
+    this.commentEvent.on('update', (user) => {
+      this.commentEvent.onUpdate();
+      const { inAppNotificationService } = this.crowi;
+
+      inAppNotificationService.emitSocketIo(user);
+    });
+
+    // remove
+    this.commentEvent.on('remove', async(comment) => {
+      this.commentEvent.onRemove();
+
+      const { activityService } = this.crowi;
+
+      try {
+        // TODO: Able to remove child activities of comment by GW-7510
+        await activityService.removeByPageCommentDelete(comment);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+  }
+
+}
+
+module.exports = CommentService;

+ 50 - 28
packages/app/src/server/service/in-app-notification.ts

@@ -1,51 +1,73 @@
+import { Types } from 'mongoose';
+import { subDays } from 'date-fns';
 import Crowi from '../crowi';
-import { InAppNotification } from '~/server/models/in-app-notification';
+import { InAppNotification, InAppNotificationDocument, STATUS_UNREAD } from '~/server/models/in-app-notification';
+import { ActivityDocument } from '~/server/models/activity';
 
-class InAppNotificationService {
+import loggerFactory from '~/utils/logger';
 
-  crowi!: any;
+const logger = loggerFactory('growi:service:inAppNotification');
+
+
+export default class InAppNotificationService {
+
+  crowi!: Crowi;
 
   socketIoService!: any;
 
   commentEvent!: any;
 
+  activityEvent!: any;
+
 
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.socketIoService = crowi.socketIoService;
-    this.commentEvent = crowi.event('comment');
-
-    // init
-    this.initCommentEvent();
+    this.activityEvent = crowi.event('activity');
   }
 
-  initCommentEvent(): void {
-    this.commentEvent.on('create', (user) => {
-      this.commentEvent.onCreate();
-    });
 
-    this.commentEvent.on('update', (user) => {
-      this.commentEvent.onUpdate();
+  emitSocketIo = async(user) => {
+    if (this.socketIoService.isInitialized) {
+      await this.socketIoService.getDefaultSocket().emit('comment updated', { user });
+    }
+  }
 
-      if (this.socketIoService.isInitialized) {
-        this.socketIoService.getDefaultSocket().emit('comment updated', { user });
-      }
+  upsertByActivity = async function(
+      users: Types.ObjectId[], activity: ActivityDocument, createdAt?: Date | null,
+  ): Promise<void> {
+    const {
+      _id: activityId, targetModel, target, action,
+    } = activity;
+    const now = createdAt || Date.now();
+    const lastWeek = subDays(now, 7);
+    const operations = users.map((user) => {
+      const filter = {
+        user, target, action, createdAt: { $gt: lastWeek },
+      };
+      const parameters = {
+        user,
+        targetModel,
+        target,
+        action,
+        status: STATUS_UNREAD,
+        createdAt: now,
+        $addToSet: { activities: activityId },
+      };
+      return {
+        updateOne: {
+          filter,
+          update: parameters,
+          upsert: true,
+        },
+      };
     });
 
+    await InAppNotification.bulkWrite(operations);
+    logger.info('InAppNotification bulkWrite has run');
+    return;
   }
 
-  removeActivity = async function(activity) {
-    const { _id, target, action } = activity;
-    const query = { target, action };
-    const parameters = { $pull: { activities: _id } };
-
-    const result = await InAppNotification.updateMany(query, parameters);
-
-    await InAppNotification.removeEmpty();
-
-    return result;
-  };
-
 }
 
 module.exports = InAppNotificationService;