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

Merge branch 'feat/auditlog' of https://github.com/weseek/growi into feat/94792-search-by-period

Shun Miyazawa 3 лет назад
Родитель
Сommit
f639b712b2
35 измененных файлов с 219 добавлено и 228 удалено
  1. 2 2
      packages/app/src/components/Page/TagsInput.tsx
  2. 1 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  3. 2 2
      packages/app/src/components/Sidebar/Tag.tsx
  4. 5 3
      packages/app/src/components/TagCloudBox.tsx
  5. 3 3
      packages/app/src/components/TagList.tsx
  6. 2 2
      packages/app/src/components/TagPage.tsx
  7. 3 8
      packages/app/src/interfaces/activity.ts
  8. 3 3
      packages/app/src/interfaces/page.ts
  9. 6 9
      packages/app/src/interfaces/tag.ts
  10. 13 12
      packages/app/src/server/crowi/index.js
  11. 2 2
      packages/app/src/server/events/comment.ts
  12. 16 16
      packages/app/src/server/models/activity.ts
  13. 2 2
      packages/app/src/server/models/comment.js
  14. 2 2
      packages/app/src/server/models/in-app-notification-settings.ts
  15. 6 6
      packages/app/src/server/models/in-app-notification.ts
  16. 3 5
      packages/app/src/server/models/page-tag-relation.js
  17. 3 3
      packages/app/src/server/models/subscription.ts
  18. 0 69
      packages/app/src/server/models/tag.js
  19. 63 0
      packages/app/src/server/models/tag.ts
  20. 1 0
      packages/app/src/server/routes/apiv3/pages.js
  21. 2 2
      packages/app/src/server/routes/comment.js
  22. 2 1
      packages/app/src/server/routes/tag.js
  23. 18 14
      packages/app/src/server/service/comment.ts
  24. 17 14
      packages/app/src/server/service/page.ts
  25. 3 3
      packages/app/src/stores/tag.tsx
  26. 10 0
      packages/app/src/styles/_mixins.scss
  27. 0 7
      packages/app/src/styles/_page-tree.scss
  28. 1 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  29. 4 5
      packages/app/src/styles/theme/_apply-colors-light.scss
  30. 6 8
      packages/app/src/styles/theme/mixins/_list-group.scss
  31. 1 1
      packages/app/src/styles/theme/nature.scss
  32. 1 2
      packages/app/test/integration/models/v5.page.test.js
  33. 3 2
      packages/app/test/integration/service/page.test.js
  34. 5 7
      packages/app/test/integration/service/v5.non-public-page.test.ts
  35. 8 10
      packages/app/test/integration/service/v5.public-page.test.ts

+ 2 - 2
packages/app/src/components/Page/TagsInput.tsx

@@ -5,7 +5,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/apiNotification';
-import { ITagsSearchApiv1Result } from '~/interfaces/tag';
+import { IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 type TypeaheadInstance = {
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
@@ -36,7 +36,7 @@ const TagsInput: FC<Props> = (props: Props) => {
     setLoading(true);
     try {
       // TODO: 91698 SWRize
-      const res = await apiGet('/tags.search', { q: query }) as ITagsSearchApiv1Result;
+      const res = await apiGet('/tags.search', { q: query }) as IResTagsSearchApiv1;
       res.tags.unshift(query);
       setResultTags(Array.from(new Set(res.tags)));
     }

+ 1 - 1
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -439,7 +439,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                 <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
               )}
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-                <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
               </a>
             </>
           )}

