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

Merge pull request #7308 from weseek/feat/condition-to-show-questionnaire

feat: Condition to show questionnaire
Haku Mizuki 3 лет назад
Родитель
Сommit
769aae457f

+ 1 - 1
packages/app/src/components/Questionnaire/ProactiveQuestionnaireModal.tsx

@@ -72,7 +72,7 @@ const ProactiveQuestionnaireModal = (props: ModalProps): JSX.Element => {
       growiVersion,
     };
 
-    // TODO: send qestionnaire data
+    // TODO: send questionnaire data
 
     onClose();
     setQuestionnaireCompletionModal(true);

+ 16 - 2
packages/app/src/components/Questionnaire/QuestionnaireModal.tsx

@@ -82,17 +82,31 @@ const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JS
     closeQuestionnaireModal();
   }, [closeQuestionnaireModal, questionnaireOrder._id, t]);
 
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async() => {
+    closeQuestionnaireModal();
+
+    try {
+      await apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [closeQuestionnaireModal, questionnaireOrder._id]);
+
   const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
 
   return (<Modal
     size="lg"
     isOpen={isOpened}
-    toggle={() => closeQuestionnaireModal()}
+    toggle={closeBtnClickHandler}
   >
     <form onSubmit={submitHandler}>
       <ModalHeader
         tag="h4"
-        toggle={() => closeQuestionnaireModal()}
+        toggle={closeBtnClickHandler}
         className="bg-primary text-light">
         <span>{t('questionnaire.give_us_feedback')}</span>
       </ModalHeader>

+ 28 - 12
packages/app/src/components/Questionnaire/QuestionnaireToast.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -24,31 +24,47 @@ const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JS
 
   const { t } = useTranslation();
 
-  const answerBtnClickHandler = () => {
+  const answerBtnClickHandler = useCallback(() => {
     setIsOpen(false);
     openQuestionnaireModal(questionnaireOrder._id);
-  };
+  }, [openQuestionnaireModal, questionnaireOrder._id]);
 
-  const skipBtnClickHandler = async() => {
-    apiv3Put('/questionnaire/skip', {
-      user: currentUser?._id,
-      questionnaireOrderId: questionnaireOrder._id,
-    }).then(() => {
+  const skipBtnClickHandler = useCallback(async() => {
+    // Immediately close
+    setIsOpen(false);
+
+    try {
+      await apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
       toastSuccess(t('questionnaire.skipped'));
-    }).catch((e) => {
+    }
+    catch (e) {
       logger.error(e);
       toastError(t('questionnaire.failed_to_skip'));
-    });
+    }
+  }, [questionnaireOrder._id, t]);
 
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async() => {
     setIsOpen(false);
-  };
+
+    try {
+      await apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [questionnaireOrder._id]);
 
   const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
 
   return <div className={`toast ${isOpen ? 'show' : 'hide'}`}>
     <div className="toast-header bg-info">
       <strong className="mr-auto text-light">{questionnaireOrderTitle}</strong>
-      <button type="button" className="ml-2 mb-1 close" onClick={() => setIsOpen(false)}>
+      <button type="button" className="ml-2 mb-1 close" onClick={closeBtnClickHandler}>
         <span aria-hidden="true" className="text-light">&times;</span>
       </button>
     </div>

+ 3 - 0
packages/app/src/interfaces/questionnaire/growi-info.ts

@@ -10,7 +10,10 @@ const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
 const GrowiAttachmentType = {
   aws: 'aws',
   gcs: 'gcs',
+  gcp: 'gcp',
   gridfs: 'gridfs',
+  mongo: 'mongo',
+  mongodb: 'mongodb',
   local: 'local',
   none: 'none',
 } as const;

+ 3 - 0
packages/app/src/interfaces/questionnaire/questionnaire-answer-status.ts

@@ -1,3 +1,6 @@
+// eslint-disable-next-line max-len
+// see: https://dev.growi.org/6385911e1632aa30f4dae6a4#mdcont-%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%94%E3%81%A8%E3%81%AB%E5%9B%9E%E7%AD%94%E6%B8%88%E3%81%BF%E3%81%8B%E3%81%A9%E3%81%86%E3%81%8B%E3%82%92%E4%BF%9D%E5%AD%98%E3%81%99%E3%82%8B
+
 import { Types } from 'mongoose';
 
 export const StatusType = {

+ 1 - 1
packages/app/src/interfaces/questionnaire/user-info.ts

@@ -5,7 +5,7 @@ export type UserType = typeof UserType[keyof typeof UserType];
 
 export type IUserInfo = {
   userIdHash: string // userId hash generated by using appSiteUrl as salt
-  type: Omit<UserType, guestType>
+  type: Exclude<UserType, guestType>
   userCreatedAt: Date // createdAt of user that answered the questionnaire
 } | {
   type: guestType

+ 5 - 5
packages/app/src/server/crowi/index.js

@@ -30,8 +30,8 @@ import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 // eslint-disable-next-line import/no-cycle
 import { PluginService } from '../service/plugin';
+import QuestionnaireService from '../service/questionnaire';
 import QuestionnaireCronService from '../service/questionnaire-cron';
-import QuestionnaireInfoService from '../service/questionnaire-info';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -85,7 +85,7 @@ function Crowi() {
   this.activityService = null;
   this.commentService = null;
   this.xss = new Xss();
-  this.questionnaireInfoService = null;
+  this.questionnaireService = null;
   this.questionnaireCronService = null;
 
   this.tokens = null;
@@ -150,7 +150,7 @@ Crowi.prototype.init = async function() {
     this.setupActivityService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
-    this.setupQuestionnaireInfoService(),
+    this.setupQuestionnaireService(),
     this.setUpCustomize(), // depends on pluginService
   ]);
 
@@ -321,8 +321,8 @@ Crowi.prototype.setupCron = function() {
   this.questionnaireCronService.startCron();
 };
 
-Crowi.prototype.setupQuestionnaireInfoService = function() {
-  this.questionnaireInfoService = new QuestionnaireInfoService(this);
+Crowi.prototype.setupQuestionnaireService = function() {
+  this.questionnaireService = new QuestionnaireService(this);
 };
 
 Crowi.prototype.scanRuntimeVersions = async function() {

+ 1 - 1
packages/app/src/server/models/questionnaire/questionnaire-answer-status.ts

@@ -10,7 +10,7 @@ export type QuestionnaireAnswerStatusModel = Model<QuestionnaireAnswerStatusDocu
 const questionnaireOrderSchema = new Schema<QuestionnaireAnswerStatusDocument>({
   user: { type: Schema.Types.ObjectId, required: true },
   questionnaireOrderId: { type: String, required: true },
-  status: { type: String, enum: Object.values(StatusType), required: true },
+  status: { type: String, enum: Object.values(StatusType), default: StatusType.not_answered },
 }, { timestamps: true });
 
 export default getOrCreateModel<QuestionnaireAnswerStatusDocument, QuestionnaireAnswerStatusModel>('QuestionnaireAnswerStatus', questionnaireOrderSchema);

+ 32 - 17
packages/app/src/server/routes/apiv3/questionnaire.js → packages/app/src/server/routes/apiv3/questionnaire.ts

@@ -1,20 +1,25 @@
-import { Router } from 'express';
+import { Router, Request } from 'express';
 
 import { StatusType } from '~/interfaces/questionnaire/questionnaire-answer-status';
+import Crowi from '~/server/crowi';
 import QuestionnaireAnswerStatus from '~/server/models/questionnaire/questionnaire-answer-status';
-import QuestionnaireOrder from '~/server/models/questionnaire/questionnaire-order';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
+import { ApiV3Response } from './interfaces/apiv3-response';
+
 
 const logger = loggerFactory('growi:routes:apiv3:questionnaire');
 
 const router = Router();
 
-module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+interface AuthorizedRequest extends Request {
+  user?: any
+}
 
-  const User = crowi.model('User');
+module.exports = (crowi: Crowi): Router => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
 
   const changeAnswerStatus = async(user, questionnaireOrderId, status) => {
     const result = await QuestionnaireAnswerStatus.updateOne({
@@ -33,14 +38,13 @@ module.exports = (crowi) => {
     return 404;
   };
 
-  router.get('/orders', async(req, res) => {
-    const currentDate = new Date();
+  router.get('/orders', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
+    const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+
+    // TODO: add condition
     try {
-      const questionnaireOrders = await QuestionnaireOrder.find({
-        showUntil: {
-          $gte: currentDate,
-        },
-      });
+      const questionnaireOrders = await crowi.questionnaireService!.getQuestionnaireOrdersToShow(userInfo, growiInfo, req.user?._id ?? null);
 
       return res.apiv3({ questionnaireOrders });
     }
@@ -50,11 +54,11 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/answer', loginRequired, async(req, res) => {
+  router.put('/answer', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async(user, answers) => {
       const growiQuestionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:growiQuestionnaireServerOrigin');
-      const growiInfo = await crowi.questionnaireInfoService.getGrowiInfo();
-      const userInfo = crowi.questionnaireInfoService.getUserInfo(user, growiInfo.appSiteUrlHashed);
+      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
+      const userInfo = crowi.questionnaireService!.getUserInfo(user, growiInfo.appSiteUrlHashed);
 
       const questionnaireAnswer = {
         growiInfo,
@@ -67,7 +71,7 @@ module.exports = (crowi) => {
     };
 
     try {
-      await sendQuestionnaireAnswer(req.user, req.body.answers);
+      await sendQuestionnaireAnswer(req.user ?? null, req.body.answers);
       const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.answered);
       return res.apiv3({}, status);
     }
@@ -77,7 +81,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/skip', loginRequired, async(req, res) => {
+  router.put('/skip', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     try {
       const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.skipped);
       return res.apiv3({}, status);
@@ -88,6 +92,17 @@ module.exports = (crowi) => {
     }
   });
 
+  router.put('/deny', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    try {
+      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.denied);
+      return res.apiv3({}, status);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
   return router;
 
 };

+ 10 - 0
packages/app/src/server/service/questionnaire-cron.ts

@@ -1,10 +1,12 @@
 import axiosRetry from 'axios-retry';
 
+import { StatusType } from '~/interfaces/questionnaire/questionnaire-answer-status';
 import { IQuestionnaireOrder } from '~/interfaces/questionnaire/questionnaire-order';
 import loggerFactory from '~/utils/logger';
 import { getRandomIntInRange } from '~/utils/rand';
 import { sleep } from '~/utils/sleep';
 
+import QuestionnaireAnswerStatus from '../models/questionnaire/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire/questionnaire-order';
 
 const logger = loggerFactory('growi:service:questionnaire-cron');
@@ -56,7 +58,15 @@ class QuestionnaireCronService {
         const response = await axios.get(`${growiQuestionnaireServerOrigin}/questionnaire-order/index`);
         const questionnaireOrders: IQuestionnaireOrder[] = response.data.questionnaireOrders;
 
+        // Reset status (denied => not_answered)
+        await QuestionnaireAnswerStatus.updateMany(
+          { status: StatusType.denied },
+          { status: StatusType.not_answered },
+        );
+
+        // Cleanup
         await QuestionnaireOrder.deleteMany();
+
         await saveOrders(questionnaireOrders);
       }
       catch (e) {

+ 36 - 5
packages/app/src/server/service/questionnaire-info.ts → packages/app/src/server/service/questionnaire.ts

@@ -2,10 +2,16 @@ import crypto from 'crypto';
 import * as os from 'node:os';
 
 import { IGrowiInfo } from '~/interfaces/questionnaire/growi-info';
+import { StatusType } from '~/interfaces/questionnaire/questionnaire-answer-status';
 import { IUserInfo, UserType } from '~/interfaces/questionnaire/user-info';
 import { IUserHasId } from '~/interfaces/user';
+import QuestionnaireOrder, { QuestionnaireOrderDocument } from '~/server/models/questionnaire/questionnaire-order';
 
-class QuestionnaireInfoService {
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import QuestionnaireAnswerStatus from '../models/questionnaire/questionnaire-answer-status';
+import { isShowableCondition } from '../util/questionnaire/condition';
+
+class QuestionnaireService {
 
   crowi: any;
 
@@ -34,7 +40,7 @@ class QuestionnaireInfoService {
         arch: os.arch(),
         totalmem: os.totalmem(),
       },
-      appSiteUrl,
+      appSiteUrl, // TODO: set only if allowed (see: https://dev.growi.org/6385911e1632aa30f4dae6a4#mdcont-%E5%8C%BF%E5%90%8D%E5%8C%96%E3%81%8C%E5%BF%85%E8%A6%81%E3%81%AA%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3)
       appSiteUrlHashed,
       type: 'cloud', // TODO: set actual value
       currentUsersCount,
@@ -46,8 +52,8 @@ class QuestionnaireInfoService {
     };
   }
 
-  getUserInfo(user: IUserHasId, appSiteUrlHashed: string): IUserInfo {
-    if (user) {
+  getUserInfo(user: IUserHasId | null, appSiteUrlHashed: string): IUserInfo {
+    if (user != null) {
       const hasher = crypto.createHmac('sha256', appSiteUrlHashed);
       hasher.update(user._id.toString());
 
@@ -61,6 +67,31 @@ class QuestionnaireInfoService {
     return { type: UserType.guest };
   }
 
+  async getQuestionnaireOrdersToShow(userInfo: IUserInfo, growiInfo: IGrowiInfo, userId: ObjectIdLike | null): Promise<QuestionnaireOrderDocument[]> {
+    const currentDate = new Date();
+
+    let questionnaireOrders = await QuestionnaireOrder.find({
+      showUntil: {
+        $gte: currentDate,
+      },
+    });
+
+    if (userId != null) {
+      const statuses = await QuestionnaireAnswerStatus.find({ userId, questionnaireOrderId: { $in: questionnaireOrders.map(d => d._id) } });
+
+      questionnaireOrders = questionnaireOrders.filter((order) => {
+        const status = statuses.find(s => s.questionnaireOrderId.toString() === order._id.toString());
+
+        return status?.status === StatusType.not_answered;
+      });
+    }
+
+    return questionnaireOrders
+      .filter((order) => {
+        return isShowableCondition(order, userInfo, growiInfo);
+      });
+  }
+
 }
 
-export default QuestionnaireInfoService;
+export default QuestionnaireService;

+ 38 - 0
packages/app/src/server/util/questionnaire/condition.ts

@@ -0,0 +1,38 @@
+import { ICondition } from '~/interfaces/questionnaire/condition';
+import { IGrowiInfo } from '~/interfaces/questionnaire/growi-info';
+import { IQuestionnaireOrder } from '~/interfaces/questionnaire/questionnaire-order';
+import { IUserInfo } from '~/interfaces/questionnaire/user-info';
+
+
+const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
+  const { user: { types } } = condition;
+
+  return types.includes(userInfo.type);
+};
+
+const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean => {
+  const { growi: { types, versionRegExps } } = condition;
+
+  if (!types.includes(growiInfo.type)) {
+    return false;
+  }
+
+  if (!versionRegExps.some(rs => new RegExp(rs).test(growiInfo.version))) {
+    return false;
+  }
+
+  return true;
+};
+
+export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo): boolean => {
+  const { condition } = order;
+
+  if (!checkUserInfo(condition, userInfo)) {
+    return false;
+  }
+  if (!checkGrowiInfo(condition, growiInfo)) {
+    return false;
+  }
+
+  return true;
+};