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

Merge pull request #7277 from arafubeatbox/feat/110277-113224-anwer-or-decline-questionnaire

feat: 110277 113224 anwer or decline questionnaire
Haku Mizuki 3 лет назад
Родитель
Сommit
d41aa7d71f

+ 3 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -821,6 +821,9 @@
     "answer": "Answer",
     "no_answer": "No answer",
     "settings": "Questionnaire settings",
+    "failed_to_send": "Failed to send feedback",
+    "failed_to_skip": "Failed to skip questionnaire",
+    "skipped": "The questionnaire was skipped and won't be shown again",
     "title": "GROWI questionnaire for service improvement",
     "successfully_submit": "Your survey has been submitted.",
     "thanks_for_answer": "Thank you very much for taking the time to complete the survey.",

+ 3 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -820,6 +820,9 @@
     "answer": "回答する",
     "no_answer": "無回答",
     "settings": "アンケート設定",
+    "failed_to_send": "回答送信に失敗しました",
+    "failed_to_skip": "アンケートのスキップに失敗しました",
+    "skipped": "このアンケートはスキップされたため、今後表示されません",
     "title": "GROWI サービス改善のためのアンケート",
     "successfully_submit": "アンケートの送信が完了しました。",
     "thanks_for_answer": "アンケートのご回答誠にありがとうございました。",

+ 11 - 14
packages/app/src/components/Questionnaire/Question.tsx

@@ -3,9 +3,10 @@ import { useCurrentUser } from '~/stores/context';
 
 type QuestionProps = {
   question: IQuestionHasId,
+  inputNamePrefix: string,
 }
 
-const Question = ({ question }: QuestionProps): JSX.Element => {
+const Question = ({ question, inputNamePrefix }: QuestionProps): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
   const lang = currentUser?.lang;
 
@@ -17,29 +18,25 @@ const Question = ({ question }: QuestionProps): JSX.Element => {
         {questionText}
       </span>
     </div>
-    <div className="col-1 d-flex align-items-center p-0">
+    <div className="col-6 d-flex align-items-center pl-0">
       <div className="btn-group btn-group-toggle flex-fill grw-questionnaire-btn-group" data-toggle="buttons">
-        <label className="btn btn-outline-primary active">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-noAnswer`}/> -
+        <label className="btn btn-outline-primary active mr-3 rounded">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-noAnswer`} value='0' checked/> -
         </label>
-      </div>
-    </div>
-    <div className="col-5 d-flex align-items-center">
-      <div className="btn-group btn-group-toggle flex-fill grw-questionnaire-btn-group" data-toggle="buttons">
-        <label className="btn btn-outline-primary">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option1`}/> 1
+        <label className="btn btn-outline-primary rounded-left">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option1`} value='1'/> 1
         </label>
         <label className="btn btn-outline-primary">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option2`}/> 2
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option2`} value='2'/> 2
         </label>
         <label className="btn btn-outline-primary">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option3`}/> 3
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option3`} value='3'/> 3
         </label>
         <label className="btn btn-outline-primary">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option4`}/> 4
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option4`} value='4'/> 4
         </label>
         <label className="btn btn-outline-primary">
-          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option5`}/> 5
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option5`} value='5'/> 5
         </label>
       </div>
     </div>

+ 87 - 41
packages/app/src/components/Questionnaire/QuestionnaireModal.tsx

@@ -1,16 +1,22 @@
+import { useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { toastSuccess } from '~/client/util/toastr';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IAnswer } from '~/interfaces/questionnaire/answer';
 import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnaire-order';
 import { useCurrentUser } from '~/stores/context';
 import { useQuestionnaireModal } from '~/stores/modal';
-
+import loggerFactory from '~/utils/logger';
 
 import Question from './Question';
 
+const logger = loggerFactory('growi:QuestionnaireModal');
+
 type QuestionnaireModalProps = {
   questionnaireOrder: IQuestionnaireOrderHasId
 }
@@ -22,21 +28,59 @@ const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JS
   const { data: questionnaireModalData, close: closeQuestionnaireModal } = useQuestionnaireModal();
   const isOpened = questionnaireModalData?.openedQuestionnaireId === questionnaireOrder._id;
 
+  const inputNamePrefix = 'question-';
+
   const { t } = useTranslation();
 
