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

Merge pull request #5864 from weseek/feat/94850-get-snapshot-username-api

feat: Get Snapshot username API
Yuki Takei 3 лет назад
Родитель
Сommit
6401d83e25

+ 5 - 5
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -48,12 +48,12 @@ export default class CommentMentionHelper {
     });
   }
 
-  getUsersList = async(username) => {
+  getUsersList = async(q: string) => {
     const limit = 20;
-    const { data } = await apiv3Get('/users/list', { username, limit });
-    return data.users.map(user => ({
-      text: `@${user.username} `,
-      displayText: user.username,
+    const { data } = await apiv3Get('/users/usernames', { q, limit });
+    return data.activeUser.usernames.map(username => ({
+      text: `@${username} `,
+      displayText: username,
     }));
   }
 

+ 25 - 1
packages/app/src/server/models/activity.ts

@@ -30,7 +30,7 @@ export interface ActivityModel extends Model<ActivityDocument> {
 }
 
 const snapshotSchema = new Schema<ISnapshot>({
-  username: { type: String },
+  username: { type: String, index: true },
 });
 
 // TODO: add revision id
@@ -116,4 +116,28 @@ activitySchema.statics.getPaginatedActivity = async function(limit: number, offs
   return paginateResult;
 };
 
+activitySchema.statics.findSnapshotUsernamesByUsernameRegexWithTotalCount = async function(
+    q: string, option: { sortOpt: number | string, offset: number, limit: number},
+): Promise<{usernames: string[], totalCount: number}> {
+  const opt = option || {};
+  const sortOpt = opt.sortOpt || 1;
+  const offset = opt.offset || 0;
+  const limit = opt.limit || 10;
+
+  const conditions = { 'snapshot.username': { $regex: q, $options: 'i' } };
+
+  const usernames = await this.aggregate()
+    .skip(0)
+    .limit(10000) // Narrow down the search target
+    .match(conditions)
+    .group({ _id: '$snapshot.username' })
+    .sort({ _id: sortOpt }) // Sort "snapshot.username" in ascending order
+    .skip(offset)
+    .limit(limit);
+
+  const totalCount = (await this.find(conditions).distinct('snapshot.username')).length;
+
+  return { usernames: usernames.map(r => r._id), totalCount };
+};
+
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);

+ 16 - 2
packages/app/src/server/models/user.js

@@ -715,8 +715,22 @@ module.exports = function(crowi) {
     return users;
   };
 
-  userSchema.statics.findUserByUsernameRegex = async function(username, limit) {
-    return this.find({ username: { $regex: username, $options: 'i' } }).limit(limit);
+  userSchema.statics.findUserByUsernameRegexWithTotalCount = async function(username, status, option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || { username: 1 };
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 10;
+
+    const conditions = { username: { $regex: username, $options: 'i' }, status: { $in: status } };
+
+    const users = await this.find(conditions)
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find(conditions).distinct('username')).length;
+
+    return { users, totalCount };
   };
 
   class UserUpperLimitException {

+ 5 - 0
packages/app/src/server/routes/apiv3/activity.ts

@@ -48,6 +48,11 @@ module.exports = (crowi: Crowi): Router => {
     try {
       const parsedSearchFilter = JSON.parse(req.query.searchFilter as string);
 
+      // add username to query
+      if (typeof parsedSearchFilter.username === 'string') {
+        Object.assign(query, { 'snapshot.username': parsedSearchFilter.username });
+      }
+
       // add action to query
       const canContainActionFilterToQuery = parsedSearchFilter.action.every(a => AllSupportedActionType.includes(a));
       if (canContainActionFilterToQuery) {

+ 51 - 6
packages/app/src/server/routes/apiv3/users.js

@@ -1,3 +1,4 @@
+import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -120,6 +121,13 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
 
+  validator.usernames = [
+    query('q').isString().withMessage('q is required'),
+    query('offset').optional().isInt().withMessage('offset must be a number'),
+    query('limit').optional().isInt({ max: 20 }).withMessage('You should set less than 20 or not to set limit.'),
+    query('options').optional().isString().withMessage('options must be string'),
+  ];
+
   const sendEmailByUserList = async(userList) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
@@ -899,17 +907,11 @@ module.exports = (crowi) => {
    */
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
     const userIds = req.query.userIds || null;
-    const username = req.query.username || null;
-    const limit = req.query.limit || 20;
 
     let userFetcher;
     if (userIds !== null && userIds.split(',').length > 0) {
       userFetcher = User.findUsersByIds(userIds.split(','));
     }
-    // Get username list by matching pattern from username mention
-    else if (username !== null) {
-      userFetcher = User.findUserByUsernameRegex(username, limit);
-    }
     else {
       userFetcher = User.findAllUsers();
     }
@@ -932,5 +934,48 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
+  router.get('/usernames', accessTokenParser, loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
+    const q = req.query.q;
+    const offset = +req.query.offset || 0;
+    const limit = +req.query.limit || 10;
+
+    try {
+      const options = JSON.parse(req.query.options || '{}');
+      const data = {};
+
+      if (options.isIncludeActiveUser == null || options.isIncludeActiveUser) {
+        const activeUserData = await User.findUserByUsernameRegexWithTotalCount(q, [User.STATUS_ACTIVE], { offset, limit });
+        const activeUsernames = activeUserData.users.map(user => user.username);
+        Object.assign(data, { activeUser: { usernames: activeUsernames, totalCount: activeUserData.totalCount } });
+      }
+
+      if (options.isIncludeInactiveUser) {
+        const inactiveUserStates = [User.STATUS_REGISTERED, User.STATUS_SUSPENDED, User.STATUS_INVITED];
+        const inactiveUserData = await User.findUserByUsernameRegexWithTotalCount(q, inactiveUserStates, { offset, limit });
+        const inactiveUsernames = inactiveUserData.users.map(user => user.username);
+        Object.assign(data, { inactiveUser: { usernames: inactiveUsernames, totalCount: inactiveUserData.totalCount } });
+      }
+
+      if (options.isIncludeActivitySnapshotUser && req.user.admin) {
+        const activitySnapshotUserData = await Activity.findSnapshotUsernamesByUsernameRegexWithTotalCount(q, { offset, limit });
+        Object.assign(data, { activitySnapshotUser: activitySnapshotUserData });
+      }
+
+      // eslint-disable-next-line max-len
+      const canIncludeMixedUsernames = (options.isIncludeMixedUsernames && req.user.admin) || (options.isIncludeMixedUsernames && !options.isIncludeActivitySnapshotUser);
+      if (canIncludeMixedUsernames) {
+        const allUsernames = [...data.activeUser?.usernames || [], ...data.inactiveUser?.usernames || [], ...data?.activitySnapshotUser?.usernames || []];
+        const distinctUsernames = Array.from(new Set(allUsernames));
+        Object.assign(data, { mixedUsernames: distinctUsernames });
+      }
+
+      return res.apiv3(data);
+    }
+    catch (err) {
+      logger.error('Failed to get usernames', err);
+      return res.apiv3Err(err);
+    }
+  });
+
   return router;
 };

+ 31 - 2
packages/app/src/stores/user.tsx

@@ -1,8 +1,8 @@
 import useSWR, { SWRResponse } from 'swr';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import useSWRImmutable from 'swr/immutable';
 
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { IUserHasId } from '~/interfaces/user';
-
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
 export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], Error> => {
@@ -15,3 +15,32 @@ export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], E
     { use: [checkAndUpdateImageUrlCached] },
   );
 };
+
+
+type usernameRequestOptions = {
+  isIncludeActiveUser?: boolean,
+  isIncludeInactiveUser?: boolean,
+  isIncludeActivitySnapshotUser?: boolean,
+  isIncludeMixedUsernames?: boolean,
+}
+
+type userData = {
+  usernames: string[]
+  totalCount: number
+}
+
+type usernameResult = {
+  activeUser?: userData
+  inactiveUser?: userData
+  activitySnapshotUser?: userData
+  mixedUsernames?: string[]
+}
+
+export const useSWRxUsernames = (q: string, offset?: number, limit?: number, options?: usernameRequestOptions): SWRResponse<usernameResult, Error> => {
+  return useSWRImmutable(
+    q != null ? ['/users/usernames', q, offset, limit, options] : null,
+    (endpoint, q, offset, limit, options) => apiv3Get(endpoint, {
+      q, offset, limit, options,
+    }).then(result => result.data),
+  );
+};