in-app-notification.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import type { HasObjectId, IPage, IUser } from '@growi/core';
  2. import { SubscriptionStatusType } from '@growi/core';
  3. import { subDays } from 'date-fns/subDays';
  4. import type { FilterQuery, Types, UpdateQuery } from 'mongoose';
  5. import type { IAuditLogBulkExportJob } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
  6. import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
  7. import { AllEssentialActions } from '~/interfaces/activity';
  8. import type { PaginateResult } from '~/interfaces/in-app-notification';
  9. import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
  10. import type { ActivityDocument } from '~/server/models/activity';
  11. import type { InAppNotificationDocument } from '~/server/models/in-app-notification';
  12. import { InAppNotification } from '~/server/models/in-app-notification';
  13. import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
  14. import Subscription from '~/server/models/subscription';
  15. import loggerFactory from '~/utils/logger';
  16. import type Crowi from '../crowi';
  17. import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
  18. import { type PreNotify, preNotifyService } from './pre-notify';
  19. import { getRoomNameWithId, RoomPrefix } from './socket-io/helper';
  20. const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
  21. const logger = loggerFactory('growi:service:inAppNotification');
  22. export default class InAppNotificationService {
  23. crowi!: Crowi;
  24. socketIoService!: any;
  25. activityEvent!: any;
  26. constructor(crowi: Crowi) {
  27. this.crowi = crowi;
  28. this.activityEvent = crowi.event('activity');
  29. this.socketIoService = crowi.socketIoService;
  30. this.emitSocketIo = this.emitSocketIo.bind(this);
  31. this.upsertByActivity = this.upsertByActivity.bind(this);
  32. this.getUnreadCountByUser = this.getUnreadCountByUser.bind(this);
  33. this.createInAppNotification = this.createInAppNotification.bind(this);
  34. this.initActivityEventListeners();
  35. }
  36. initActivityEventListeners(): void {
  37. this.activityEvent.on(
  38. 'updated',
  39. async (
  40. activity: ActivityDocument,
  41. target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
  42. preNotify: PreNotify,
  43. ) => {
  44. try {
  45. const shouldNotification =
  46. activity != null &&
  47. target != null &&
  48. (AllEssentialActions as ReadonlyArray<string>).includes(
  49. activity.action,
  50. );
  51. if (shouldNotification) {
  52. await this.createInAppNotification(activity, target, preNotify);
  53. }
  54. } catch (err) {
  55. logger.error('Create InAppNotification failed', err);
  56. }
  57. },
  58. );
  59. }
  60. emitSocketIo = async (targetUsers) => {
  61. if (this.socketIoService.isInitialized) {
  62. targetUsers.forEach(async (userId) => {
  63. // emit to the room for each user
  64. await this.socketIoService
  65. .getDefaultSocket()
  66. .in(getRoomNameWithId(RoomPrefix.USER, userId))
  67. .emit('notificationUpdated');
  68. });
  69. }
  70. };
  71. upsertByActivity = async (
  72. users: Types.ObjectId[],
  73. activity: ActivityDocument,
  74. snapshot: string,
  75. createdAt?: Date | null,
  76. ): Promise<void> => {
  77. const { _id: activityId, targetModel, target, action } = activity;
  78. const now = createdAt || Date.now();
  79. const lastWeek = subDays(now, 7);
  80. const operations = users.map((user) => {
  81. const filter: FilterQuery<InAppNotificationDocument> = {
  82. user,
  83. target,
  84. action,
  85. createdAt: { $gt: lastWeek },
  86. snapshot,
  87. };
  88. const parameters: UpdateQuery<InAppNotificationDocument> = {
  89. user,
  90. targetModel,
  91. target,
  92. action,
  93. status: STATUS_UNOPENED,
  94. createdAt: now,
  95. snapshot,
  96. $addToSet: { activities: activityId },
  97. };
  98. return {
  99. updateOne: {
  100. filter,
  101. update: parameters,
  102. upsert: true,
  103. },
  104. };
  105. });
  106. await InAppNotification.bulkWrite(operations);
  107. logger.info('InAppNotification bulkWrite has run');
  108. return;
  109. };
  110. getLatestNotificationsByUser = async (
  111. userId: Types.ObjectId,
  112. queryOptions: {
  113. offset: number;
  114. limit: number;
  115. status?: InAppNotificationStatuses;
  116. },
  117. ): Promise<PaginateResult<InAppNotificationDocument>> => {
  118. const { limit, offset, status } = queryOptions;
  119. try {
  120. const paginateOptions = { user: userId };
  121. if (status != null) {
  122. Object.assign(paginateOptions, { status });
  123. }
  124. // TODO: import @types/mongoose-paginate-v2 and use PaginateResult as a type after upgrading mongoose v6.0.0
  125. const paginationResult = await (InAppNotification as any).paginate(
  126. paginateOptions,
  127. {
  128. sort: { createdAt: -1 },
  129. limit,
  130. offset,
  131. populate: [
  132. { path: 'user' },
  133. {
  134. path: 'target',
  135. populate: [{ path: 'attachment', strictPopulate: false }],
  136. },
  137. { path: 'activities', populate: { path: 'user' } },
  138. ],
  139. },
  140. );
  141. return paginationResult;
  142. } catch (err) {
  143. logger.error('Error', err);
  144. throw new Error(err);
  145. }
  146. };
  147. open = async (
  148. user: IUser & HasObjectId,
  149. id: Types.ObjectId,
  150. ): Promise<void> => {
  151. const query = { _id: id, user: user._id };
  152. const parameters = { status: STATUS_OPENED };
  153. const options = { new: true };
  154. await InAppNotification.findOneAndUpdate(query, parameters, options);
  155. return;
  156. };
  157. updateAllNotificationsAsOpened = async (
  158. user: IUser & HasObjectId,
  159. ): Promise<void> => {
  160. const filter = { user: user._id, status: STATUS_UNOPENED };
  161. const options = { status: STATUS_OPENED };
  162. await InAppNotification.updateMany(filter, options);
  163. return;
  164. };
  165. getUnreadCountByUser = async (
  166. user: Types.ObjectId,
  167. ): Promise<number | undefined> => {
  168. const query = { user, status: STATUS_UNOPENED };
  169. try {
  170. const count = await InAppNotification.countDocuments(query);
  171. return count;
  172. } catch (err) {
  173. logger.error('Error on getUnreadCountByUser', err);
  174. throw err;
  175. }
  176. };
  177. createSubscription = async (
  178. userId: Types.ObjectId,
  179. pageId: Types.ObjectId,
  180. targetRuleName: string,
  181. ): Promise<void> => {
  182. const query = { userId };
  183. const inAppNotificationSettings =
  184. await InAppNotificationSettings.findOne(query);
  185. if (inAppNotificationSettings != null) {
  186. const subscribeRule = inAppNotificationSettings.subscribeRules.find(
  187. (subscribeRule) => subscribeRule.name === targetRuleName,
  188. );
  189. if (subscribeRule != null && subscribeRule.isEnabled) {
  190. await Subscription.subscribeByPageId(
  191. userId,
  192. pageId,
  193. SubscriptionStatusType.SUBSCRIBE,
  194. );
  195. }
  196. }
  197. return;
  198. };
  199. createInAppNotification = async function (
  200. activity: ActivityDocument,
  201. target: IUser | IPage | IPageBulkExportJob | IAuditLogBulkExportJob,
  202. preNotify: PreNotify,
  203. ): Promise<void> {
  204. const shouldNotification =
  205. activity != null &&
  206. target != null &&
  207. (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
  208. const targetModel = activity.targetModel;
  209. const snapshot = await generateSnapshot(targetModel, target);
  210. if (shouldNotification) {
  211. const props = preNotifyService.generateInitialPreNotifyProps();
  212. await preNotify(props);
  213. await this.upsertByActivity(
  214. props.notificationTargetUsers,
  215. activity,
  216. snapshot,
  217. );
  218. await this.emitSocketIo(props.notificationTargetUsers);
  219. } else {
  220. throw Error('no activity to notify');
  221. }
  222. return;
  223. };
  224. }
  225. module.exports = InAppNotificationService;