-  const answerBtnClickHandler = () => {
-    toastSuccess(
-      <>
-        <div className="font-weight-bold">{t('questionnaire.thank_you_for_answering')}</div>
-        <div className="pt-2">{t('questionnaire.additional_feedback')}</div>
-      </>,
-      {
-        autoClose: 3000,
-        closeButton: true,
-      },
-    );
+  const sendAnswer = useCallback(async(answers: IAnswer[]) => {
+    try {
+      await apiv3Put('/questionnaire/answer', {
+        questionnaireOrderId: questionnaireOrder._id,
+        answers,
+      });
+      toastSuccess(
+        <>
+          <div className="font-weight-bold">{t('questionnaire.thank_you_for_answering')}</div>
+          <div className="pt-2">{t('questionnaire.additional_feedback')}</div>
+        </>,
+        {
+          autoClose: 3000,
+          closeButton: true,
+        },
+      );
+    }
+    catch (e) {
+      logger.error(e);
+      toastError(t('questionnaire.failed_to_send'));
+    }
+  }, [questionnaireOrder._id, t]);
+
+  const submitHandler = useCallback(async(event) => {
+    event.preventDefault();
+
+    const answers: IAnswer[] = questionnaireOrder.questions.map((question) => {
+      const answerValue = event.target[`${inputNamePrefix + question._id}`].value;
+      return { question: question._id, value: answerValue };
+    });
+
+    sendAnswer(answers);
+
+    closeQuestionnaireModal();
+  }, [closeQuestionnaireModal, questionnaireOrder.questions, sendAnswer]);
+
+  const skipBtnClickHandler = useCallback(async() => {
+    try {
+      apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      toastSuccess(t('questionnaire.skipped'));
+    }
+    catch (e) {
+      logger.error(e);
+      toastError(t('questionnaire.failed_to_skip'));
+    }
     closeQuestionnaireModal();
-  };
+  }, [closeQuestionnaireModal, questionnaireOrder._id, t]);
 
   const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
 
@@ -45,36 +89,38 @@ const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JS
     isOpen={isOpened}
     toggle={() => closeQuestionnaireModal()}
   >
-    <ModalHeader
-      tag="h4"
-      toggle={() => closeQuestionnaireModal()}
-      className="bg-primary text-light">
-      <span>{t('questionnaire.give_us_feedback')}</span>
-    </ModalHeader>
-    <ModalBody className="my-4">
-      <div className="container">
-        <h3 className="grw-modal-head">{questionnaireOrderTitle}</h3>
-        <div className="row mt-4">
-          <div className="col-6"></div>
-          <div className="col-1 p-0 font-weight-bold text-center align-items-center">{t('questionnaire.no_answer')}</div>
-          <div className="col-5 d-flex justify-content-between align-items-center">
-            <span className="font-weight-bold">{t('questionnaire.disagree')}</span>
-            <span className="font-weight-bold">{t('questionnaire.agree')}</span>
+    <form onSubmit={submitHandler}>
+      <ModalHeader
+        tag="h4"
+        toggle={() => closeQuestionnaireModal()}
+        className="bg-primary text-light">
+        <span>{t('questionnaire.give_us_feedback')}</span>
+      </ModalHeader>
+      <ModalBody className="my-4">
+        <div className="container">
+          <h3 className="grw-modal-head">{questionnaireOrderTitle}</h3>
+          <div className="row mt-4">
+            <div className="col-6"></div>
+            <div className="col-1 p-0 pr-2 font-weight-bold text-center align-items-center">{t('questionnaire.no_answer')}</div>
+            <div className="col-5 d-flex justify-content-between align-items-center pl-2">
+              <span className="font-weight-bold">{t('questionnaire.disagree')}</span>
+              <span className="font-weight-bold">{t('questionnaire.agree')}</span>
+            </div>
           </div>
+          {questionnaireOrder.questions?.map((question) => {
+            return <Question question={question} inputNamePrefix={inputNamePrefix} key={question._id}/>;
+          })}
         </div>