+ 2 - 2
packages/app/src/components/Sidebar/Tag.tsx

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from '../TagCloudBox';
@@ -16,7 +16,7 @@ const Tag: FC = () => {
   const [offset, setOffset] = useState<number>(0);
 
   const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 

+ 5 - 3
packages/app/src/components/TagCloudBox.tsx

@@ -1,9 +1,11 @@
 import React, { FC, memo } from 'react';
+
 import { TagCloud } from 'react-tagcloud';
-import { ITagCountHasId } from '~/interfaces/tag';
+
+import { IDataTagCount } from '~/interfaces/tag';
 
 type Props = {
-  tags:ITagCountHasId[],
+  tags:IDataTagCount[],
   minSize?: number,
   maxSize?: number,
   maxTagTextLength?: number,
@@ -29,7 +31,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
       <TagCloud
         minSize={minSize ?? MIN_FONT_SIZE}
         maxSize={maxSize ?? MAX_FONT_SIZE}
-        tags={tags.map((tag:ITagCountHasId) => {
+        tags={tags.map((tag:IDataTagCount) => {
           return {
             // text truncation
             value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,

+ 3 - 3
packages/app/src/components/TagList.tsx

@@ -4,12 +4,12 @@ import React, {
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 
 import PaginationWrapper from './PaginationWrapper';
 
 type TagListProps = {
-  tagData: ITagCountHasId[],
+  tagData: IDataTagCount[],
   totalTags: number,
   activePage: number,
   onChangePage?: (selectedPageNumber: number) => void,
@@ -29,7 +29,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   const { t } = useTranslation('');
 
   const generateTagList = useCallback((tagData) => {
-    return tagData.map((tag:ITagCountHasId, index:number) => {
+    return tagData.map((tag:IDataTagCount, index:number) => {
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
 
       return (

+ 2 - 2
packages/app/src/components/TagPage.tsx

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from './TagCloudBox';
@@ -15,7 +15,7 @@ const TagPage: FC = () => {
   const [offset, setOffset] = useState<number>(0);
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 

+ 3 - 8
packages/app/src/interfaces/activity.ts

@@ -3,7 +3,6 @@ import { IUser } from './user';
 
 // Model
 const MODEL_PAGE = 'Page';
-const MODEL_COMMENT = 'Comment';
 
 // Action
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
@@ -21,11 +20,6 @@ const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 
 export const SUPPORTED_TARGET_MODEL_TYPE = {
   MODEL_PAGE,
-  MODEL_COMMENT,
-} as const;
-
-export const SUPPORTED_EVENT_MODEL_TYPE = {
-  MODEL_COMMENT,
 } as const;
 
 export const SUPPORTED_ACTION_TYPE = {
@@ -44,10 +38,8 @@ export const SUPPORTED_ACTION_TYPE = {
 
 
 export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
-export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
 export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
 
-
 /*
  * For AuditLogManagement.tsx
  */
@@ -73,6 +65,7 @@ export type SupportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_TYPE[keyof
 // type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_TYPE[keyof typeof SUPPORTED_EVENT_MODEL_TYPE];
 export type SupportedActionType = typeof SUPPORTED_ACTION_TYPE[keyof typeof SUPPORTED_ACTION_TYPE];
 
+
 export type IActivity = {
   user?: IUser
   targetModel: SupportedTargetModelType
@@ -82,3 +75,5 @@ export type IActivity = {
 }
 
 export type IActivityHasId = IActivity & HasObjectId;
+
+export type ISnapshot = Pick<IUser, 'username'>

+ 3 - 3
packages/app/src/interfaces/page.ts

@@ -1,9 +1,9 @@
 import { Ref, Nullable } from './common';
-import { IUser } from './user';
-import { IRevision, HasRevisionShortbody } from './revision';
-import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { SubscriptionStatusType } from './subscription';
+import { ITag } from './tag';
+import { IUser } from './user';
 
 
 export interface IPage {

+ 6 - 9
packages/app/src/interfaces/tag.ts

@@ -1,21 +1,18 @@
-import { HasObjectId } from './has-object-id';
-
-export type ITag = {
+export type ITag<ID = string> = {
+  _id: ID
   name: string,
-  createdAt: Date;
 }
 
-export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+export type IDataTagCount = ITag & {count: number}
 
-export type ITagCountHasId = ITagCount & HasObjectId
 
-export type ITagsSearchApiv1Result = {
+export type IResTagsSearchApiv1 = {
   ok: boolean,
   tags: string[]
 }
 
-export type ITagsListApiv1Result = {
+export type IResTagsListApiv1 = {
   ok: boolean,
-  data: ITagCountHasId[],
+  data: IDataTagCount[],
   totalCount: number,
 }

+ 13 - 12
packages/app/src/server/crowi/index.js

@@ -1,12 +1,13 @@
 /* eslint-disable @typescript-eslint/no-this-alias */
 
-import path from 'path';
 import http from 'http';
-import mongoose from 'mongoose';
+import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+
 import pkg from '^/package.json';
 
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -15,26 +16,25 @@ import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
-import ConfigManager from '../service/config-manager';
-import AppService from '../service/app';
+import Activity from '../models/activity';
+import PageRedirect from '../models/page-redirect';
+import Tag from '../models/tag';
+import UserGroup from '../models/user-group';
 import AclService from '../service/acl';
-import SearchService from '../service/search';
+import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
+import ConfigManager from '../service/config-manager';
+import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-import { InstallerService } from '../service/installer';
-import Activity from '../models/activity';
-import UserGroup from '../models/user-group';
-import PageRedirect from '../models/page-redirect';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-
 const models = require('../models');
-
 const PluginService = require('../plugins/plugin.service');
 
 const sep = path.sep;
@@ -281,6 +281,7 @@ Crowi.prototype.setupModels = async function() {
 
   // include models that independent from crowi
   allModels.Activity = Activity;
+  allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
 

+ 2 - 2
packages/app/src/server/events/comment.ts

@@ -2,9 +2,9 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:events:comment');
 
+const events = require('events');
 const util = require('util');
 
-const events = require('events');
 
 function CommentEvent(crowi) {
   this.crowi = crowi;
@@ -20,7 +20,7 @@ CommentEvent.prototype.onUpdate = function(comment) {
   logger.info('onUpdate comment event fired');
 };
 CommentEvent.prototype.onDelete = function(comment) {
-  logger.info('onRemove comment event fired');
+  logger.info('onDelete comment event fired');
 };
 
 module.exports = CommentEvent;

+ 16 - 16
packages/app/src/server/models/activity.ts

@@ -4,7 +4,7 @@ import {
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
-import { AllSupportedTargetModelType, AllSupportedEventModelType, AllSupportedActionType } from '~/interfaces/activity';
+import { AllSupportedTargetModelType, AllSupportedActionType, ISnapshot } from '~/interfaces/activity';
 
 import loggerFactory from '../../utils/logger';
 import activityEvent from '../events/activity';
@@ -19,8 +19,7 @@ export interface ActivityDocument extends Document {
   targetModel: string
   target: Types.ObjectId
   action: string
-  event: Types.ObjectId
-  eventModel: string
+  snapshot: ISnapshot
 
   getNotificationTargetUsers(): Promise<any[]>
 }
@@ -29,39 +28,40 @@ export interface ActivityModel extends Model<ActivityDocument> {
   [x:string]: any
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
 }
+
+const snapshotSchema = new Schema<ISnapshot>({
+  username: { type: String },
+});
+
 // TODO: add revision id
 const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   user: {
     type: Schema.Types.ObjectId,
     ref: 'User',
     index: true,
-    require: true,
+    required: true,
   },
   targetModel: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   action: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSupportedActionType,
   },
-  event: {
-    type: Schema.Types.ObjectId,
-    refPath: 'eventModel',
-  },
-  eventModel: {
-    type: String,
-    enum: AllSupportedEventModelType,
-  },
+  snapshot: snapshotSchema,
 }, {
-  timestamps: true,
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({

+ 2 - 2
packages/app/src/server/models/comment.js

@@ -99,7 +99,7 @@ module.exports = function(crowi) {
    * post remove hook
    */
   commentSchema.post('reomove', async(savedComment) => {
-    await commentEvent.emit('remove', savedComment);
+    await commentEvent.emit('delete', savedComment);
   });
 
   commentSchema.methods.removeWithReplies = async function(comment) {
@@ -110,7 +110,7 @@ module.exports = function(crowi) {
         [{ replyTo: this._id }, { _id: this._id }]),
     });
 
-    await commentEvent.emit('remove', comment);
+    await commentEvent.emit('delete', comment);
     return;
   };
 

+ 2 - 2
packages/app/src/server/models/in-app-notification-settings.ts

@@ -1,5 +1,5 @@
-import { Schema, Model, Document } from 'mongoose';
 import { getOrCreateModel } from '@growi/core';
+import { Schema, Model, Document } from 'mongoose';
 
 import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
 
@@ -10,7 +10,7 @@ const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocu
   userId: { type: Schema.Types.ObjectId },
   subscribeRules: [
     {
-      name: { type: String, require: true, enum: subscribeRuleNames },
+      name: { type: String, required: true, enum: subscribeRuleNames },
       isEnabled: { type: Boolean },
     },
   ],

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

@@ -41,21 +41,21 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     type: Schema.Types.ObjectId,
     ref: 'User',
     index: true,
-    require: true,
+    required: true,
   },
   targetModel: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   action: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSupportedActionType,
   },
   activities: [
@@ -69,7 +69,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     default: STATUS_UNREAD,
     enum: InAppNotificationStatuses,
     index: true,
-    require: true,
+    required: true,
   },
   createdAt: {
     type: Date,
@@ -77,7 +77,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   },
   snapshot: {
     type: String,
-    require: true,
+    required: true,
   },
 });
 inAppNotificationSchema.plugin(mongoosePaginate);

+ 3 - 5
packages/app/src/server/models/page-tag-relation.js

@@ -1,8 +1,9 @@
+import Tag from './tag';
+
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
 const flatMap = require('array.prototype.flatmap');
-
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
@@ -110,8 +111,7 @@ class PageTagRelation {
       .flatMap(result => result.tagIds); // map + flatten
     const distinctTagIds = Array.from(new Set(allTagIds));
 
-    // retrieve tag documents
-    const Tag = mongoose.model('Tag');
+    // TODO: set IdToNameMap type by 93933
     const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
 
     // convert to map
@@ -136,8 +136,6 @@ class PageTagRelation {
     // eslint-disable-next-line no-param-reassign
     tags = tags.filter((tag) => { return tag !== '' });
 
-    const Tag = mongoose.model('Tag');
-
     // get relations for this page
     const relations = await this.findByPageId(pageId, { nullable: true });
 

+ 3 - 3
packages/app/src/server/models/subscription.ts

@@ -37,17 +37,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   targetModel: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   status: {
     type: String,
-    require: true,
+    required: true,
     enum: AllSubscriptionStatusType,
   },
   createdAt: { type: Date, default: new Date() },

+ 0 - 69
packages/app/src/server/models/tag.js

@@ -1,69 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  name: {
-    type: String,
-    required: true,
-    unique: true,
-  },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * Tag Class
- *
- * @class Tag
- */
-class Tag {
-
-  static async getIdToNameMap(tagIds) {
-    const tags = await this.find({ _id: { $in: tagIds } });
-
-    const idToNameMap = {};
-    tags.forEach((tag) => {
-      idToNameMap[tag._id.toString()] = tag.name;
-    });
-
-    return idToNameMap;
-  }
-
-  static async findOrCreate(tagName) {
-    const tag = await this.findOne({ name: tagName });
-    if (!tag) {
-      return this.create({ name: tagName });
-    }
-    return tag;
-  }
-
-  static async findOrCreateMany(tagNames) {
-    const existTags = await this.find({ name: { $in: tagNames } });
-    const existTagNames = existTags.map((tag) => { return tag.name });
-
-    // bulk insert
-    const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
-    await this.insertMany(
-      tagsToCreate.map((tag) => {
-        return { name: tag };
-      }),
-    );
-
-    return this.find({ name: { $in: tagNames } });
-  }
-
-}
-
-module.exports = function(crowi) {
-  Tag.crowi = crowi;
-  schema.loadClass(Tag);
-  const model = mongoose.model('Tag', schema);
-  return model;
-};

+ 63 - 0
packages/app/src/server/models/tag.ts

@@ -0,0 +1,63 @@
+import { getOrCreateModel } from '@growi/core';
+import {
+  Types, Model, Schema,
+} from 'mongoose';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+
+export interface TagDocument {
+  _id: Types.ObjectId;
+  name: string;
+}
+
+export type IdToNameMap = {[key: string] : string }
+
+export interface TagModel extends Model<TagDocument>{
+  getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap
+  findOrCreateMany(tagNames: string[]): Promise<TagDocument[]>
+}
+
+
+const tagSchema = new Schema<TagDocument, TagModel>({
+  name: {
+    type: String,
+    require: true,
+    unique: true,
+  },
+});
+tagSchema.plugin(mongoosePaginate);
+tagSchema.plugin(uniqueValidator);
+
+
+tagSchema.statics.getIdToNameMap = async function(tagIds: ObjectIdLike[]): Promise<IdToNameMap> {
+  const tags = await this.find({ _id: { $in: tagIds } });
+
+  const idToNameMap = {};
+  tags.forEach((tag) => {
+    idToNameMap[tag._id.toString()] = tag.name;
+  });
+
+  return idToNameMap;
+};
+
+tagSchema.statics.findOrCreateMany = async function(tagNames: string[]): Promise<TagDocument[]> {
+  const existTags = await this.find({ name: { $in: tagNames } });
+  const existTagNames = existTags.map((tag) => { return tag.name });
+
+  // bulk insert
+  const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
+  await this.insertMany(
+    tagsToCreate.map((tag) => {
+      return { name: tag };
+    }),
+  );
+
+  return this.find({ name: { $in: tagNames } });
+};
+
+
+export default getOrCreateModel<TagDocument, TagModel>('Tag', tagSchema);

+ 1 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -351,6 +351,7 @@ module.exports = (crowi) => {
         targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
         target: createdPage,
         action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
+        snapshot: { username: req.user.username },
       };
       await crowi.activityService.createByParameters(parameters);
     }

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

@@ -242,7 +242,7 @@ module.exports = function(crowi, app) {
     let createdComment;
     try {
       createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
-      commentEvent.emit('create', createdComment);
+      commentEvent.emit('create', req.user, createdComment);
     }
     catch (err) {
       logger.error(err);
@@ -379,7 +379,7 @@ module.exports = function(crowi, app) {
         { _id: commentId },
         { $set: { comment: commentStr, isMarkdown, revision } },
       );
-      commentEvent.emit('update', updatedComment);
+      commentEvent.emit('update', req.user, updatedComment);
     }
     catch (err) {
       logger.error(err);

+ 2 - 1
packages/app/src/server/routes/tag.js

@@ -1,3 +1,5 @@
+import Tag from '~/server/models/tag';
+
 /**
  * @swagger
  *
@@ -29,7 +31,6 @@
  */
 module.exports = function(crowi, app) {
 
-  const Tag = crowi.model('Tag');
   const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};

+ 18 - 14
packages/app/src/server/service/comment.ts

@@ -1,7 +1,11 @@
 import { getModelSafely } from '@growi/core';
 import { Types } from 'mongoose';
 
-import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_EVENT_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import {
+  SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE, SupportedActionType, ISnapshot,
+} from '~/interfaces/activity';
+import { IPage } from '~/interfaces/page';
+import { IUserHasId } from '~/interfaces/user';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 
 import loggerFactory from '../../utils/logger';
@@ -33,7 +37,7 @@ class CommentService {
 
   initCommentEventListeners(): void {
     // create
-    this.commentEvent.on('create', async(savedComment) => {
+    this.commentEvent.on('create', async(user, savedComment) => {
 
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
@@ -45,7 +49,7 @@ class CommentService {
           return;
         }
 
-        const activity = await this.createActivity(savedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_CREATE);
+        const activity = await this.createActivity(user, savedComment.page, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_CREATE);
         await this.createAndSendNotifications(activity, page);
       }
       catch (err) {
@@ -55,10 +59,10 @@ class CommentService {
     });
 
     // update
-    this.commentEvent.on('update', async(updatedComment) => {
+    this.commentEvent.on('update', async(user, updatedComment) => {
       try {
         this.commentEvent.onUpdate();
-        await this.createActivity(updatedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_UPDATE);
+        await this.createActivity(user, updatedComment.page, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_UPDATE);
       }
       catch (err) {
         logger.error('Error occurred while handling the comment update event:\n', err);
@@ -66,8 +70,8 @@ class CommentService {
     });
 
     // remove
-    this.commentEvent.on('remove', async(comment) => {
-      this.commentEvent.onRemove();
+    this.commentEvent.on('delete', async(comment) => {
+      this.commentEvent.onDelete();
 
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
@@ -79,27 +83,27 @@ class CommentService {
     });
   }
 
-  private createActivity = async function(comment, action) {
+  private createActivity = async function(user: IUserHasId, target: IPage, action: SupportedActionType) {
+    const snapshot: ISnapshot = { username: user.username };
     const parameters = {
-      user: comment.creator,
+      user: user._id,
       targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
-      target: comment.page,
-      eventModel: SUPPORTED_EVENT_MODEL_TYPE.MODEL_COMMENT,
-      event: comment._id,
+      target,
       action,
+      snapshot,
     };
     const activity = await this.activityService.createByParameters(parameters);
     return activity;
   };
 
-  private createAndSendNotifications = async function(activity, page) {
-    const snapshot = stringifySnapshot(page);
+  private createAndSendNotifications = async function(activity, page: IPage) {
 
     // Get user to be notified
     let targetUsers: Types.ObjectId[] = [];
     targetUsers = await activity.getNotificationTargetUsers();
 
     // Create and send notifications
+    const snapshot = stringifySnapshot(page);
     await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await this.inAppNotificationService.emitSocketIo(targetUsers);
   };

+ 17 - 14
packages/app/src/server/service/page.ts

@@ -6,7 +6,9 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
-import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import {
+  SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE, SupportedActionType, ISnapshot,
+} from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
@@ -159,7 +161,7 @@ class PageService {
       this.pageEvent.onUpdate();
 
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_UPDATE);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_UPDATE);
       }
       catch (err) {
         logger.error(err);
@@ -169,7 +171,7 @@ class PageService {
     // rename
     this.pageEvent.on('rename', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_RENAME);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_RENAME);
       }
       catch (err) {
         logger.error(err);
@@ -179,7 +181,7 @@ class PageService {
     // duplicate
     this.pageEvent.on('duplicate', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DUPLICATE);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DUPLICATE);
       }
       catch (err) {
         logger.error(err);
@@ -189,7 +191,7 @@ class PageService {
     // delete
     this.pageEvent.on('delete', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE);
       }
       catch (err) {
         logger.error(err);
@@ -199,7 +201,7 @@ class PageService {
     // delete completely
     this.pageEvent.on('deleteCompletely', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE_COMPLETELY);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE_COMPLETELY);
       }
       catch (err) {
         logger.error(err);
@@ -209,7 +211,7 @@ class PageService {
     // revert
     this.pageEvent.on('revert', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_REVERT);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_REVERT);
       }
       catch (err) {
         logger.error(err);
@@ -219,7 +221,7 @@ class PageService {
     // likes
     this.pageEvent.on('like', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_LIKE);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_LIKE);
       }
       catch (err) {
         logger.error(err);
@@ -229,7 +231,7 @@ class PageService {
     // bookmark
     this.pageEvent.on('bookmark', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_BOOKMARK);
+        await this.createAndSendNotifications(user, page, SUPPORTED_ACTION_TYPE.ACTION_PAGE_BOOKMARK);
       }
       catch (err) {
         logger.error(err);
@@ -2231,17 +2233,17 @@ class PageService {
     return shortBodiesMap;
   }
 
-  private async createAndSendNotifications(page, user, action) {
+  private async createAndSendNotifications(user: IUserHasId, target: IPage, action: SupportedActionType) {
     const { activityService, inAppNotificationService } = this.crowi;
 
-    const snapshot = stringifySnapshot(page);
-
     // Create activity
+    const snapshotForActivity: ISnapshot = { username: user.username };
     const parameters = {
       user: user._id,
       targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
-      target: page,
+      target,
       action,
+      snapshot: snapshotForActivity,
     };
     const activity = await activityService.createByParameters(parameters);
 
@@ -2249,7 +2251,8 @@ class PageService {
     const targetUsers = await activity.getNotificationTargetUsers();
 
     // Create and send notifications
-    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
+    const snapshotForInAppNotification = stringifySnapshot(target);
+    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshotForInAppNotification);
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
 

+ 3 - 3
packages/app/src/stores/tag.tsx

@@ -2,11 +2,11 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
-import { ITagsListApiv1Result } from '~/interfaces/tag';
+import { IResTagsListApiv1 } from '~/interfaces/tag';
 
-export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
+export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
   return useSWRImmutable(
     ['/tags.list', limit, offset],
-    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: ITagsListApiv1Result) => result),
+    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
   );
 };

+ 10 - 0
packages/app/src/styles/_mixins.scss

@@ -223,3 +223,13 @@
     }
   }
 }
+
+@mixin count-badge($color, $bg-color, $min-width: initial) {
+  min-width: $min-width;
+  padding: 0.1rem 0.5rem;
+  font-family: $font-family-monospace;
+  font-size: 12px;
+  font-weight: 500;
+  color: $color;
+  background-color: $bg-color;
+}

+ 0 - 7
packages/app/src/styles/_page-tree.scss

@@ -48,13 +48,6 @@ $grw-pagetree-item-padding-left: 10px;
       &:hover {
         display: none;
       }
-
-      .grw-count-badge {
-        min-width: 28px;
-        padding: 0.1rem 0.5rem;
-        font-size: 12px;
-        font-weight: 500;
-      }
     }
   }
 

+ 1 - 2
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -479,8 +479,7 @@ ul.pagination {
 */
 .grw-side-contents-sticky-container {
   .grw-count-badge {
-    color: $gray-400;
-    background: $gray-700;
+    @include count-badge($gray-400, $gray-700);
   }
 
   .grw-border-vr {

+ 4 - 5
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -182,11 +182,11 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   // Pagetree
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
-      $color-list,
+      $color-sidebar-context,
       darken($bgcolor-sidebar-context, 5%),
       darken($bgcolor-sidebar-context, 12%),
-      $gray-500,
-      $gray-600,
+      lighten($color-sidebar-context, 10%),
+      lighten($color-sidebar-context, 8%),
       darken($bgcolor-sidebar-context, 15%),
       darken($bgcolor-sidebar-context, 24%)
     );
@@ -355,8 +355,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 */
 .grw-side-contents-sticky-container {
   .grw-count-badge {
-    color: $primary;
-    background: $gray-200;
+    @include count-badge($gray-600, $gray-200);
   }
 
   .grw-border-vr {

+ 6 - 8
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -28,8 +28,7 @@
     border-color: $border-color-global;
 
     .grw-count-badge {
-      color: $btn-color;
-      background: $bgcolor-hover;
+      @include count-badge($btn-color, $bgcolor-hover, 28px);
     }
 
     .btn.btn-page-item-control {
@@ -53,15 +52,14 @@
     &.list-group-item-action {
       &:hover {
         background-color: $bgcolor-hover;
-        .grw-count-badge {
-          background: $bgcolor-active;
-        }
       }
       &:active {
         background-color: $bgcolor-active;
-        .grw-count-badge {
-          background: $bgcolor-active;
-        }
+      }
+    }
+    .grw-pagetree-title-anchor {
+      .grw-sidebar-text-muted {
+        color: rgba(desaturate($color, 50%), 0.6);
       }
     }
   }

+ 1 - 1
packages/app/src/styles/theme/nature.scss

@@ -72,7 +72,7 @@ html[dark] {
   $bgcolor-sidebar: #188f64;
   // Sidebar contents
   $color-sidebar-context: #7e0044;
-  $bgcolor-sidebar-context: #fdffeb;
+  $bgcolor-sidebar-context: #f7f9e9;
   // Sidebar resize button
   $color-resize-button: white;
   $bgcolor-resize-button: $themecolor;

+ 1 - 2
packages/app/test/integration/models/v5.page.test.js

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 
+
 import { getInstance } from '../setup-crowi';
 
 describe('Page', () => {
@@ -7,7 +8,6 @@ describe('Page', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -35,7 +35,6 @@ describe('Page', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');

+ 3 - 2
packages/app/test/integration/service/page.test.js

@@ -1,8 +1,11 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
 
+import Tag from '~/server/models/tag';
+
 const mongoose = require('mongoose');
 
+
 const { getInstance } = require('../setup-crowi');
 
 let testUser1;
@@ -50,7 +53,6 @@ describe('PageService', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -64,7 +66,6 @@ describe('PageService', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');

+ 5 - 7
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -1,8 +1,8 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
-
 import mongoose from 'mongoose';
 
+import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with non-public pages', () => {
@@ -22,7 +22,6 @@ describe('PageService page operations with non-public pages', () => {
   let User;
   let UserGroup;
   let UserGroupRelation;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -93,7 +92,6 @@ describe('PageService page operations with non-public pages', () => {
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
@@ -1044,7 +1042,7 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage = await Page.findOne({ path: '/trash/np_revert1', status: Page.STATUS_DELETED, grant: Page.GRANT_RESTRICTED });
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag1' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(trashedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
@@ -1054,7 +1052,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
@@ -1071,7 +1069,7 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage = await Page.findOne({ path: beforeRevertPath, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag2' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(trashedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
@@ -1081,7 +1079,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();

+ 8 - 10
packages/app/test/integration/service/v5.public-page.test.ts

@@ -1,8 +1,8 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
-
 import mongoose from 'mongoose';
 
+import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with only public pages', () => {
@@ -14,7 +14,6 @@ describe('PageService page operations with only public pages', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -31,7 +30,6 @@ describe('PageService page operations with only public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
@@ -1304,8 +1302,8 @@ describe('PageService page operations with only public pages', () => {
       const basePage = await Page.findOne({ path: '/v5_PageForDuplicate5' });
       const tag1 = await Tag.findOne({ name: 'duplicate_Tag1' });
       const tag2 = await Tag.findOne({ name:  'duplicate_Tag2' });
-      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
-      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1?._id });
+      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2?._id });
       expect(basePage).toBeTruthy();
       expect(tag1).toBeTruthy();
       expect(tag2).toBeTruthy();
@@ -1463,8 +1461,8 @@ describe('PageService page operations with only public pages', () => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete6' });
       const tag1 = await Tag.findOne({ name: 'TagForDelete1' });
       const tag2 = await Tag.findOne({ name: 'TagForDelete2' });
-      const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
-      const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1?._id });
+      const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2?._id });
       expect(pageToDelete).toBeTruthy();
       expect(tag1).toBeTruthy();
       expect(tag2).toBeTruthy();
@@ -1549,7 +1547,7 @@ describe('PageService page operations with only public pages', () => {
       await deleteCompletely(parentPage, dummyUser1, {}, true);
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
-      const tags = await Tag.find({ _id: { $in: [tag1._id, tag2._id] } });
+      const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
       const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
       const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
       const deletedComments = await Comment.find({ _id: comment._id });
@@ -1632,14 +1630,14 @@ describe('PageService page operations with only public pages', () => {
       const deletedPage = await Page.findOne({ path: '/trash/v5_revert1', status: Page.STATUS_DELETED });
       const revision = await Revision.findOne({ pageId: deletedPage._id });
       const tag = await Tag.findOne({ name: 'revertTag1' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(deletedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
       const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.path).toBe('/v5_revert1');