-        {questionnaireOrder.questions?.map((question) => {
-          return <Question question={question} key={question._id.toString()}/>;
-        })}
-      </div>
-    </ModalBody>
-    <ModalFooter>
-      {currentUser?.admin
+      </ModalBody>
+      <ModalFooter>
+        {currentUser?.admin
         && <a href="" className="mr-auto d-flex align-items-center"><i className="material-icons mr-1">settings</i>{t('questionnaire.settings')}</a>}
-      <>
-        <button type="button" className="btn btn-outline-secondary mr-3">{t('questionnaire.dont_show_again')}</button>
-        <button type="button" className="btn btn-primary" onClick={answerBtnClickHandler}>{t('questionnaire.answer')}</button>
-      </>
-    </ModalFooter>
+        <>
+          <button type="button" className="btn btn-outline-secondary mr-3" onClick={skipBtnClickHandler}>{t('questionnaire.dont_show_again')}</button>
+          <button type="submit" className="btn btn-primary">{t('questionnaire.answer')}</button>
+        </>
+      </ModalFooter>
+    </form>
   </Modal>);
 };
 

+ 3 - 1
packages/app/src/components/Questionnaire/QuestionnaireModalManager.tsx

@@ -10,7 +10,9 @@ const QuestionnaireModalManager = ():JSX.Element => {
 
   return <>
     {questionnaireOrders?.map((questionnaireOrder) => {
-      return <QuestionnaireModal questionnaireOrder={questionnaireOrder} key={questionnaireOrder._id} />;
+      return <QuestionnaireModal
+        questionnaireOrder={questionnaireOrder}
+        key={questionnaireOrder._id} />;
     })}
     <div className={styles['grw-questionnaire-toasts']}>
       {questionnaireOrders?.map((questionnaireOrder) => {

+ 19 - 1
packages/app/src/components/Questionnaire/QuestionnaireToast.tsx

@@ -2,10 +2,14 @@ import { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnaire-order';
 import { useCurrentUser } from '~/stores/context';
 import { useQuestionnaireModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
+const logger = loggerFactory('growi:QuestionnaireToast');
 
 type QuestionnaireToastProps = {
   questionnaireOrder: IQuestionnaireOrderHasId,
@@ -25,6 +29,20 @@ const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JS
     openQuestionnaireModal(questionnaireOrder._id);
   };
 
+  const skipBtnClickHandler = async() => {
+    apiv3Put('/questionnaire/skip', {
+      user: currentUser?._id,
+      questionnaireOrderId: questionnaireOrder._id,
+    }).then(() => {
+      toastSuccess(t('questionnaire.skipped'));
+    }).catch((e) => {
+      logger.error(e);
+      toastError(t('questionnaire.failed_to_skip'));
+    });
+
+    setIsOpen(false);
+  };
+
   const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
 
   return <div className={`toast ${isOpen ? 'show' : 'hide'}`}>
@@ -36,7 +54,7 @@ const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JS
     </div>
     <div className="toast-body bg-light d-flex justify-content-end">
       <button type="button" className="btn btn-secondary mr-3" onClick={answerBtnClickHandler}>{t('questionnaire.answer')}</button>
-      <button type="button" className="btn btn-secondary" onClick={() => setIsOpen(false)}>{t('questionnaire.skip')}</button>
+      <button type="button" className="btn btn-secondary" onClick={skipBtnClickHandler}>{t('questionnaire.skip')}</button>
     </div>
   </div>;
 };

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

@@ -0,0 +1,4 @@
+export interface IAnswer<ID = string> {
+  question: ID
+  value: string
+}

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

@@ -0,0 +1,13 @@
+import { Types } from 'mongoose';
+
+export const StatusType = {
+  not_answered: 'not_answered', answered: 'answered', skipped: 'skipped', denied: 'denied',
+} as const;
+
+export type StatusType = typeof StatusType[keyof typeof StatusType];
+
+export interface IQuestionnaireAnswerStatus {
+  user: Types.ObjectId | string // user that answered questionnaire
+  questionnaireOrderId: string
+  status: StatusType
+}

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

@@ -0,0 +1,10 @@
+import { IAnswer } from './answer';
+import { IGrowiInfo } from './growi-info';
+import { IUserInfo } from './user-info';
+
+export interface IQuestionnaireAnswer {
+  answers: IAnswer[]
+  answeredAt: Date
+  growiInfo: IGrowiInfo
+  userInfo: IUserInfo
+}

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

@@ -1,9 +1,12 @@
-export const UserType = { admin: 'admin', general: 'general' } as const;
+export const UserType = { admin: 'admin', general: 'general', guest: 'guest' } as const;
 
+type guestType = typeof UserType.guest
 export type UserType = typeof UserType[keyof typeof UserType];
 
-export interface IUserInfo {
+export type IUserInfo = {
   userIdHash: string // userId hash generated by using appSiteUrl as salt
-  type: UserType
+  type: Omit<UserType, guestType>
   userCreatedAt: Date // createdAt of user that answered the questionnaire
+} | {
+  type: guestType
 }

+ 1 - 1
packages/app/src/pages/_search.page.tsx

@@ -1,11 +1,11 @@
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
-import { useTranslation } from 'next-i18next';
 import SearchResultLayout from '~/components/Layout/SearchResultLayout';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';

+ 1 - 1
packages/app/src/pages/trash.page.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 
 import type { IUser, IUserHasId } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
@@ -28,7 +29,6 @@ import { NextPageWithLayout } from './_app.page';
 import {
   CommonProps, getServerSideCommonProps, getNextI18NextConfig, generateCustomTitleForPage,
 } from './utils/commons';
-import { useTranslation } from 'next-i18next';
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });

+ 7 - 0
packages/app/src/server/crowi/index.js

@@ -31,6 +31,7 @@ import PageOperationService from '../service/page-operation';
 // eslint-disable-next-line import/no-cycle
 import { PluginService } from '../service/plugin';
 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';
@@ -84,6 +85,7 @@ function Crowi() {
   this.activityService = null;
   this.commentService = null;
   this.xss = new Xss();
+  this.questionnaireInfoService = null;
   this.questionnaireCronService = null;
 
   this.tokens = null;
@@ -148,6 +150,7 @@ Crowi.prototype.init = async function() {
     this.setupActivityService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
+    this.setupQuestionnaireInfoService(),
     this.setUpCustomize(), // depends on pluginService
   ]);
 
@@ -318,6 +321,10 @@ Crowi.prototype.setupCron = function() {
   this.questionnaireCronService.startCron();
 };
 
+Crowi.prototype.setupQuestionnaireInfoService = function() {
+  this.questionnaireInfoService = new QuestionnaireInfoService(this);
+};
+
 Crowi.prototype.scanRuntimeVersions = async function() {
   const self = this;
 

+ 1 - 1
packages/app/src/server/middlewares/login-required.js

@@ -5,7 +5,7 @@ const logger = loggerFactory('growi:middleware:login-required');
 /**
  * require login handler
  *
- * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ * @param {boolean} isGuestAllowed whether guest user is allowed (default false)
  * @param {function} fallback fallback function which will be triggered when the check cannot be passed
  */
 module.exports = (crowi, isGuestAllowed = false, fallback = null) => {

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

@@ -0,0 +1,16 @@
+import { Model, Schema, Document } from 'mongoose';
+
+import { IQuestionnaireAnswerStatus, StatusType } from '~/interfaces/questionnaire/questionnaire-answer-status';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+export interface QuestionnaireAnswerStatusDocument extends IQuestionnaireAnswerStatus, Document {}
+
+export type QuestionnaireAnswerStatusModel = Model<QuestionnaireAnswerStatusDocument>
+
+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 },
+}, { timestamps: true });
+
+export default getOrCreateModel<QuestionnaireAnswerStatusDocument, QuestionnaireAnswerStatusModel>('QuestionnaireAnswerStatus', questionnaireOrderSchema);

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

@@ -112,7 +112,7 @@ module.exports = (crowi, app) => {
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
-  router.use('/questionnaire-orders', require('./questionnaire-orders')());
+  router.use('/questionnaire', require('./questionnaire')(crowi));
 
 
   return [router, routerForAdmin, routerForAuth];

+ 0 - 33
packages/app/src/server/routes/apiv3/questionnaire-orders.ts

@@ -1,33 +0,0 @@
-import express, { Request } from 'express';
-
-import QuestionnaireOrder from '~/server/models/questionnaire/questionnaire-order';
-import loggerFactory from '~/utils/logger';
-
-import { ApiV3Response } from './interfaces/apiv3-response';
-
-const logger = loggerFactory('growi:routes:apiv3:questionnaire');
-
-const router = express.Router();
-
-module.exports = () => {
-
-  router.get('/', async(req: Request, res: ApiV3Response) => {
-    const currentDate = new Date();
-    try {
-      const questionnaireOrders = await QuestionnaireOrder.find({
-        showUntil: {
-          $gte: currentDate,
-        },
-      });
-
-      return res.apiv3({ questionnaireOrders });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
-
-  return router;
-
-};

+ 93 - 0
packages/app/src/server/routes/apiv3/questionnaire.js

@@ -0,0 +1,93 @@
+import { Router } from 'express';
+
+import { StatusType } from '~/interfaces/questionnaire/questionnaire-answer-status';
+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';
+
+
+const logger = loggerFactory('growi:routes:apiv3:questionnaire');
+
+const router = Router();
+
+module.exports = (crowi) => {
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+
+  const User = crowi.model('User');
+
+  const changeAnswerStatus = async(user, questionnaireOrderId, status) => {
+    const result = await QuestionnaireAnswerStatus.updateOne({
+      user,
+      questionnaireOrderId,
+    }, {
+      status,
+    }, { upsert: true });
+
+    if (result.modifiedCount === 1) {
+      return 204;
+    }
+    if (result.upsertedCount === 1) {
+      return 201;
+    }
+    return 404;
+  };
+
+  router.get('/orders', async(req, res) => {
+    const currentDate = new Date();
+    try {
+      const questionnaireOrders = await QuestionnaireOrder.find({
+        showUntil: {
+          $gte: currentDate,
+        },
+      });
+
+      return res.apiv3({ questionnaireOrders });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.put('/answer', loginRequired, async(req, res) => {
+    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 questionnaireAnswer = {
+        growiInfo,
+        userInfo,
+        answers,
+        answeredAt: new Date(),
+      };
+
+      await axios.post(`${growiQuestionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswer);
+    };
+
+    try {
+      await sendQuestionnaireAnswer(req.user, req.body.answers);
+      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.answered);
+      return res.apiv3({}, status);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.put('/skip', loginRequired, async(req, res) => {
+    try {
+      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.skipped);
+      return res.apiv3({}, status);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+
+};

+ 66 - 0
packages/app/src/server/service/questionnaire-info.ts

@@ -0,0 +1,66 @@
+import crypto from 'crypto';
+import * as os from 'node:os';
+
+import { IGrowiInfo } from '~/interfaces/questionnaire/growi-info';
+import { IUserInfo, UserType } from '~/interfaces/questionnaire/user-info';
+import { IUserHasId } from '~/interfaces/user';
+
+class QuestionnaireInfoService {
+
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async getGrowiInfo(): Promise<IGrowiInfo> {
+    const User = this.crowi.model('User');
+
+    const appSiteUrl = this.crowi.appService.getSiteUrl();
+    const hasher = crypto.createHash('sha256');
+    hasher.update(appSiteUrl);
+    const appSiteUrlHashed = hasher.digest('hex');
+
+    const currentUsersCount = await User.countDocuments();
+    const currentActiveUsersCount = await User.countActiveUsers();
+    const attachmentType = this.crowi.configManager.getConfig('crowi', 'app:fileUploadType');
+
+    return {
+      version: this.crowi.version,
+      osInfo: {
+        type: os.type(),
+        platform: os.platform(),
+        arch: os.arch(),
+        totalmem: os.totalmem(),
+      },
+      appSiteUrl,
+      appSiteUrlHashed,
+      type: 'cloud', // TODO: set actual value
+      currentUsersCount,
+      currentActiveUsersCount,
+      wikiType: 'open', // TODO: set actual value
+      attachmentType,
+      activeExternalAccountTypes: undefined, // TODO: set actual value
+      deploymentType: undefined, // TODO: set actual value
+    };
+  }
+
+  getUserInfo(user: IUserHasId, appSiteUrlHashed: string): IUserInfo {
+    if (user) {
+      const hasher = crypto.createHmac('sha256', appSiteUrlHashed);
+      hasher.update(user._id.toString());
+
+      return {
+        userIdHash: hasher.digest('hex'),
+        type: user.admin ? UserType.admin : UserType.general,
+        userCreatedAt: user.createdAt,
+      };
+    }
+
+    return { type: UserType.guest };
+  }
+
+}
+
+export default QuestionnaireInfoService;

+ 1 - 1
packages/app/src/stores/questionnaire.tsx

@@ -5,7 +5,7 @@ import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnai
 
 export const useSWRxQuestionnaireOrders = (): SWRResponse<IQuestionnaireOrderHasId[], Error> => {
   return useSWR(
-    '/questionnaire-orders',
+    '/questionnaire/orders',
     endpoint => apiv3Get(endpoint).then((response) => {
       return response.data.questionnaireOrders;
     }),