Browse Source

Merge branch 'master' into feat/gw7925-normalize-cypress-test

ryoji-s 2 years ago
parent
commit
0bf0d7f360
77 changed files with 2979 additions and 59 deletions
  1. 11 9
      .devcontainer/Dockerfile
  2. 1 0
      apps/app/.env.development
  3. 1 0
      apps/app/package.json
  4. 11 1
      apps/app/public/static/locales/en_US/admin.json
  5. 26 1
      apps/app/public/static/locales/en_US/commons.json
  6. 21 0
      apps/app/public/static/locales/en_US/translation.json
  7. 11 1
      apps/app/public/static/locales/ja_JP/admin.json
  8. 26 2
      apps/app/public/static/locales/ja_JP/commons.json
  9. 20 0
      apps/app/public/static/locales/ja_JP/translation.json
  10. 11 1
      apps/app/public/static/locales/zh_CN/admin.json
  11. 26 1
      apps/app/public/static/locales/zh_CN/commons.json
  12. 21 1
      apps/app/public/static/locales/zh_CN/translation.json
  13. 8 0
      apps/app/src/components/Admin/App/AppSettingsPageContents.tsx
  14. 113 0
      apps/app/src/components/Admin/App/QuestionnaireSettings.tsx
  15. 2 3
      apps/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx
  16. 102 0
      apps/app/src/components/Me/OtherSettings.tsx
  17. 16 2
      apps/app/src/components/Me/PersonalSettings.jsx
  18. 32 9
      apps/app/src/components/Navbar/PersonalDropdown.jsx
  19. 3 3
      apps/app/src/components/PageAttachment.tsx
  20. 3 3
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  21. 3 3
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  22. 152 0
      apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx
  23. 47 0
      apps/app/src/features/questionnaire/client/components/Question.tsx
  24. 165 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModal.tsx
  25. 9 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.module.scss
  26. 46 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.tsx
  27. 85 0
      apps/app/src/features/questionnaire/client/components/QuestionnaireToast.tsx
  28. 79 0
      apps/app/src/features/questionnaire/client/services/guest-questionnaire-answer-status.ts
  29. 40 0
      apps/app/src/features/questionnaire/client/stores/model.tsx
  30. 23 0
      apps/app/src/features/questionnaire/client/stores/questionnaire.tsx
  31. 4 0
      apps/app/src/features/questionnaire/interfaces/answer.ts
  32. 25 0
      apps/app/src/features/questionnaire/interfaces/condition.ts
  33. 59 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  34. 14 0
      apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts
  35. 15 0
      apps/app/src/features/questionnaire/interfaces/question.ts
  36. 16 0
      apps/app/src/features/questionnaire/interfaces/questionnaire-answer-status.ts
  37. 11 0
      apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts
  38. 21 0
      apps/app/src/features/questionnaire/interfaces/questionnaire-order.ts
  39. 12 0
      apps/app/src/features/questionnaire/interfaces/user-info.ts
  40. 27 0
      apps/app/src/features/questionnaire/server/models/proactive-questionnaire-answer.ts
  41. 17 0
      apps/app/src/features/questionnaire/server/models/questionnaire-answer-status.ts
  42. 24 0
      apps/app/src/features/questionnaire/server/models/questionnaire-answer.ts
  43. 34 0
      apps/app/src/features/questionnaire/server/models/questionnaire-order.ts
  44. 10 0
      apps/app/src/features/questionnaire/server/models/schema/answer.ts
  45. 29 0
      apps/app/src/features/questionnaire/server/models/schema/condition.ts
  46. 24 0
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  47. 15 0
      apps/app/src/features/questionnaire/server/models/schema/question.ts
  48. 9 0
      apps/app/src/features/questionnaire/server/models/schema/user-info.ts
  49. 204 0
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  50. 121 0
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  51. 114 0
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  52. 67 0
      apps/app/src/features/questionnaire/server/util/condition.ts
  53. 3 0
      apps/app/src/interfaces/activity.ts
  54. 2 2
      apps/app/src/interfaces/attachment.ts
  55. 48 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  56. 3 2
      apps/app/src/pages/[[...path]].page.tsx
  57. 18 0
      apps/app/src/server/crowi/index.js
  58. 1 1
      apps/app/src/server/middlewares/login-required.js
  59. 6 0
      apps/app/src/server/models/user.js
  60. 37 0
      apps/app/src/server/routes/apiv3/app-settings.js
  61. 1 0
      apps/app/src/server/routes/apiv3/index.js
  62. 18 0
      apps/app/src/server/routes/apiv3/personal-setting.js
  63. 2 2
      apps/app/src/server/routes/apiv3/user-activation.ts
  64. 2 2
      apps/app/src/server/routes/login.js
  65. 43 1
      apps/app/src/server/service/config-loader.ts
  66. 13 0
      apps/app/src/stores/admin/app-settings.tsx
  67. 2 3
      apps/app/src/stores/attachment.tsx
  68. 3 0
      apps/app/src/stores/modal.tsx
  69. 21 2
      apps/app/src/styles/theme/apply-colors.scss
  70. 5 0
      apps/app/src/utils/rand.ts
  71. 3 1
      apps/app/test/cypress/integration/40-admin/40-admin--access-to-admin-page.spec.ts
  72. 37 0
      apps/app/test/cypress/integration/60-home/60-home--home.spec.ts
  73. 408 0
      apps/app/test/integration/service/questionnaire-cron.test.ts
  74. 306 0
      apps/app/test/integration/service/questionnaire.test.ts
  75. 1 0
      packages/core/src/interfaces/user.ts
  76. 3 3
      packages/ui/src/components/Attachment.tsx
  77. 7 0
      yarn.lock

+ 11 - 9
.devcontainer/Dockerfile

@@ -37,15 +37,17 @@ RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 
 RUN apt-get update \
-   && apt-get -y install --no-install-recommends git-lfs \
-      # Chrome
-      google-chrome-stable \
-      # for Cypress
-      libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
-   # Clean up
-   && apt-get autoremove -y \
-   && apt-get clean -y \
-   && rm -rf /var/lib/apt/lists/*
+    && apt-get -y install --no-install-recommends git-lfs \
+
+    # Uncomment below lines to install Chrome and libs for Cypress
+    # --- works only on AMD64 ---
+    # && apt-get -y install --no-install-recommends google-chrome-stable \
+    #    libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
+
+    # Clean up
+    && apt-get autoremove -y \
+    && apt-get clean -y \
+    && rm -rf /var/lib/apt/lists/*
 ENV DEBIAN_FRONTEND=dialog
 
 RUN yarn global add turbo

+ 1 - 0
apps/app/.env.development

@@ -16,6 +16,7 @@ ELASTICSEARCH_REJECT_UNAUTHORIZED=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 OGP_URI="http://ogp:8088"
+QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # PUBLISH_OPEN_API=true

+ 1 - 0
apps/app/package.json

@@ -134,6 +134,7 @@
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
     "nocache": "^3.0.1",
+    "node-cron": "^3.0.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",

+ 11 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -387,7 +387,17 @@
     "enable": "Enable",
     "disable": "Disable",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
-    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "questionnaire_settings": "Questionnaire settings",
+    "questionnaire_settings_explanation": "This will enable/disable questionnaires on the whole system. When enabled, users can also enable/disable questionnaires individually from \"Other Settings\" in the personal settings page.",
+    "about_data_sent": "About information sent",
+    "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
+    "learn_more": "Learn more",
+    "other_info_will_be_sent": "Along with the questionnaire answer, information necessary to improve GROWI will be sent. Personal user info will not be included in the data sent.",
+    "we_will_use_the_data_to_improve_growi": "We will use the data to improve GROWI experience as much as possible.",
+    "anonymize_app_site_url": "Anonymize app site URL in data sent",
+    "url_anonymization_explanation": "The app site URL included in the questionnaire answer will be anonymized. By enabling this, the GROWI application that sends the questionnaire answer will not be identified.",
+    "enable_questionnaire": "Enable questionnaire"
   },
   "markdown_settings": {
     "markdown_settings": "Markdown Settings",

+ 26 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -61,7 +61,8 @@
     "color_mode": "Color mode",
     "sidebar_mode": "Sidebar mode",
     "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
+    "use_os_settings": "Use OS settings",
+    "feedback": "Feedback"
   },
 
   "copy_to_clipboard": {
@@ -96,6 +97,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "Required",
+    "submit": "Submit",
+    "close": "Close",
+    "title": "GROWI questionnaire for service improvement",
+    "more_satisfied_services": "We hope that GROWI customers will be even more satisfied",
+    "strive_to_improve_services": "once we improve our services based on your feedback.",
+    "length_of_experience": {
+      "more_than_two_years": "More than 2 years",
+      "one_to_two_years": "More than 1 year but less than 2 years",
+      "six_months_to_one_year": "More than 6 months but less than 1 year",
+      "three_months_to_six_months": "More than 3 months but less than 6 months",
+      "one_month_to_three_months": "More than 1 month but less than 3 months",
+      "less_than_one_month": "Less than 1 month"
+    },
+    "satisfaction_with_growi": "Satisfaction with GROWI",
+    "history_of_growi_usage": "History of GROWI usage",
+    "occupation": "Occupation",
+    "position": "Position",
+    "comment_on_growi": "Comment on GROWI",
+    "successfully_submitted": "Your survey has been submitted.",
+    "thanks_for_answering": "Thank you very much for answering."
+  },
+
   "not_found_page": {
     "page_not_exist": "This page does not exist."
   },

+ 21 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -50,6 +50,8 @@
   "Sign up with Google Account": "Sign up with Google Account",
   "Sign in with Google Account": "Sign in with Google Account",
   "Sign up with this Google Account": "Sign up with this Google Account",
+  "Select": "Select",
+  "Required": "Required",
   "Example": "Example",
   "Taro Yamada": "John Doe",
   "List View": "List",
@@ -135,6 +137,7 @@
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
+  "Questionnaire": "Questionnaire",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
@@ -236,6 +239,7 @@
     "link_sharing_is_disabled": "Link sharing is disabled"
   },
   "API Settings": "API settings",
+  "Other Settings": "Other Settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
@@ -798,6 +802,23 @@
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature"
   },
+  "questionnaire": {
+    "give_us_feedback": "Give us feedback for improvements",
+    "thank_you_for_answering": "Thank you for answering",
+    "additional_feedback": "Send us additional feedback from the user icon dropdown.",
+    "dont_show_again": "Don`t show again",
+    "deny": "Don't answer",
+    "agree": "Agree",
+    "disagree": "Disagree",
+    "answer": "Answer",
+    "no_answer": "No answer",
+    "settings": "Questionnaire settings",
+    "failed_to_send": "Failed to send feedback",
+    "denied": "The questionnaire won't be shown again",
+    "personal_settings_explanation": "Questionnaires for improving GROWI will be shown. If you have other feedbacks, you can send them from the user icon dropdown.",
+    "enable_questionnaire": "Enable questionnaire",
+    "disabled_by_admin": "Questionnaire is disabled by admin"
+  },
   "tag_edit_modal": {
     "edit_tags": "Edit Tags",
     "done": "Done",

+ 11 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -395,7 +395,17 @@
     "enable": "有効",
     "disable": "無効",
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
-    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
+    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+    "questionnaire_settings": "アンケート設定",
+    "questionnaire_settings_explanation": "システム全体でアンケート機能を有効/無効にします。有効の場合、各ユーザーはユーザー設定ページの「その他の設定」から個別にアンケート機能を有効/無効にできます。",
+    "about_data_sent": "送信される情報について",
+    "docs_link": "https://docs.growi.org/ja/admin-guide/management-cookbook/app-settings.html#%E3%82%A2%E3%83%B3%E3%82%B1%E3%83%BC%E3%83%88%E8%A8%AD%E5%AE%9A",
+    "learn_more": "詳細",
+    "other_info_will_be_sent": "アンケートの回答と合わせて、GROWI の改善に必要な情報を送信します。送信されるデータにユーザーの個人情報は含まれません。",
+    "we_will_use_the_data_to_improve_growi": "私たちはそれらを活用し、最大限ユーザーの体験を向上させるよう努めます。",
+    "anonymize_app_site_url": "サイト URL を匿名化して送信する",
+    "url_anonymization_explanation": "アンケート回答データに含まれるサイト URL が匿名化されます。この設定を有効にすることで、アンケート回答データの送信元である GROWI アプリケーションが特定されなくなります。",
+    "enable_questionnaire": "アンケートを有効にする"
   },
   "markdown_settings": {
     "markdown_settings": "マークダウン設定",

+ 26 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -5,7 +5,6 @@
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",
-
   "meta": {
     "display_name": "日本語"
   },
@@ -61,7 +60,8 @@
     "color_mode": "カラーモード",
     "sidebar_mode": "サイドバーモード",
     "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
+    "use_os_settings": "OS設定を利用する",
+    "feedback": "ご意見・ご要望"
   },
 
   "copy_to_clipboard": {
@@ -96,6 +96,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "必須",
+    "submit": "送信",
+    "close": "閉じる",
+    "title": "GROWI サービス改善のためのアンケート",
+    "more_satisfied_services": "GROWI をご利用の皆さまに更にご満足いただけるよう",
+    "strive_to_improve_services": "皆さまからのご意見を参考にサービス改善に努めてまいります。",
+    "length_of_experience": {
+      "more_than_two_years": "2年以上",
+      "one_to_two_years": "1年以上2年未満",
+      "six_months_to_one_year": "6ヶ月以上1年未満",
+      "three_months_to_six_months": "3ヶ月以上6ヶ月未満",
+      "one_month_to_three_months": "1ヶ月以上3ヶ月未満",
+      "less_than_one_month": "1ヶ月未満"
+    },
+    "satisfaction_with_growi": "GROWI の満足度",
+    "history_of_growi_usage": "GROWI の利用歴",
+    "occupation": "職種",
+    "position": "役職",
+    "comment_on_growi": "GROWI へのコメント",
+    "successfully_submitted": "アンケートの送信が完了しました。",
+    "thanks_for_answering": "アンケートのご回答誠にありがとうございました。"
+  },
+
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
   },

+ 20 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -47,6 +47,8 @@
   "Sign up with Google Account": "Google で登録",
   "Sign in with Google Account": "Google でログイン",
   "Sign up with this Google Account": "この Google アカウントで登録します",
+  "Select": "選択してください",
+  "Required": "必須",
   "Example": "例",
   "Taro Yamada": "山田 太郎",
   "List View": "リスト表示",
@@ -238,6 +240,7 @@
     "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "API Settings": "API設定",
+  "Other Settings": "その他の設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
@@ -832,6 +835,23 @@
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する"
   },
+  "questionnaire": {
+    "give_us_feedback": "GROWI の改善のために、アンケートにご協力ください",
+    "thank_you_for_answering": "ご回答ありがとうございます",
+    "additional_feedback": "その他ご意見ご要望はユーザーアイコンのドロップダウンからお願い致します。",
+    "dont_show_again": "今後このアンケートを表示しない",
+    "deny": "回答しない",
+    "agree": "そう思う",
+    "disagree": "そう思わない",
+    "answer": "回答する",
+    "no_answer": "わからない",
+    "settings": "アンケート設定",
+    "failed_to_send": "回答送信に失敗しました",
+    "denied": "このアンケートは今後表示されません",
+    "personal_settings_explanation": "GROWI 改善のためのアンケートが表示されるようになります。ご意見ご要望はユーザーアイコンのドロップダウンからお願いいたします。",
+    "enable_questionnaire": "アンケートを有効にする",
+    "disabled_by_admin": "管理者によってアンケートは無効化されています"
+  },
   "tag_edit_modal": {
     "edit_tags": "タグの編集",
     "done": "完了",

+ 11 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -395,7 +395,17 @@
     "enable": "启用",
     "disable": "停用",
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
-    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "questionnaire_settings": "问卷设置",
+    "questionnaire_settings_explanation": "这将在整个系统上启用/禁用问卷。 启用后,用户还可以在个人设置页面的“其他设置”中单独启用/禁用问卷调查。",
+    "about_data_sent": "关于发送的信息",
+    "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
+    "learn_more": "细节",
+    "other_info_will_be_sent": "与问卷回答一起,将发送改进 GROWI 所需的信息。个人用户信息将不包含在发送的数据中。",
+    "we_will_use_the_data_to_improve_growi": "我们将使用这些数据尽可能地改善 GROWI 体验。",
+    "anonymize_app_site_url": "在发送的数据中匿名应用程序站点 URL",
+    "url_anonymization_explanation": "问卷答案中包含的应用站点URL将被匿名化,启用后将不会识别发送问卷答案的GROWI应用。",
+    "enable_questionnaire": "启用问卷"
   },
   "markdown_settings": {
     "markdown_settings": "Markdown设置",

+ 26 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -61,7 +61,8 @@
 		"color_mode": "颜色模式",
 		"sidebar_mode": "边栏模式",
 		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
+		"use_os_settings": "使用操作系统设置",
+    "feedback": "意见和要求"
   },
 
 	"copy_to_clipboard": {
@@ -96,6 +97,30 @@
     }
   },
 
+  "questionnaire_modal": {
+    "required": "必需的",
+    "submit": "发送",
+    "close": "Close",
+    "title": "改善服务的GROWI调查表",
+    "more_satisfied_services": "我们希望让使用GROWI的人更加满意",
+    "strive_to_improve_services": "我们将利用你的反馈来改善我们的服务。",
+    "length_of_experience": {
+      "more_than_two_years": "2年以上",
+      "one_to_two_years": "超过1年但少于2年",
+      "six_months_to_one_year": "超过6个月但少于1年",
+      "three_months_to_six_months": "超过3个月但少于6个月",
+      "one_month_to_three_months": "超过1个月但少于3个月",
+      "less_than_one_month": "不到1个月"
+    },
+    "satisfaction_with_growi": "对GROWI的满意程度",
+    "history_of_growi_usage": "GROWI的使用历史",
+    "occupation": "职位",
+    "position": "职业类型",
+    "comment_on_growi": "关于GROWI的评论",
+    "successfully_submitted": "问卷已经发出。",
+    "thanks_for_answering": "非常感谢您完成问卷调查。"
+  },
+
   "not_found_page": {
     "page_not_exist": "该页面不存在"
   },

+ 21 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -49,6 +49,8 @@
 	"Sign up with this Google Account": "Sign up with this Google Account",
 	"Example": "例如",
 	"Taro Yamada": "John Doe",
+  "Select": "请选择",
+  "Required": "必需的",
 	"List View": "列表",
 	"Timeline View": "时间线",
   "History": "历史",
@@ -228,6 +230,7 @@
 		"password_is_not_set": "密码未设置"
 	},
 	"API Settings": "API设置",
+  "Other Settings": "其他设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
@@ -781,7 +784,7 @@
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
   },
-  "bookmark_folder":{
+  "bookmark_folder": {
     "bookmark_folder": "书签文件夹",
     "bookmark": "书签",
     "delete_modal": {
@@ -798,6 +801,23 @@
     "move_to_root": "移动到根部",
     "do_not_include_folder": "不包括在文件夹中"
   },
+  "questionnaire": {
+    "give_us_feedback": "向我们提供反馈以进行改进",
+    "thank_you_for_answering": "谢谢你的回答",
+    "additional_feedback": "从用户图标下拉菜单向我们发送更多反馈。",
+    "dont_show_again": "不再显示",
+    "deny": "不要回答",
+    "agree": "同意",
+    "disagree": "不同意",
+    "answer": "答案是",
+    "no_answer": "没有答案",
+    "settings": "问卷设置",
+    "failed_to_send": "无法发送反馈",
+    "denied": "问卷不会再显示",
+    "personal_settings_explanation": "将展示改进 GROWI 的问卷。 如果您有其他反馈,可以从用户图标下拉菜单中发送。",
+    "enable_questionnaire": "启用问卷",
+    "disabled_by_admin": "问卷已被管理员禁用"
+  },
   "v5_page_migration": {
     "page_tree_not_avaliable": "Page Tree 功能不可用",
     "go_to_settings": "进入设置,启用该功能"

+ 8 - 0
apps/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -14,6 +14,7 @@ import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
 import { MaintenanceMode } from './MaintenanceMode';
+import QuestionnaireSettings from './QuestionnaireSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 
@@ -107,6 +108,13 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.questionnaire_settings')}</h2>
+          <QuestionnaireSettings />
+        </div>
+      </div>
+
       <div className="row">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>

+ 113 - 0
apps/app/src/components/Admin/App/QuestionnaireSettings.tsx

@@ -0,0 +1,113 @@
+import {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxAppSettings } from '~/stores/admin/app-settings';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const QuestionnaireSettings = (): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const { data, error, mutate } = useSWRxAppSettings();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(data?.isQuestionnaireEnabled);
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const [isAppSiteUrlHashed, setIsAppSiteUrlHashed] = useState(data?.isAppSiteUrlHashed);
+  const onChangeisAppSiteUrlHashedHandler = useCallback(() => {
+    setIsAppSiteUrlHashed(prev => !prev);
+  }, []);
+
+  const onSubmitHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/app-settings/questionnaire-settings', {
+        isQuestionnaireEnabled,
+        isAppSiteUrlHashed,
+      });
+      toastSuccess(t('commons:toaster.update_successed', { target: t('app_setting.questionnaire_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    mutate();
+  }, [isAppSiteUrlHashed, isQuestionnaireEnabled, mutate, t]);
+
+  // Sync SWR value and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(data?.isQuestionnaireEnabled);
+    setIsAppSiteUrlHashed(data?.isAppSiteUrlHashed);
+  }, [data, data?.isAppSiteUrlHashed, data?.isQuestionnaireEnabled]);
+
+  const isLoading = data === undefined && error === undefined;
+
+  return (
+    <div id="questionnaire-settings" className="mb-5">
+      <p className="card well">
+        <div className="mb-4">{t('app_setting.questionnaire_settings_explanation')}</div>
+        <span>
+          <div className="mb-2">
+            <span className="text-info mr-2"><i className="icon-info icon-fw"></i>{t('app_setting.about_data_sent')}</span>
+            <a href={t('app_setting.docs_link')} rel="noreferrer" target="_blank" className="d-inline">
+              {t('app_setting.learn_more')} <i className="icon-share-alt"></i>
+            </a>
+          </div>
+          {t('app_setting.other_info_will_be_sent')}<br />
+          {t('app_setting.we_will_use_the_data_to_improve_growi')}
+        </span>
+      </p>
+
+      {isLoading && <div className="text-muted text-center mb-5">
+        <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+      </div>}
+
+      {!isLoading && (
+        <>
+          <div className="row my-3">
+            <div className="custom-control custom-switch custom-checkbox-info col-md-5 offset-md-5">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isQuestionnaireEnabled"
+                checked={isQuestionnaireEnabled}
+                onChange={onChangeIsQuestionnaireEnabledHandler}
+              />
+              <label className="custom-control-label" htmlFor="isQuestionnaireEnabled">
+                {t('app_setting.enable_questionnaire')}
+              </label>
+            </div>
+          </div>
+
+          <div className="row my-4">
+            <div className="custom-control custom-checkbox custom-checkbox-info col-md-5 offset-md-5">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isAppSiteUrlHashed"
+                checked={isAppSiteUrlHashed}
+                onChange={onChangeisAppSiteUrlHashedHandler}
+                disabled={!isQuestionnaireEnabled}
+              />
+              <label className="custom-control-label" htmlFor="isAppSiteUrlHashed">
+                {t('app_setting.anonymize_app_site_url')}
+              </label>
+              <p className="form-text text-muted small">
+                {t('app_setting.url_anonymization_explanation')}
+              </p>
+            </div>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onSubmitHandler}/>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default QuestionnaireSettings;

+ 2 - 3
apps/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -4,8 +4,7 @@ import { useTranslation } from 'next-i18next';
 
 type Props = {
   onClick: () => void,
-  disabled: boolean,
-
+  disabled?: boolean,
 }
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
@@ -14,7 +13,7 @@ const AdminUpdateButtonRow = (props: Props): JSX.Element => {
   return (
     <div className="row my-3">
       <div className="mx-auto">
-        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>{ t('Update') }</button>
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled ?? false}>{ t('Update') }</button>
       </div>
     </div>
   );

+ 102 - 0
apps/app/src/components/Me/OtherSettings.tsx

@@ -0,0 +1,102 @@
+import {
+  useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
+import { useCurrentUser } from '~/stores/context';
+
+const OtherSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
+  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
+
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/personal-setting/questionnaire-settings', {
+        isQuestionnaireEnabled,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isQuestionnaireEnabled, t]);
+
+  // Sync currentUser and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
+  }, [currentUser?.isQuestionnaireEnabled]);
+
+  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
+
+  return (
+    <>
+      <h2 className="border-bottom my-4">{t('questionnaire.settings')}</h2>
+
+      {isLoadingCurrentUser && <div className="text-muted text-center mb-5">
+        <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+      </div>}
+
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          {!isLoadingCurrentUser && (
+            <div className="custom-control custom-switch custom-checkbox-primary">
+              <span id="grw-questionnaire-settings-toggle-wrapper">
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id="isQuestionnaireEnabled"
+                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                  disabled={!growiIsQuestionnaireEnabled}
+                />
+                <label className="custom-control-label" htmlFor="isQuestionnaireEnabled">
+                  {t('questionnaire.enable_questionnaire')}
+                </label>
+              </span>
+              <p className="form-text text-muted small">
+                {t('questionnaire.personal_settings_explanation')}
+              </p>
+              {!growiIsQuestionnaireEnabled && <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
+                {t('questionnaire.disabled_by_admin')}
+              </UncontrolledTooltip> }
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
+            <button
+              data-testid="grw-questionnaire-settings-update-btn"
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
+              disabled={!growiIsQuestionnaireEnabled}
+              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
+            >
+              {t('Update')}
+            </button>
+          </span>
+          {!growiIsQuestionnaireEnabled && <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
+            {t('questionnaire.disabled_by_admin')}
+          </UncontrolledTooltip>}
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default OtherSettings;

+ 16 - 2
apps/app/src/components/Me/PersonalSettings.jsx

@@ -9,6 +9,7 @@ import ApiSettings from './ApiSettings';
 // import { EditorSettings } from './EditorSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
+import OtherSettings from './OtherSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 
@@ -48,14 +49,27 @@ const PersonalSettings = () => {
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
+      other_settings: {
+        Icon: () => <i className="icon-fw icon-settings"></i>,
+        Content: OtherSettings,
+        i18n: t('Other Settings'),
+      },
     };
   }, [t]);
 
-  const onPasswordSettings = window.location.hash === '#password';
+  const getDefaultTabIndex = () => {
+    // e.g) '/me#password_settings' sets password settings tab as default
+    const tab = window.location.hash?.substring(1);
+    let defaultTabIndex;
+    Object.keys(navTabMapping).forEach((key, i) => {
+      if (key === tab) { defaultTabIndex = i }
+    });
+    return defaultTabIndex;
+  };
 
   return (
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={getDefaultTabIndex()} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
   );
 

+ 32 - 9
apps/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,7 +1,8 @@
-import React, { useRef } from 'react';
+import { useRef, useState } from 'react';
 
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 
@@ -9,15 +10,23 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/stores/context';
 
+const ProactiveQuestionnaireModal = dynamic(() => import('~/features/questionnaire/client/components/ProactiveQuestionnaireModal'), { ssr: false });
+
 const PersonalDropdown = () => {
   const { t } = useTranslation('commons');
   const { data: currentUser } = useCurrentUser();
 
+  const [isQuestionnaireModalOpen, setQuestionnaireModalOpen] = useState(false);
+
   // ripple
   const buttonRef = useRef(null);
   useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  const user = currentUser || {};
+  if (currentUser == null) {
+    return <div className="text-muted text-center mb-5">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+    </div>;
+  }
 
   const logoutHandler = async() => {
     try {
@@ -35,27 +44,27 @@ const PersonalDropdown = () => {
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
       <button className="bg-transparent border-0 nav-link" type="button" ref={buttonRef} data-toggle="dropdown" data-testid="personal-dropdown-button">
-        <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
+        <UserPicture user={currentUser} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{currentUser.name}</span>
       </button>
 
       {/* Menu */}
       <div className="dropdown-menu dropdown-menu-right" data-testid="personal-dropdown-menu">
 
         <div className="px-4 pt-3 pb-2 text-center">
-          <UserPicture user={user} size="lg" noLink noTooltip />
+          <UserPicture user={currentUser} size="lg" noLink noTooltip />
 
           <h5 className="mt-2">
-            {user.name}
+            {currentUser.name}
           </h5>
 
           <div className="my-2">
-            <i className="icon-user icon-fw"></i>{user.username}<br />
-            <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{user.email}</span>
+            <i className="icon-user icon-fw"></i>{currentUser.username}<br />
+            <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{currentUser.email}</span>
           </div>
 
           <div className="btn-group btn-block mt-2" role="group">
             <Link
-              href={`/user/${user.username}`}
+              href={`/user/${currentUser.username}`}
               className="btn btn-sm btn-outline-secondary col"
               data-testid="grw-personal-dropdown-menu-user-home"
             >
@@ -73,9 +82,23 @@ const PersonalDropdown = () => {
 
         <div className="dropdown-divider"></div>
 
-        <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{t('Sign out')}</button>
+        <button
+          data-testid="grw-proactive-questionnaire-modal-toggle-btn"
+          type="button"
+          className="dropdown-item"
+          onClick={() => setQuestionnaireModalOpen(true)}>
+          <i className="icon-fw icon-pencil"></i>{t('personal_dropdown.feedback')}
+        </button>
+
+        <div className="dropdown-divider"></div>
+
+        <button type="button" className="dropdown-item" onClick={logoutHandler}>
+          <i className="icon-fw icon-power"></i>{t('Sign out')}
+        </button>
       </div>
 
+      <ProactiveQuestionnaireModal isOpen={isQuestionnaireModalOpen} onClose={() => setQuestionnaireModalOpen(false)} />
+
     </>
   );
 

+ 3 - 3
apps/app/src/components/PageAttachment.tsx

@@ -2,7 +2,7 @@ import React, {
   useCallback, useMemo, useState,
 } from 'react';
 
-import { HasObjectId, IAttachment } from '@growi/core';
+import { IAttachmentHasId } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser } from '~/stores/context';
@@ -28,7 +28,7 @@ const PageAttachment = (): JSX.Element => {
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachment & HasObjectId) | null>(null);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachmentHasId) | null>(null);
   const [deleting, setDeleting] = useState(false);
   const [deleteError, setDeleteError] = useState('');
 
@@ -58,7 +58,7 @@ const PageAttachment = (): JSX.Element => {
     setAttachmentToDelete(attachment);
   }, []);
 
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachment & HasObjectId) => {
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachmentHasId) => {
     setDeleting(true);
 
     try {

+ 3 - 3
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,7 +1,7 @@
 /* eslint-disable react/prop-types */
 import React, { useCallback } from 'react';
 
-import { HasObjectId, IAttachment } from '@growi/core';
+import { IAttachmentHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import {
   Button,
@@ -25,10 +25,10 @@ function iconNameByFormat(format: string): string {
 type Props = {
   isOpen: boolean,
   toggle: () => void,
-  attachmentToDelete: IAttachment & HasObjectId | null,
+  attachmentToDelete: IAttachmentHasId | null,
   deleting: boolean,
   deleteError: string,
-  onAttachmentDeleteClickedConfirm?: (attachment: IAttachment & HasObjectId) => Promise<void>,
+  onAttachmentDeleteClickedConfirm?: (attachment: IAttachmentHasId) => Promise<void>,
 }
 
 export const DeleteAttachmentModal = (props: Props): JSX.Element => {

+ 3 - 3
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -1,13 +1,13 @@
 import React from 'react';
 
-import { HasObjectId, IAttachment } from '@growi/core';
+import { IAttachmentHasId } from '@growi/core';
 import { Attachment } from '@growi/ui/dist/components/Attachment';
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  attachments: (IAttachment & HasObjectId)[],
+  attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
 }
 

+ 152 - 0
apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx

@@ -0,0 +1,152 @@
+import { useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody,
+} from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+type ModalProps = {
+  isOpen: boolean,
+  onClose: () => void,
+};
+
+const QuestionnaireCompletionModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation('commons');
+
+  const { isOpen, onClose } = props;
+
+  return (
+    <Modal
+      size="lg"
+      isOpen={isOpen}
+      toggle={onClose}
+      centered
+    >
+      <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+        <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+          <div className="text-center">
+            <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
+            <p className="mb-1">{t('questionnaire_modal.successfully_submitted')}</p>
+            <p>{t('questionnaire_modal.thanks_for_answering')}</p>
+          </div>
+          <div className="text-center my-3">
+            <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={onClose}>{t('Close')}</span>
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+const ProactiveQuestionnaireModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation('commons');
+
+  const { isOpen, onClose } = props;
+
+  const [isQuestionnaireCompletionModal, setQuestionnaireCompletionModal] = useState(false);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      satisfaction: { value: satisfaction },
+      lengthOfExperience: { value: lengthOfExperience },
+      occupation: { value: occupation },
+      position: { value: position },
+      commentText: { value: commentText },
+    } = formData;
+
+    const sendValues = {
+      satisfaction: Number(satisfaction),
+      lengthOfExperience,
+      occupation,
+      position,
+      commentText,
+    };
+
+    apiv3Post('/questionnaire/proactive/answer', sendValues);
+
+    onClose();
+    setQuestionnaireCompletionModal(true);
+  }, [onClose]);
+
+  return (
+    <>
+      <Modal
+        data-testid="grw-proactive-questionnaire-modal"
+        size="lg"
+        isOpen={isOpen}
+        toggle={onClose}
+        centered
+      >
+        <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+          <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+            <div className="text-center">
+              <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
+              <p className="mb-1">{t('questionnaire_modal.more_satisfied_services')}</p>
+              <p>{t('questionnaire_modal.strive_to_improve_services')}</p>
+            </div>
+            <form className="px-5" onSubmit={submitHandler}>
+              <div className="form-group row mt-5">
+                <label className="col-sm-5 col-form-label" htmlFor="satisfaction">
+                  <span className="badge badge-primary mr-2">{t('questionnaire_modal.required')}</span>{t('questionnaire_modal.satisfaction_with_growi')}
+                </label>
+                <select className="col-sm-7 form-control" name="satisfaction" id="satisfaction" required>
+                  <option value="">▼ {t('Select')}</option>
+                  <option>1</option>
+                  <option>2</option>
+                  <option>3</option>
+                  <option>4</option>
+                  <option>5</option>
+                </select>
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="lengthOfExperience">{t('questionnaire_modal.history_of_growi_usage')}</label>
+                <select
+                  name="lengthOfExperience"
+                  id="lengthOfExperience"
+                  className="col-sm-7 form-control"
+                >
+                  <option value="">▼ {t('Select')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.more_than_two_years')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.one_to_two_years')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.six_months_to_one_year')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.three_months_to_six_months')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.one_month_to_three_months')}</option>
+                  <option>{t('questionnaire_modal.length_of_experience.less_than_one_month')}</option>
+                </select>
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="occupation">{t('questionnaire_modal.occupation')}</label>
+                <input className="col-sm-7 form-control" type="text" name="occupation" id="occupation" />
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="position">{t('questionnaire_modal.position')}</label>
+                <input className="col-sm-7 form-control" type="text" name="position" id="position" />
+              </div>
+              <div className="form-group row mt-3">
+                <label className="col-sm-5 col-form-label" htmlFor="commentText">
+                  <span className="badge badge-primary mr-2">{t('questionnaire_modal.required')}</span>{t('questionnaire_modal.comment_on_growi')}
+                </label>
+                <textarea className="col-sm-7 form-control" name="commentText" id="commentText" rows={5} required/>
+              </div>
+              <div className="text-center mt-5">
+                <button type="submit" className="btn btn-primary">{t('questionnaire_modal.submit')}</button>
+              </div>
+              <div className="text-center my-3">
+                <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={onClose}>{t('questionnaire_modal.close')}</span>
+              </div>
+            </form>
+          </div>
+        </ModalBody>
+      </Modal>
+      <QuestionnaireCompletionModal isOpen={isQuestionnaireCompletionModal} onClose={() => setQuestionnaireCompletionModal(false)} />
+    </>
+  );
+};
+
+export default ProactiveQuestionnaireModal;

+ 47 - 0
apps/app/src/features/questionnaire/client/components/Question.tsx

@@ -0,0 +1,47 @@
+import { useCurrentUser } from '~/stores/context';
+
+import { IQuestionHasId } from '../../interfaces/question';
+
+type QuestionProps = {
+  question: IQuestionHasId,
+  inputNamePrefix: string,
+}
+
+const Question = ({ question, inputNamePrefix }: QuestionProps): JSX.Element => {
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const questionText = lang === 'en_US' ? question.text.en_US : question.text.ja_JP;
+
+  return <div className="row mt-4">
+    <div className="col-6 d-flex align-items-center">
+      <span>
+        {questionText}
+      </span>
+    </div>
+    <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 mr-4 rounded">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-noAnswer`} value='0' defaultChecked/> -
+        </label>
+        <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={`${inputNamePrefix + question._id}`} id={`${question._id}-option2`} value='2'/> 2
+        </label>
+        <label className="btn btn-outline-primary">
+          <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={`${inputNamePrefix + question._id}`} id={`${question._id}-option4`} value='4'/> 4
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`${inputNamePrefix + question._id}`} id={`${question._id}-option5`} value='5'/> 5
+        </label>
+      </div>
+    </div>
+  </div>;
+};
+
+export default Question;

+ 165 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModal.tsx

@@ -0,0 +1,165 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalBody } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useQuestionnaireModal } from '~/features/questionnaire/client/stores/model';
+import { IAnswer } from '~/features/questionnaire/interfaces/answer';
+import { StatusType } from '~/features/questionnaire/interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '~/features/questionnaire/interfaces/questionnaire-order';
+import { useCurrentUser } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
+
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+
+import Question from './Question';
+
+const logger = loggerFactory('growi:QuestionnaireModal');
+
+type QuestionnaireModalProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId
+}
+
+const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JSX.Element => {
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const { data: questionnaireModalData, close: closeQuestionnaireModal } = useQuestionnaireModal();
+  const isOpened = questionnaireModalData?.openedQuestionnaireId === questionnaireOrder._id;
+
+  const inputNamePrefix = 'question-';
+
+  const { t } = useTranslation(['translation', 'commons']);
+
+  const sendAnswer = useCallback(async(answers: IAnswer[]) => {
+    try {
+      await apiv3Put('/questionnaire/answer', {
+        questionnaireOrderId: questionnaireOrder._id,
+        answers,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.answered);
+      }
+      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, currentUser]);
+
+  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);
+
+    const shouldCloseToast = true;
+    closeQuestionnaireModal(shouldCloseToast);
+  }, [closeQuestionnaireModal, questionnaireOrder.questions, sendAnswer]);
+
+  const denyBtnClickHandler = useCallback(async() => {
+    try {
+      apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.denied);
+      }
+      toastSuccess(t('questionnaire.denied'));
+    }
+    catch (e) {
+      logger.error(e);
+    }
+    const shouldCloseToast = true;
+    closeQuestionnaireModal(shouldCloseToast);
+  }, [closeQuestionnaireModal, questionnaireOrder._id, t, currentUser]);
+
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async(shouldCloseToast: boolean) => {
+    closeQuestionnaireModal(shouldCloseToast);
+
+    try {
+      await apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.skipped);
+      }
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [closeQuestionnaireModal, questionnaireOrder._id, currentUser]);
+
+  const closeBtnClickHandlerClosingToast = useCallback(async() => {
+    closeBtnClickHandler(true);
+  }, [closeBtnClickHandler]);
+
+  const questionnaireOrderTitle = lang === 'en_US' ? questionnaireOrder.title.en_US : questionnaireOrder.title.ja_JP;
+
+  return (<Modal
+    size="lg"
+    isOpen={isOpened}
+    toggle={closeBtnClickHandlerClosingToast}
+    centered
+  >
+    <form onSubmit={submitHandler}>
+      <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
+        <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+          <div className="text-center mb-2">
+            <h2 className="my-4">{questionnaireOrderTitle}</h2>
+            <p className="mb-1">{t('commons:questionnaire_modal.more_satisfied_services')}</p>
+            <p>{t('commons:questionnaire_modal.strive_to_improve_services')}</p>
+          </div>
+          <div className="container">
+            <div className="row mt-4">
+              <div className="col-md-2 offset-md-5 font-weight-bold text-right align-items-center p-0">{t('questionnaire.no_answer')}</div>
+              <div className="col-md-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>
+              </div>
+            </div>
+            {questionnaireOrder.questions?.map((question) => {
+              return <Question question={question} inputNamePrefix={inputNamePrefix} key={question._id}/>;
+            })}
+          </div>
+          <div className="text-center mt-5">
+            <button type="submit" className="btn btn-primary">{t('questionnaire.answer')}</button>
+          </div>
+          <div className="text-center cursor-pointer text-decoration-underline my-3">
+            <span style={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={denyBtnClickHandler}>{t('questionnaire.dont_show_again')}</span>
+          </div>
+
+          {currentUser?.admin && (
+            <a href="/admin/app#questionnaire-settings">
+              <i className="material-icons mr-1" >admin_panel_settings</i>
+            </a>
+          )}
+          {currentUser != null && (
+            <a href="/me#other_settings">
+              <i className="material-icons" >settings</i>
+            </a>
+          )}
+        </div>
+      </ModalBody>
+    </form>
+  </Modal>);
+};
+
+export default QuestionnaireModal;

+ 9 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.module.scss

@@ -0,0 +1,9 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-questionnaire-toasts :global {
+  position: fixed;
+  right: 20px;
+  bottom: 52px;
+  z-index: bs.$zindex-fixed + 1;
+  width: 230px;
+}

+ 46 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireModalManager.tsx

@@ -0,0 +1,46 @@
+import { useCallback } from 'react';
+
+import { useCurrentUser } from '~/stores/context';
+
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+import { useSWRxQuestionnaireOrders } from '../stores/questionnaire';
+
+import QuestionnaireModal from './QuestionnaireModal';
+import QuestionnaireToast from './QuestionnaireToast';
+
+import styles from './QuestionnaireModalManager.module.scss';
+
+const QuestionnaireModalManager = ():JSX.Element => {
+  const { data: questionnaireOrders } = useSWRxQuestionnaireOrders();
+  const { data: currentUser } = useCurrentUser();
+
+  const questionnaireOrdersToShow = useCallback((questionnaireOrders: IQuestionnaireOrderHasId[] | undefined) => {
+    const guestQuestionnaireAnswerStorage = GuestQuestionnaireAnswerStatusService.getStorage();
+    if (currentUser != null || guestQuestionnaireAnswerStorage == null) {
+      return questionnaireOrders;
+    }
+
+    return questionnaireOrders?.filter((questionnaireOrder) => {
+      const localAnswerStatus = guestQuestionnaireAnswerStorage[questionnaireOrder._id];
+      return localAnswerStatus == null || localAnswerStatus.status === StatusType.not_answered;
+    });
+  }, [currentUser]);
+
+  return <>
+    {questionnaireOrders?.map((questionnaireOrder) => {
+      return <QuestionnaireModal
+        questionnaireOrder={questionnaireOrder}
+        key={questionnaireOrder._id} />;
+    })}
+    <div className={styles['grw-questionnaire-toasts']}>
+      {questionnaireOrdersToShow(questionnaireOrders)?.map((questionnaireOrder) => {
+        return <QuestionnaireToast questionnaireOrder={questionnaireOrder} key={questionnaireOrder._id}/>;
+      })}
+    </div>
+  </>;
+};
+
+export default QuestionnaireModalManager;

+ 85 - 0
apps/app/src/features/questionnaire/client/components/QuestionnaireToast.tsx

@@ -0,0 +1,85 @@
+import { useCallback, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess } from '~/client/util/toastr';
+import { useQuestionnaireModal } from '~/features/questionnaire/client/stores/model';
+import { useCurrentUser } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+import { GuestQuestionnaireAnswerStatusService } from '../services/guest-questionnaire-answer-status';
+
+const logger = loggerFactory('growi:QuestionnaireToast');
+
+type QuestionnaireToastProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId,
+}
+
+const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JSX.Element => {
+  const { open: openQuestionnaireModal } = useQuestionnaireModal();
+  const { data: currentUser } = useCurrentUser();
+  const lang = currentUser?.lang;
+
+  const [isOpen, setIsOpen] = useState(true);
+
+  const { t } = useTranslation();
+
+  const answerBtnClickHandler = useCallback(() => {
+    openQuestionnaireModal(questionnaireOrder._id, () => setIsOpen(false));
+  }, [openQuestionnaireModal, questionnaireOrder._id]);
+
+  const denyBtnClickHandler = useCallback(async() => {
+    // Immediately close
+    setIsOpen(false);
+
+    try {
+      await apiv3Put('/questionnaire/deny', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.denied);
+      }
+      toastSuccess(t('questionnaire.denied'));
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [questionnaireOrder._id, t, currentUser]);
+
+  // No showing toasts since not important
+  const closeBtnClickHandler = useCallback(async() => {
+    setIsOpen(false);
+
+    try {
+      await apiv3Put('/questionnaire/skip', {
+        questionnaireOrderId: questionnaireOrder._id,
+      });
+      if (currentUser == null) {
+        GuestQuestionnaireAnswerStatusService.setStatus(questionnaireOrder._id, StatusType.skipped);
+      }
+    }
+    catch (e) {
+      logger.error(e);
+    }
+  }, [questionnaireOrder._id, currentUser]);
+
+  const questionnaireOrderShortTitle = lang === 'en_US' ? questionnaireOrder.shortTitle.en_US : questionnaireOrder.shortTitle.ja_JP;
+
+  return <div className={`toast ${isOpen ? 'show' : 'hide'}`}>
+    <div className="toast-header bg-primary">
+      <strong className="mr-auto text-light">{questionnaireOrderShortTitle}</strong>
+      <button type="button" className="ml-2 mb-1 close" onClick={closeBtnClickHandler}>
+        <span aria-hidden="true" className="text-light">&times;</span>
+      </button>
+    </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={denyBtnClickHandler}>{t('questionnaire.deny')}</button>
+    </div>
+  </div>;
+};
+
+export default QuestionnaireToast;

+ 79 - 0
apps/app/src/features/questionnaire/client/services/guest-questionnaire-answer-status.ts

@@ -0,0 +1,79 @@
+// A service to manage questionnaire answer statuses for guest user.
+// Saves statuses in localStorage.
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+
+interface GuestQuestionnaireAnswerStatus {
+  status: StatusType
+  updatedDate: string
+}
+
+interface GuestQuestionnaireAnswerStatusStorage {
+  [key: string]: GuestQuestionnaireAnswerStatus
+}
+
+const storageKey = 'guestQuestionnaireAnswerStatuses';
+const DAYS_UNTIL_EXPIRATION = 30;
+
+/**
+ * Get all answer statuses stored in localStorage as GuestQuestionnaireAnswerStatusStorage,
+ * and update outdated information.
+ */
+const getStorage = (): GuestQuestionnaireAnswerStatusStorage | null => {
+  if (typeof window === 'undefined') { return null }
+
+  const currentStorage = localStorage.getItem(storageKey);
+
+  if (currentStorage == null) { return null }
+
+  const storageJson: GuestQuestionnaireAnswerStatusStorage = JSON.parse(currentStorage);
+  // delete status if outdated to prevent localStorage overflow
+  // change skipped to not_answered if different date than when skipped
+  Object.keys(storageJson).forEach((key) => {
+    const answerStatus = storageJson[key];
+    const updatedDate = new Date(answerStatus.updatedDate);
+    const expirationDate = new Date(updatedDate.setDate(updatedDate.getDate() + DAYS_UNTIL_EXPIRATION));
+    if (expirationDate < new Date()) {
+      delete storageJson[key];
+    }
+    else if (answerStatus.status === StatusType.skipped
+          && new Date().toDateString() !== answerStatus.updatedDate) {
+      storageJson[key] = {
+        status: StatusType.not_answered,
+        updatedDate: new Date().toDateString(),
+      };
+    }
+  });
+
+  return storageJson;
+};
+
+/**
+ * Set answer status for questionnaire order in GuestQuestionnaireAnswerStatusStorage,
+ * and save it in localStorage.
+ */
+const setStatus = (questionnaireOrderId: string, status: StatusType): void => {
+  if (typeof window === 'undefined') { return }
+
+  const guestQuestionnaireAnswerStatus: GuestQuestionnaireAnswerStatus = {
+    status,
+    updatedDate: new Date().toDateString(),
+  };
+
+  const storage = getStorage();
+
+  if (storage != null) {
+    storage[questionnaireOrderId] = guestQuestionnaireAnswerStatus;
+    localStorage.setItem(storageKey, JSON.stringify(storage));
+    return;
+  }
+
+  const initialStorage: GuestQuestionnaireAnswerStatusStorage = { [questionnaireOrderId]: guestQuestionnaireAnswerStatus };
+  localStorage.setItem(storageKey, JSON.stringify(initialStorage));
+
+};
+
+export const GuestQuestionnaireAnswerStatusService = {
+  setStatus,
+  getStorage,
+};

+ 40 - 0
apps/app/src/features/questionnaire/client/stores/model.tsx

@@ -0,0 +1,40 @@
+import { SWRResponse } from 'swr';
+
+import { useStaticSWR } from '~/stores/use-static-swr';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:stores:modal');
+
+/*
+* QuestionnaireModals
+*/
+type QuestionnaireModalStatuses = {
+  openedQuestionnaireId: string | null,
+  closeToast?: () => void | Promise<void>,
+}
+
+type QuestionnaireModalStatusUtils = {
+  open(string: string, closeToast: () => void | Promise<void>): Promise<QuestionnaireModalStatuses | undefined>
+  close(shouldCloseToast?: boolean): Promise<QuestionnaireModalStatuses | undefined>
+}
+
+export const useQuestionnaireModal = (status?: QuestionnaireModalStatuses): SWRResponse<QuestionnaireModalStatuses, Error> & QuestionnaireModalStatusUtils => {
+  const initialData: QuestionnaireModalStatuses = { openedQuestionnaireId: null };
+  const swrResponse = useStaticSWR<QuestionnaireModalStatuses, Error>('questionnaireModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (questionnaireOrderId: string, closeToast: () => void | Promise<void>) => swrResponse.mutate({
+      openedQuestionnaireId: questionnaireOrderId,
+      closeToast,
+    }),
+    close: (shouldCloseToast?: boolean) => {
+      if (shouldCloseToast) {
+        swrResponse.data?.closeToast?.();
+        if (swrResponse.data?.closeToast === undefined) logger.debug('Tried to run `swrResponse.data?.closeToast` but it was `undefined`');
+      }
+
+      return swrResponse.mutate({ openedQuestionnaireId: null });
+    },
+  };
+};

+ 23 - 0
apps/app/src/features/questionnaire/client/stores/questionnaire.tsx

@@ -0,0 +1,23 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IQuestionnaireOrderHasId } from '../../interfaces/questionnaire-order';
+
+export const useSWRxQuestionnaireOrders = (): SWRResponse<IQuestionnaireOrderHasId[], Error> => {
+  return useSWR(
+    '/questionnaire/orders',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.questionnaireOrders;
+    }),
+  );
+};
+
+export const useSWRxIsQuestionnaireEnabled = (): SWRResponse<boolean, Error> => {
+  return useSWR(
+    '/questionnaire/is-enabled',
+    endpoint => apiv3Get(endpoint)
+      .then(response => !!response.data.isEnabled)
+      .catch(() => false),
+  );
+};

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

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

+ 25 - 0
apps/app/src/features/questionnaire/interfaces/condition.ts

@@ -0,0 +1,25 @@
+import { HasObjectId } from '@growi/core';
+
+import { GrowiServiceType } from './growi-info';
+import { UserType } from './user-info';
+
+
+interface UserCondition {
+  types: UserType[] // user types to show questionnaire
+  daysSinceCreation?: {
+    moreThanOrEqualTo?: number
+    lessThanOrEqualTo?: number
+  }
+}
+
+interface GrowiCondition {
+  types: GrowiServiceType[] // GROWI types to show questionnaire in
+  versionRegExps: string[] // GROWI versions to show questionnaire in
+}
+
+export interface ICondition {
+  user: UserCondition
+  growi: GrowiCondition
+}
+
+export type IConditionHasId = ICondition & HasObjectId;

+ 59 - 0
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -0,0 +1,59 @@
+import * as os from 'node:os';
+
+export const GrowiServiceType = {
+  cloud: 'cloud',
+  privateCloud: 'private-cloud',
+  onPremise: 'on-premise',
+  others: 'others',
+} as const;
+export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
+export const GrowiAttachmentType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  gcp: 'gcp',
+  gridfs: 'gridfs',
+  mongo: 'mongo',
+  mongodb: 'mongodb',
+  local: 'local',
+  none: 'none',
+} as const;
+export const GrowiDeploymentType = {
+  officialHelmChart: 'official-helm-chart',
+  growiDockerCompose: 'growi-docker-compose',
+  node: 'node',
+  others: 'others',
+} as const;
+export const GrowiExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oicd: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
+type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
+export type GrowiAttachmentType = typeof GrowiAttachmentType[keyof typeof GrowiAttachmentType]
+export type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
+export type GrowiExternalAuthProviderType = typeof GrowiExternalAuthProviderType[keyof typeof GrowiExternalAuthProviderType]
+
+interface IGrowiOSInfo {
+  type?: ReturnType<typeof os.type>
+  platform?: ReturnType<typeof os.platform>
+  arch?: ReturnType<typeof os.arch>
+  totalmem?: ReturnType<typeof os.totalmem>
+}
+
+export interface IGrowiInfo {
+  version: string
+  appSiteUrl?: string
+  appSiteUrlHashed: string
+  type: GrowiServiceType
+  currentUsersCount: number
+  currentActiveUsersCount: number
+  wikiType: GrowiWikiType
+  attachmentType: GrowiAttachmentType
+  activeExternalAccountTypes?: GrowiExternalAuthProviderType[]
+  osInfo?: IGrowiOSInfo
+  deploymentType?: GrowiDeploymentType
+}

+ 14 - 0
apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

@@ -0,0 +1,14 @@
+import { IGrowiInfo } from './growi-info';
+import { IUserInfo } from './user-info';
+
+
+export interface IProactiveQuestionnaireAnswer {
+  satisfaction: number,
+  commentText: string,
+  growiInfo: IGrowiInfo,
+  userInfo: IUserInfo,
+  answeredAt: Date,
+  lengthOfExperience?: string,
+  position?: string,
+  occupation?: string,
+}

+ 15 - 0
apps/app/src/features/questionnaire/interfaces/question.ts

@@ -0,0 +1,15 @@
+import { HasObjectId } from '@growi/core';
+
+export const QuestionType = { points: 'points', text: 'text' } as const;
+
+type QuestionType = typeof QuestionType[keyof typeof QuestionType];
+
+export interface IQuestion {
+  type: QuestionType
+  text: {
+    ja_JP: string
+    en_US: string
+  }
+}
+
+export type IQuestionHasId = IQuestion & HasObjectId;

+ 16 - 0
apps/app/src/features/questionnaire/interfaces/questionnaire-answer-status.ts

@@ -0,0 +1,16 @@
+// 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 = {
+  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
+}

+ 11 - 0
apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts

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

+ 21 - 0
apps/app/src/features/questionnaire/interfaces/questionnaire-order.ts

@@ -0,0 +1,21 @@
+import { HasObjectId } from '@growi/core';
+
+import { ICondition, IConditionHasId } from './condition';
+import { IQuestion, IQuestionHasId } from './question';
+
+export interface IQuestionnaireOrder<TQUESTION = IQuestion, TCONDITION = ICondition> {
+  shortTitle: {
+    ja_JP: string
+    en_US: string
+  }
+  title: {
+    ja_JP: string
+    en_US: string
+  }
+  showFrom: Date
+  showUntil: Date
+  questions: TQUESTION[]
+  condition: TCONDITION
+}
+
+export type IQuestionnaireOrderHasId = IQuestionnaireOrder<IQuestionHasId, IConditionHasId> & HasObjectId;

+ 12 - 0
apps/app/src/features/questionnaire/interfaces/user-info.ts

@@ -0,0 +1,12 @@
+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 type IUserInfo = {
+  userIdHash: string // userId hash generated by using appSiteUrl as salt
+  type: Exclude<UserType, guestType>
+  userCreatedAt: Date // createdAt of user that answered the questionnaire
+} | {
+  type: guestType
+}

+ 27 - 0
apps/app/src/features/questionnaire/server/models/proactive-questionnaire-answer.ts

@@ -0,0 +1,27 @@
+import { Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IProactiveQuestionnaireAnswer } from '../../interfaces/proactive-questionnaire-answer';
+
+import { growiInfoSchema } from './schema/growi-info';
+import { userInfoSchema } from './schema/user-info';
+
+interface ProactiveQuestionnaireAnswerDocument extends IProactiveQuestionnaireAnswer, Document {}
+
+type ProactiveQuestionnaireAnswerModel = Model<ProactiveQuestionnaireAnswerDocument>
+
+export const proactiveQuestionnaireAnswerSchema = new Schema<ProactiveQuestionnaireAnswerDocument>({
+  satisfaction: { type: Number, required: true },
+  lengthOfExperience: { type: String },
+  position: { type: String },
+  occupation: { type: String },
+  commentText: { type: String, required: true },
+  growiInfo: { type: growiInfoSchema, required: true },
+  userInfo: { type: userInfoSchema, required: true },
+  answeredAt: { type: Date },
+}, { timestamps: true });
+
+export default getOrCreateModel<ProactiveQuestionnaireAnswerDocument, ProactiveQuestionnaireAnswerModel>(
+  'ProactiveQuestionnaireAnswer', proactiveQuestionnaireAnswerSchema,
+);

+ 17 - 0
apps/app/src/features/questionnaire/server/models/questionnaire-answer-status.ts

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

+ 24 - 0
apps/app/src/features/questionnaire/server/models/questionnaire-answer.ts

@@ -0,0 +1,24 @@
+import { Document, Model, Schema } from 'mongoose';
+
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IQuestionnaireAnswer } from '../../interfaces/questionnaire-answer';
+
+import { answerSchema } from './schema/answer';
+import { growiInfoSchema } from './schema/growi-info';
+import { userInfoSchema } from './schema/user-info';
+
+interface QuestionnaireAnswerDocument extends IQuestionnaireAnswer<ObjectIdLike>, Document {}
+
+type QuestionnaireAnswerModel = Model<QuestionnaireAnswerDocument>
+
+const questionnaireAnswerSchema = new Schema<QuestionnaireAnswerDocument>({
+  answers: [answerSchema],
+  answeredAt: { type: Date, required: true },
+  growiInfo: { type: growiInfoSchema, required: true },
+  userInfo: { type: userInfoSchema, required: true },
+  questionnaireOrder: { type: Schema.Types.ObjectId, ref: 'QuestionnaireOrder' },
+}, { timestamps: true });
+
+export default getOrCreateModel<QuestionnaireAnswerDocument, QuestionnaireAnswerModel>('QuestionnaireAnswer', questionnaireAnswerSchema);

+ 34 - 0
apps/app/src/features/questionnaire/server/models/questionnaire-order.ts

@@ -0,0 +1,34 @@
+import { Model, Schema, Document } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+
+import conditionSchema from './schema/condition';
+import questionSchema from './schema/question';
+
+export interface QuestionnaireOrderDocument extends IQuestionnaireOrder, Document {}
+
+export type QuestionnaireOrderModel = Model<QuestionnaireOrderDocument>
+
+const questionnaireOrderTitleSchema = new Schema<IQuestionnaireOrder['title']>({
+  ja_JP: { type: String, required: true },
+  en_US: { type: String, required: true },
+}, { _id: false });
+
+const questionnaireOrderSchema = new Schema<QuestionnaireOrderDocument>({
+  shortTitle: { type: questionnaireOrderTitleSchema, required: true },
+  title: { type: questionnaireOrderTitleSchema, required: true },
+  showFrom: { type: Date, required: true },
+  showUntil: {
+    type: Date,
+    required: true,
+    validate: [function(value) {
+      return this.showFrom <= value;
+    }, 'showFrom must be before showUntil'],
+  },
+  questions: [questionSchema],
+  condition: { type: conditionSchema, required: true },
+}, { timestamps: true });
+
+export default getOrCreateModel<QuestionnaireOrderDocument, QuestionnaireOrderModel>('QuestionnaireOrder', questionnaireOrderSchema);

+ 10 - 0
apps/app/src/features/questionnaire/server/models/schema/answer.ts

@@ -0,0 +1,10 @@
+import { Schema } from 'mongoose';
+
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import { IAnswer } from '../../../interfaces/answer';
+
+export const answerSchema = new Schema<IAnswer<ObjectIdLike>>({
+  question: { type: Schema.Types.ObjectId, ref: 'Question', required: true },
+  value: { type: String, required: true },
+});

+ 29 - 0
apps/app/src/features/questionnaire/server/models/schema/condition.ts

@@ -0,0 +1,29 @@
+import { Schema } from 'mongoose';
+
+import { ICondition } from '../../../interfaces/condition';
+import { GrowiServiceType } from '../../../interfaces/growi-info';
+import { UserType } from '../../../interfaces/user-info';
+
+const conditionSchema = new Schema<ICondition>({
+  user: {
+    types: [{ type: String, enum: Object.values(UserType) }],
+    daysSinceCreation: {
+      moreThanOrEqualTo: { type: Number, min: 0 },
+      lessThanOrEqualTo: {
+        type: Number,
+        min: 0,
+        validate: [
+          function(value) {
+            return this.user.daysSinceCreation.moreThanOrEqualTo == null || this.user.daysSinceCreation.moreThanOrEqualTo <= value;
+          }, 'daysSinceCreation.lessThanOrEqualTo must be greater than moreThanOrEqualTo',
+        ],
+      },
+    },
+  },
+  growi: {
+    types: [{ type: String, enum: Object.values(GrowiServiceType) }],
+    versionRegExps: [String],
+  },
+});
+
+export default conditionSchema;

+ 24 - 0
apps/app/src/features/questionnaire/server/models/schema/growi-info.ts

@@ -0,0 +1,24 @@
+import { Schema } from 'mongoose';
+
+import {
+  GrowiAttachmentType, GrowiDeploymentType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiWikiType, IGrowiInfo,
+} from '../../../interfaces/growi-info';
+
+export const growiInfoSchema = new Schema<IGrowiInfo>({
+  version: { type: String, required: true },
+  appSiteUrl: { type: String },
+  appSiteUrlHashed: { type: String, required: true },
+  type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
+  currentUsersCount: { type: Number, required: true },
+  currentActiveUsersCount: { type: Number, required: true },
+  wikiType: { type: String, required: true, enum: Object.values(GrowiWikiType) },
+  attachmentType: { type: String, required: true, enum: Object.values(GrowiAttachmentType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(GrowiExternalAuthProviderType) }],
+  osInfo: {
+    type: { type: String },
+    platform: String,
+    arch: String,
+    totalmem: Number,
+  },
+  deploymentType: { type: String, enum: (<(string | null)[]>Object.values(GrowiDeploymentType)).concat([null]) },
+});

+ 15 - 0
apps/app/src/features/questionnaire/server/models/schema/question.ts

@@ -0,0 +1,15 @@
+import { Schema } from 'mongoose';
+
+import { IQuestion, QuestionType } from '../../../interfaces/question';
+
+const questionTextSchema = new Schema<IQuestion['text']>({
+  ja_JP: { type: String, required: true },
+  en_US: { type: String, required: true },
+}, { _id: false });
+
+const questionSchema = new Schema<IQuestion>({
+  type: { type: String, required: true, enum: Object.values(QuestionType) },
+  text: { type: questionTextSchema, required: true },
+}, { timestamps: true });
+
+export default questionSchema;

+ 9 - 0
apps/app/src/features/questionnaire/server/models/schema/user-info.ts

@@ -0,0 +1,9 @@
+import { Schema } from 'mongoose';
+
+import { IUserInfo, UserType } from '../../../interfaces/user-info';
+
+export const userInfoSchema = new Schema<IUserInfo>({
+  userIdHash: { type: String },
+  type: { type: String, required: true, enum: Object.values(UserType) },
+  userCreatedAt: { type: Date },
+});

+ 204 - 0
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -0,0 +1,204 @@
+import { Router, Request } from 'express';
+import { body, validationResult } from 'express-validator';
+
+import { IUserHasId } from '~/interfaces/user';
+import Crowi from '~/server/crowi';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
+
+import { IAnswer } from '../../../interfaces/answer';
+import { IProactiveQuestionnaireAnswer } from '../../../interfaces/proactive-questionnaire-answer';
+import { IQuestionnaireAnswer } from '../../../interfaces/questionnaire-answer';
+import { StatusType } from '../../../interfaces/questionnaire-answer-status';
+import ProactiveQuestionnaireAnswer from '../../models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../../models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../../models/questionnaire-answer-status';
+
+
+const logger = loggerFactory('growi:routes:apiv3:questionnaire');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const accessTokenParser = require('~/server/middlewares/access-token-parser')(crowi);
+  const loginRequired = require('~/server/middlewares/login-required')(crowi, true);
+
+  const validators = {
+    proactiveAnswer: [
+      body('satisfaction').exists().isNumeric(),
+      body('lengthOfExperience').isString(),
+      body('position').isString(),
+      body('occupation').isString(),
+      body('commentText').exists().isString(),
+    ],
+    answer: [body('questionnaireOrderId').exists().isString(), body('answers').exists().isArray({ min: 1 })],
+    skipDeny: [body('questionnaireOrderId').exists().isString()],
+  };
+
+  const changeAnswerStatus = async(user, questionnaireOrderId, status) => {
+    const result = await QuestionnaireAnswerStatus.updateOne({
+      user: { $eq: user },
+      questionnaireOrderId: { $eq: questionnaireOrderId },
+    }, {
+      status,
+    }, { upsert: true });
+
+    if (result.modifiedCount === 1) {
+      return 204;
+    }
+    if (result.upsertedCount === 1) {
+      return 201;
+    }
+    return 404;
+  };
+
+  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);
+
+    try {
+      const questionnaireOrders = await crowi.questionnaireService!.getQuestionnaireOrdersToShow(userInfo, growiInfo, req.user?._id ?? null);
+
+      return res.apiv3({ questionnaireOrders });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.get('/is-enabled', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const isEnabled = crowi.configManager!.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled');
+    return res.apiv3({ isEnabled });
+  });
+
+  router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const sendQuestionnaireAnswer = async() => {
+      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
+      const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+
+      const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
+        satisfaction: req.body.satisfaction,
+        lengthOfExperience: req.body.lengthOfExperience,
+        position: req.body.position,
+        occupation: req.body.occupation,
+        commentText: req.body.commentText,
+        growiInfo,
+        userInfo,
+        answeredAt: new Date(),
+      };
+
+      try {
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswer);
+      }
+      catch (err) {
+        if (err.request != null) {
+          // when failed to send, save to resend in cronjob
+          await ProactiveQuestionnaireAnswer.create(proactiveQuestionnaireAnswer);
+        }
+        else {
+          throw err;
+        }
+      }
+    };
+
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ errors: errors.array() });
+    }
+
+    try {
+      await sendQuestionnaireAnswer();
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.put('/answer', accessTokenParser, loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
+      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
+      const userInfo = crowi.questionnaireService!.getUserInfo(user, growiInfo.appSiteUrlHashed);
+
+      const questionnaireAnswer: IQuestionnaireAnswer = {
+        growiInfo,
+        userInfo,
+        answers,
+        answeredAt: new Date(),
+        questionnaireOrder: req.body.questionnaireOrderId,
+      };
+
+      try {
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswer);
+      }
+      catch (err) {
+        if (err.request != null) {
+          // when failed to send, save to resend in cronjob
+          await QuestionnaireAnswer.create(questionnaireAnswer);
+        }
+        else {
+          throw err;
+        }
+      }
+    };
+
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ errors: errors.array() });
+    }
+
+    try {
+      await sendQuestionnaireAnswer(req.user ?? null, 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', accessTokenParser, loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ errors: errors.array() });
+    }
+
+    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);
+    }
+  });
+
+  router.put('/deny', accessTokenParser, loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ errors: errors.array() });
+    }
+
+    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;
+
+};

+ 121 - 0
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -0,0 +1,121 @@
+import axiosRetry from 'axios-retry';
+
+import loggerFactory from '~/utils/logger';
+import { getRandomIntInRange } from '~/utils/rand';
+
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
+
+const logger = loggerFactory('growi:service:questionnaire-cron');
+
+const axios = require('axios').default;
+const nodeCron = require('node-cron');
+
+axiosRetry(axios, { retries: 3 });
+
+/**
+ * manage cronjob which
+ *  1. fetches QuestionnaireOrders from questionnaire server
+ *  2. updates QuestionnaireOrder collection to contain only the ones that exist in the fetched list and is not finished (doesn't have to be started)
+ *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
+ *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
+ */
+class QuestionnaireCronService {
+
+  crowi: any;
+
+  cronJob: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
+
+  startCron(): void {
+    const cronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
+    const maxHoursUntilRequest = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
+
+    const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
+
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob(cronSchedule, maxSecondsUntilRequest);
+    this.cronJob.start();
+  }
+
+  stopCron(): void {
+    this.cronJob.stop();
+  }
+
+  async executeJob(): Promise<void> {
+    const questionnaireServerOrigin = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+
+    const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
+      const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
+      return response.data.questionnaireOrders;
+    };
+
+    const saveUnfinishedOrders = async(questionnaireOrders: IQuestionnaireOrder[]) => {
+      const currentDate = new Date(Date.now());
+      const unfinishedOrders = questionnaireOrders.filter(order => new Date(order.showUntil) > currentDate);
+      await QuestionnaireOrder.insertMany(unfinishedOrders);
+    };
+
+    const changeSkippedAnswerStatusToNotAnswered = async() => {
+      await QuestionnaireAnswerStatus.updateMany(
+        { status: StatusType.skipped },
+        { status: StatusType.not_answered },
+      );
+    };
+
+    const resendQuestionnaireAnswers = async() => {
+      const questionnaireAnswers = await QuestionnaireAnswer.find()
+        .select('-_id -answers._id  -growiInfo._id -userInfo._id');
+      const proactiveQuestionnaireAnswers = await ProactiveQuestionnaireAnswer.find()
+        .select('-_id -growiInfo._id -userInfo._id');
+
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, { questionnaireAnswers })
+        .then(async() => {
+          await QuestionnaireAnswer.deleteMany();
+        });
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, { proactiveQuestionnaireAnswers })
+        .then(async() => {
+          await ProactiveQuestionnaireAnswer.deleteMany();
+        });
+    };
+
+    const questionnaireOrders: IQuestionnaireOrder[] = await fetchQuestionnaireOrders();
+
+    resendQuestionnaireAnswers();
+
+    // reset QuestionnaireOrder collection and save unfinished ones that exist on questionnaire server
+    await QuestionnaireOrder.deleteMany();
+    await saveUnfinishedOrders(questionnaireOrders);
+
+    await changeSkippedAnswerStatusToNotAnswered();
+  }
+
+  private generateCronJob(cronSchedule: string, maxSecondsUntilRequest: number) {
+    return nodeCron.schedule(cronSchedule, async() => {
+      // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
+      const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
+      await this.sleep(secToSleep * 1000);
+
+      try {
+        this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+
+    });
+  }
+
+}
+
+export default QuestionnaireCronService;

+ 114 - 0
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -0,0 +1,114 @@
+import crypto from 'crypto';
+import * as os from 'node:os';
+
+import { IUserHasId } from '~/interfaces/user';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import {
+  GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
+} from '../../interfaces/growi-info';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { IUserInfo, UserType } from '../../interfaces/user-info';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder, { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import { isShowableCondition } from '../util/condition';
+
+class QuestionnaireService {
+
+  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 wikiMode = this.crowi.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiType = wikiMode === 'private' ? GrowiWikiType.closed : GrowiWikiType.open;
+
+    const activeExternalAccountTypes: GrowiExternalAuthProviderType[] = Object.values(GrowiExternalAuthProviderType).filter((type) => {
+      return this.crowi.configManager.getConfig('crowi', `security:passport-${type}:isEnabled`);
+    });
+
+    const typeStr = this.crowi.configManager.getConfig('crowi', 'app:serviceType');
+    const type = Object.values(GrowiServiceType).includes(typeStr) ? typeStr : null;
+
+    const attachmentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:fileUploadType');
+    const attachmentType = Object.values(GrowiAttachmentType).includes(attachmentTypeStr) ? attachmentTypeStr : null;
+
+    const deploymentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:deploymentType');
+    const deploymentType = Object.values(GrowiDeploymentType).includes(deploymentTypeStr) ? deploymentTypeStr : null;
+
+    return {
+      version: this.crowi.version,
+      osInfo: {
+        type: os.type(),
+        platform: os.platform(),
+        arch: os.arch(),
+        totalmem: os.totalmem(),
+      },
+      appSiteUrl: this.crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed') ? null : appSiteUrl,
+      appSiteUrlHashed,
+      type,
+      currentUsersCount,
+      currentActiveUsersCount,
+      wikiType,
+      attachmentType,
+      activeExternalAccountTypes,
+      deploymentType,
+    };
+  }
+
+  getUserInfo(user: IUserHasId | null, appSiteUrlHashed: string): IUserInfo {
+    if (user != null) {
+      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 };
+  }
+
+  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?.status === StatusType.not_answered;
+      });
+    }
+
+    return questionnaireOrders
+      .filter((order) => {
+        return isShowableCondition(order, userInfo, growiInfo);
+      });
+  }
+
+}
+
+export default QuestionnaireService;

+ 67 - 0
apps/app/src/features/questionnaire/server/util/condition.ts

@@ -0,0 +1,67 @@
+import { ICondition } from '../../interfaces/condition';
+import { IGrowiInfo } from '../../interfaces/growi-info';
+import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import { IUserInfo, UserType } from '../../interfaces/user-info';
+
+
+const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
+  const { user: { types, daysSinceCreation } } = condition;
+
+  if (!types.includes(userInfo.type)) {
+    return false;
+  }
+
+  // Check if "time passed since user creation" is between specified range
+  if (userInfo.type !== UserType.guest) {
+    const createdAt = userInfo.userCreatedAt;
+    const moreThanOrEqualTo = daysSinceCreation?.moreThanOrEqualTo;
+    const lessThanOrEqualTo = daysSinceCreation?.lessThanOrEqualTo;
+    const currentDate = new Date();
+
+    const isValidLeftThreshold = (() => {
+      if (moreThanOrEqualTo == null) {
+        return true;
+      }
+      const leftThreshold = new Date(createdAt.getTime() + 60 * 1000 * 60 * 24 * moreThanOrEqualTo);
+      return leftThreshold <= currentDate;
+    })();
+    const isValidRightThreshold = (() => {
+      if (lessThanOrEqualTo == null) {
+        return true;
+      }
+      const rightThreshold = new Date(createdAt.getTime() + 60 * 1000 * 60 * 24 * lessThanOrEqualTo);
+      return currentDate <= rightThreshold;
+    })();
+
+    return isValidLeftThreshold && isValidRightThreshold;
+  }
+
+  return true;
+};
+
+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;
+};

+ 3 - 0
apps/app/src/interfaces/activity.ts

@@ -76,6 +76,7 @@ const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
 const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
+const ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE = 'ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
@@ -252,6 +253,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
@@ -436,6 +438,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,

+ 2 - 2
apps/app/src/interfaces/attachment.ts

@@ -1,8 +1,8 @@
-import type { HasObjectId, IAttachment } from '@growi/core';
+import type { IAttachmentHasId } from '@growi/core';
 
 import type { PaginateResult } from './mongoose-utils';
 
 
 export type IResAttachmentList = {
-  paginateResult: PaginateResult<IAttachment & HasObjectId>
+  paginateResult: PaginateResult<IAttachmentHasId>
 };

+ 48 - 0
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -0,0 +1,48 @@
+export type IResAppSettings = {
+  title: string,
+  confidential: string,
+  globalLang: string,
+  isEmailPublishedForNewUser: boolean,
+  fileUpload: string,
+  isV5Compatible: boolean,
+  siteUrl: string,
+  envSiteUrl: string,
+  isMailerSetup: boolean,
+  fromAddress: string,
+
+  transmissionMethod: string,
+  smtpHost: string,
+  smtpPort: string | number, // TODO: check
+  smtpUser: string,
+  smtpPassword: string,
+  sesAccessKeyId: string,
+  sesSecretAccessKey: string,
+
+  fileUploadType: string,
+  envFileUploadType: string,
+  useOnlyEnvVarForFileUploadType: boolean,
+
+  s3Region: string,
+  s3CustomEndpoint: string,
+  s3Bucket:string,
+  s3AccessKeyId: string,
+  s3SecretAccessKey: string,
+  s3ReferenceFileWithRelayMode: string,
+
+  gcsUseOnlyEnvVars: boolean,
+  gcsApiKeyJsonPath: string,
+  gcsBucket: string,
+  gcsUploadNamespace: string,
+  gcsReferenceFileWithRelayMode: string,
+
+  envGcsApiKeyJsonPath: string,
+  envGcsBucket: string,
+  envGcsUploadNamespace: string,
+
+  isEnabledPlugins: boolean,
+
+  isQuestionnaireEnabled: boolean,
+  isAppSiteUrlHashed: boolean,
+
+  isMaintenanceMode: boolean,
+}

+ 3 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -21,8 +21,7 @@ import superjson from 'superjson';
 
 import { useCurrentGrowiLayoutFluidClassName, useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
-import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -77,6 +76,7 @@ const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal')
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const TemplateModal = dynamic(() => import('../components/TemplateModal').then(mod => mod.TemplateModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
+const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -378,6 +378,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <DescendantsPageListModal />
       <DrawioModal />
       <HandsontableModal />
+      <QuestionnaireModalManager />
       <TemplateModal />
     </>
   );

+ 18 - 0
apps/app/src/server/crowi/index.js

@@ -10,11 +10,14 @@ import next from 'next';
 
 import pkg from '^/package.json';
 
+import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
+import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
+
 import Activity from '../models/activity';
 import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
@@ -36,6 +39,7 @@ import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
+
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
@@ -83,6 +87,8 @@ function Crowi() {
   this.activityService = null;
   this.commentService = null;
   this.xss = new Xss();
+  this.questionnaireService = null;
+  this.questionnaireCronService = null;
 
   this.tokens = null;
 
@@ -109,6 +115,7 @@ Crowi.prototype.init = async function() {
   await this.setupModels();
   await this.setupConfigManager();
   await this.setupSessionConfig();
+  this.setupCron();
 
   // setup messaging services
   await this.setupS2sMessagingService();
@@ -145,6 +152,7 @@ Crowi.prototype.init = async function() {
     this.setupActivityService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
+    this.setupQuestionnaireService(),
     this.setUpCustomize(), // depends on pluginService
   ]);
 
@@ -307,6 +315,16 @@ Crowi.prototype.setupModels = async function() {
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
   });
+
+};
+
+Crowi.prototype.setupCron = function() {
+  this.questionnaireCronService = new QuestionnaireCronService(this);
+  this.questionnaireCronService.startCron();
+};
+
+Crowi.prototype.setupQuestionnaireService = function() {
+  this.questionnaireService = new QuestionnaireService(this);
 };
 
 Crowi.prototype.scanRuntimeVersions = async function() {

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

@@ -6,7 +6,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) => {

+ 6 - 0
apps/app/src/server/models/user.js

@@ -69,6 +69,7 @@ module.exports = function(crowi) {
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
     isInvitationEmailSended: { type: Boolean, default: false },
+    isQuestionnaireEnabled: { type: Boolean, default: true },
   }, {
     timestamps: true,
     toObject: {
@@ -730,6 +731,11 @@ module.exports = function(crowi) {
     return { users, totalCount };
   };
 
+  userSchema.methods.updateIsQuestionnaireEnabled = async function(value) {
+    this.isQuestionnaireEnabled = value;
+    return this.save();
+  };
+
   class UserUpperLimitException {
 
     constructor() {

+ 37 - 0
apps/app/src/server/routes/apiv3/app-settings.js

@@ -199,6 +199,10 @@ module.exports = (crowi) => {
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
     ],
+    questionnaireSettings: [
+      body('isQuestionnaireEnabled').isBoolean(),
+      body('isAppSiteUrlHashed').isBoolean(),
+    ],
     maintenanceMode: [
       body('flag').isBoolean(),
     ],
@@ -266,6 +270,10 @@ module.exports = (crowi) => {
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
 
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
+
+      isQuestionnaireEnabled: crowi.configManager.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled'),
+      isAppSiteUrlHashed: crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed'),
+
       isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
     };
     return res.apiv3({ appSettingsParams });
@@ -674,6 +682,35 @@ module.exports = (crowi) => {
 
   });
 
+  // eslint-disable-next-line max-len
+  router.put('/questionnaire-settings', loginRequiredStrictly, adminRequired, addActivity, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
+    const { isQuestionnaireEnabled, isAppSiteUrlHashed } = req.body;
+
+    const requestParams = {
+      'questionnaire:isQuestionnaireEnabled': isQuestionnaireEnabled,
+      'questionnaire:isAppSiteUrlHashed': isAppSiteUrlHashed,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
+
+      const responseParams = {
+        isQuestionnaireEnabled: crowi.configManager.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled'),
+        isAppSiteUrlHashed: crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed'),
+      };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+      return res.apiv3({ responseParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating questionnaire settings';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-questionnaire-settings-failed'));
+    }
+
+  });
+
   router.post('/v5-schema-migration', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
     if (!isMaintenanceMode) {

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

@@ -113,6 +113,7 @@ module.exports = (crowi, app) => {
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
+  router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
 
   return [router, routerForAdmin, routerForAuth];
 };

+ 18 - 0
apps/app/src/server/routes/apiv3/personal-setting.js

@@ -123,6 +123,9 @@ module.exports = (crowi) => {
       body('defaultSubscribeRules.*.name').isString(),
       body('defaultSubscribeRules.*.isEnabled').optional().isBoolean(),
     ],
+    questionnaireSettings: [
+      body('isQuestionnaireEnabled').isBoolean(),
+    ],
   };
 
   /**
@@ -667,6 +670,21 @@ module.exports = (crowi) => {
     }
   });
 
+  // eslint-disable-next-line max-len
+  router.put('/questionnaire-settings', accessTokenParser, loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
+    const { isQuestionnaireEnabled } = req.body;
+    const { user } = req;
+    try {
+      await user.updateIsQuestionnaireEnabled(isQuestionnaireEnabled);
+
+      return res.apiv3({ message: 'Successfully updated questionnaire settings.', isQuestionnaireEnabled });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err({ error: 'Failed to update questionnaire settings.' });
+    }
+  });
+
 
   return router;
 };

+ 2 - 2
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -169,9 +169,9 @@ export const completeRegistrationAction = (crowi) => {
             });
           }
 
-          // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+          // userData.password can't be empty but, prepare redirect because password property in User Model is optional
           // https://github.com/weseek/growi/pull/6670
-          const redirectTo = userData.password != null ? '/' : '/me#password';
+          const redirectTo = userData.password != null ? '/' : '/me#password_settings';
           return res.apiv3({ redirectTo });
         });
       });

+ 2 - 2
apps/app/src/server/routes/login.js

@@ -82,9 +82,9 @@ module.exports = function(crowi, app) {
 
       let redirectTo;
       if (userData.password == null) {
-        // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+        // userData.password can't be empty but, prepare redirect because password property in User Model is optional
         // https://github.com/weseek/growi/pull/6670
-        redirectTo = '/me#password';
+        redirectTo = '/me#password_settings';
       }
       else if (req.session.redirectTo != null) {
         redirectTo = req.session.redirectTo;

+ 43 - 1
apps/app/src/server/service/config-loader.ts

@@ -1,7 +1,7 @@
 import { envUtils } from '@growi/core';
 import { parseISO } from 'date-fns';
 
-
+import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import loggerFactory from '~/utils/logger';
 
 import ConfigModel, {
@@ -640,6 +640,48 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.STRING,
     default: null,
   },
+  QUESTIONNAIRE_SERVER_ORIGIN: {
+    ns: 'crowi',
+    key: 'app:questionnaireServerOrigin',
+    type: ValueType.STRING,
+    default: null,
+  },
+  QUESTIONNAIRE_CRON_SCHEDULE: {
+    ns: 'crowi',
+    key: 'app:questionnaireCronSchedule',
+    type: ValueType.STRING,
+    default: '0 22 * * *',
+  },
+  QUESTIONNAIRE_CRON_MAX_HOURS_UNTIL_REQUEST: {
+    ns: 'crowi',
+    key: 'app:questionnaireCronMaxHoursUntilRequest',
+    type: ValueType.NUMBER,
+    default: 4,
+  },
+  QUESTIONNAIRE_IS_ENABLE_QUESTIONNAIRE: {
+    ns: 'crowi',
+    key: 'questionnaire:isQuestionnaireEnabled',
+    type: ValueType.BOOLEAN,
+    default: true,
+  },
+  QUESTIONNAIRE_IS_APP_SITE_URL_HASHED: {
+    ns: 'crowi',
+    key: 'questionnaire:isAppSiteUrlHashed',
+    type: ValueType.BOOLEAN,
+    default: false,
+  },
+  SERVICE_TYPE: {
+    ns: 'crowi',
+    key: 'app:serviceType',
+    type: ValueType.STRING,
+    default: GrowiServiceType.onPremise,
+  },
+  DEPLOYMENT_TYPE: {
+    ns: 'crowi',
+    key: 'app:deploymentType',
+    type: ValueType.STRING,
+    default: null,
+  },
 };
 
 

+ 13 - 0
apps/app/src/stores/admin/app-settings.tsx

@@ -0,0 +1,13 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IResAppSettings } from '~/interfaces/res/admin/app-settings';
+
+export const useSWRxAppSettings = (): SWRResponse<IResAppSettings, Error> => {
+  return useSWR(
+    '/app-settings/',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.appSettingsParams;
+    }),
+  );
+};

+ 2 - 3
apps/app/src/stores/attachment.tsx

@@ -1,8 +1,7 @@
 import { useCallback } from 'react';
 
 import {
-  HasObjectId,
-  IAttachment, Nullable, type SWRResponseWithUtils, withUtils,
+  IAttachmentHasId, Nullable, type SWRResponseWithUtils, withUtils,
 } from '@growi/core';
 import useSWR from 'swr';
 
@@ -15,7 +14,7 @@ type Util = {
 };
 
 type IDataAttachmentList = {
-  attachments: (IAttachment & HasObjectId)[]
+  attachments: (IAttachmentHasId)[]
   totalAttachments: number
   limit: number
 };

+ 3 - 0
apps/app/src/stores/modal.tsx

@@ -9,9 +9,12 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction,
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
+import loggerFactory from '~/utils/logger';
 
 import { useStaticSWR } from './use-static-swr';
 
+const logger = loggerFactory('growi:stores:modal');
+
 /*
 * PageCreateModal
 */

+ 21 - 2
apps/app/src/styles/theme/apply-colors.scss

@@ -712,8 +712,27 @@ Expand / compress button bookmark list on users page
 }
 
 /*
- revision-history-diff
-*/
+ * Questionnaire modal
+ */
+.grw-questionnaire-btn-group {
+  .btn-outline-primary {
+    @include hsl-button.button-outline-variant(
+      #{hsl.lighten(var(--primary), 30%)} !important,
+      #{hsl.contrast(var(--primary))} !important,
+      var(--primary) !important,
+      #{hsl.lighten(var(--primary), 30%)} !important,
+    );
+    &:not(:disabled):not(.disabled):active,
+    &:not(:disabled):not(.disabled).active {
+      color: #{hsl.contrast(var(--primary))} !important;
+      background-color: var(--primary) !important;
+    }
+  }
+}
+
+/*
+ * revision-history-diff
+ */
 .revision-history-diff {
   background-color: white;
 }

+ 5 - 0
apps/app/src/utils/rand.ts

@@ -0,0 +1,5 @@
+export const getRandomIntInRange = (min: number, max: number): number => {
+  const minInt = Math.ceil(min);
+  const maxInt = Math.floor(max);
+  return Math.floor(Math.random() * (maxInt - minInt) + minInt);
+};

+ 3 - 1
apps/app/test/cypress/integration/40-admin/40-admin--access-to-admin-page.spec.ts

@@ -33,7 +33,9 @@ context('Access to Admin page', () => {
     cy.visit('/admin/app');
     cy.getByTestid('admin-app-settings').should('be.visible');
     cy.getByTestid('v5-page-migration').should('be.visible');
-    cy.get('#cbFileUpload').should('be.checked')
+    cy.get('#cbFileUpload').should('be.checked');
+    cy.get('#isQuestionnaireEnabled').should('be.checked');
+    cy.get('#isAppSiteUrlHashed').should('not.be.checked');
     cy.screenshot(`${ssPrefix}-admin-app`);
   });
 

+ 37 - 0
apps/app/test/cypress/integration/60-home/60-home--home.spec.ts

@@ -123,4 +123,41 @@ context('Access User settings', () => {
     cy.get('.Toastify__toast').should('be.visible');
     cy.screenshot(`${ssPrefix}-in-app-notification-setting-2`);
   });
+
+  it('Access Other setting', () => {
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-other-setting-1`);
+    cy.getByTestid('grw-questionnaire-settings-update-btn').click();
+    cy.get('.Toastify__toast').should('be.visible').invoke('attr', 'style', 'display: none');
+    cy.screenshot(`${ssPrefix}-other-setting-2`);
+  });
+});
+
+context('Access proactive questionnaire modal', () => {
+  const ssPrefix = 'proactive-questionnaire-modal-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Opens questionnaire modal', () => {
+    cy.visit('/dummy');
+
+    // open PersonalDropdown
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('personal-dropdown-button').should('be.visible').click();
+      // wait until
+      return cy.getByTestid('grw-personal-dropdown-menu-user-home').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('grw-proactive-questionnaire-modal-toggle-btn').should('be.visible').click();
+    cy.getByTestid('grw-proactive-questionnaire-modal').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-opened`);
+  });
 });

+ 408 - 0
apps/app/test/integration/service/questionnaire-cron.test.ts

@@ -0,0 +1,408 @@
+import mongoose from 'mongoose';
+
+import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
+import { IQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/questionnaire-answer';
+import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
+import ProactiveQuestionnaireAnswer from '../../../src/features/questionnaire/server/models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../../../src/features/questionnaire/server/models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
+import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
+import { getInstance } from '../setup-crowi';
+
+const axios = require('axios').default;
+
+const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
+  axios,
+  'get',
+);
+
+const spyAxiosPost = jest.spyOn<typeof axios, 'post'>(
+  axios,
+  'post',
+);
+
+describe('QuestionnaireCronService', () => {
+  let crowi;
+
+  const mockResponse = {
+    data: {
+      questionnaireOrders: [
+        // saved in db、not finished (user.types is updated from the time it was saved)
+        {
+          _id: '63a8354837e7aa378e16f0b1',
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: '2022-12-11',
+          showUntil: '2100-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: {
+                ja_JP: 'GROWI は使いやすいですか?',
+                en_US: 'Is GROWI easy to use?',
+              },
+            },
+          ],
+          condition: {
+            user: {
+              types: ['admin', 'general'],
+            },
+            growi: {
+              types: ['cloud', 'private-cloud'],
+              versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+            },
+          },
+          createdAt: '2022-12-01',
+          updatedAt: '2022-12-01',
+          __v: 0,
+        },
+        // not saved, not finished
+        {
+          _id: '63a8354837e7aa378e16f0b2',
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: '2021-12-11',
+          showUntil: '2100-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: {
+                ja_JP: 'アンケート機能は正常動作していますか?',
+                en_US: 'Is this questionnaire functioning properly?',
+              },
+            },
+          ],
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['cloud'],
+              versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+            },
+          },
+          createdAt: '2022-12-02',
+          updatedAt: '2022-12-02',
+          __v: 0,
+        },
+        // not saved, finished
+        {
+          _id: '63a8354837e7aa378e16f0b3',
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: '2021-12-11',
+          showUntil: '2021-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: {
+                ja_JP: 'これはいい質問ですか?',
+                en_US: 'Is this a good question?',
+              },
+            },
+          ],
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['cloud'],
+              versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+            },
+          },
+          createdAt: '2022-12-03',
+          updatedAt: '2022-12-03',
+          __v: 0,
+        },
+      ],
+    },
+  };
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+  });
+
+  beforeEach(async() => {
+    // insert initial db data
+    await QuestionnaireOrder.insertMany([
+      {
+        _id: '63a8354837e7aa378e16f0b1',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2022-12-11',
+        showUntil: '2100-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: {
+              ja_JP: 'GROWI は使いやすいですか?',
+              en_US: 'Is GROWI easy to use?',
+            },
+          },
+        ],
+        condition: {
+          user: {
+            types: ['general'],
+          },
+          growi: {
+            types: ['cloud', 'private-cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+      },
+      // finished
+      {
+        _id: '63a8354837e7aa378e16f0b4',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2020-12-11',
+        showUntil: '2021-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: {
+              ja_JP: 'ver 2.0 は 1.0 より良いですか?',
+              en_US: 'Is ver 2.0 better than 1.0?',
+            },
+          },
+        ],
+        condition: {
+          user: {
+            types: ['general'],
+          },
+          growi: {
+            types: ['cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+      },
+      // questionnaire that doesn't exist in questionnaire server
+      {
+        _id: '63a8354837e7aa378e16f0b5',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2020-12-11',
+        showUntil: '2100-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: {
+              ja_JP: '新しいデザインは良いですか?',
+              en_US: 'How would you rate the latest design?',
+            },
+          },
+        ],
+        condition: {
+          user: {
+            types: ['general'],
+          },
+          growi: {
+            types: ['cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+      },
+    ]);
+
+    await QuestionnaireAnswerStatus.insertMany([
+      {
+        user: new mongoose.Types.ObjectId(),
+        questionnaireOrderId: '63a8354837e7aa378e16f0b1',
+        status: StatusType.skipped,
+      },
+      {
+        user: new mongoose.Types.ObjectId(),
+        questionnaireOrderId: '63a8354837e7aa378e16f0b1',
+        status: StatusType.answered,
+      },
+      {
+        user: new mongoose.Types.ObjectId(),
+        questionnaireOrderId: '63a8354837e7aa378e16f0b1',
+        status: StatusType.not_answered,
+      },
+    ]);
+
+    const validQuestionnaireAnswer: IQuestionnaireAnswer = {
+      answers: [{
+        question: '63c6da88143e531d95346188',
+        value: '1',
+      }],
+      answeredAt: new Date(),
+      growiInfo: {
+        version: '1.0',
+        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: 'cloud',
+        currentUsersCount: 100,
+        currentActiveUsersCount: 50,
+        wikiType: 'open',
+        attachmentType: 'aws',
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      questionnaireOrder: '63a8354837e7aa378e16f0b1',
+    };
+
+    await QuestionnaireAnswer.insertMany([
+      validQuestionnaireAnswer,
+      validQuestionnaireAnswer,
+      validQuestionnaireAnswer,
+    ]);
+
+    const validProactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
+      satisfaction: 1,
+      commentText: 'answer text',
+      growiInfo: {
+        version: '1.0',
+        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: 'cloud',
+        currentUsersCount: 100,
+        currentActiveUsersCount: 50,
+        wikiType: 'open',
+        attachmentType: 'aws',
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      answeredAt: new Date(),
+    };
+
+    await ProactiveQuestionnaireAnswer.insertMany([
+      validProactiveQuestionnaireAnswer,
+      validProactiveQuestionnaireAnswer,
+      validProactiveQuestionnaireAnswer,
+    ]);
+
+    crowi.setupCron();
+
+    spyAxiosGet.mockResolvedValue(mockResponse);
+    spyAxiosPost.mockResolvedValue({ data: { result: 'success' } });
+  });
+
+  afterAll(() => {
+    crowi.questionnaireCronService.stopCron(); // jest will not finish until cronjob stops
+  });
+
+  test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
+    // testing the cronjob from schedule has untrivial overhead, so test job execution in place
+    await crowi.questionnaireCronService.executeJob();
+
+    const savedOrders = await QuestionnaireOrder.find()
+      .select('-condition._id -questions._id')
+      .sort({ _id: 1 });
+    expect(JSON.parse(JSON.stringify(savedOrders))).toEqual([
+      {
+        _id: '63a8354837e7aa378e16f0b1',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2022-12-11T00:00:00.000Z',
+        showUntil: '2100-12-12T00:00:00.000Z',
+        questions: [
+          {
+            type: 'points',
+            text: {
+              ja_JP: 'GROWI は使いやすいですか?',
+              en_US: 'Is GROWI easy to use?',
+            },
+          },
+        ],
+        condition: {
+          user: {
+            types: ['admin', 'general'],
+          },
+          growi: {
+            types: ['cloud', 'private-cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+        createdAt: '2022-12-01T00:00:00.000Z',
+        updatedAt: '2022-12-01T00:00:00.000Z',
+        __v: 0,
+      },
+      {
+        _id: '63a8354837e7aa378e16f0b2',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2021-12-11T00:00:00.000Z',
+        showUntil: '2100-12-12T00:00:00.000Z',
+        questions: [
+          {
+            type: 'points',
+            text: {
+              ja_JP: 'アンケート機能は正常動作していますか?',
+              en_US: 'Is this questionnaire functioning properly?',
+            },
+          },
+        ],
+        condition: {
+          user: {
+            types: ['general'],
+          },
+          growi: {
+            types: ['cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+        createdAt: '2022-12-02T00:00:00.000Z',
+        updatedAt: '2022-12-02T00:00:00.000Z',
+        __v: 0,
+      },
+    ]);
+
+    expect((await QuestionnaireAnswerStatus.find({ status: StatusType.not_answered })).length).toEqual(2);
+    expect((await QuestionnaireAnswer.find()).length).toEqual(0);
+    expect((await ProactiveQuestionnaireAnswer.find()).length).toEqual(0);
+  });
+});

+ 306 - 0
apps/app/test/integration/service/questionnaire.test.ts

@@ -0,0 +1,306 @@
+import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
+import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
+import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
+import { getInstance } from '../setup-crowi';
+
+describe('QuestionnaireService', () => {
+  let crowi;
+  let user;
+
+  beforeAll(async() => {
+    process.env.APP_SITE_URL = 'http://growi.test.jp';
+    process.env.DEPLOYMENT_TYPE = 'growi-docker-compose';
+    process.env.SAML_ENABLED = 'true';
+    crowi = await getInstance();
+
+    crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
+      'security:passport-saml:isEnabled': true,
+      'security:passport-github:isEnabled': true,
+    });
+
+    crowi.setupQuestionnaireService();
+
+    const User = crowi.model('User');
+    user = await User.create({
+      name: 'Example for Questionnaire Service Test',
+      username: 'questionnaire test user',
+      email: 'questionnaireTestUser@example.com',
+      password: 'usertestpass',
+      createdAt: '2000-01-01',
+    });
+  });
+
+  describe('getGrowiInfo', () => {
+    test('Should get correct GROWI info', async() => {
+      const growiInfo = await crowi.questionnaireService.getGrowiInfo();
+
+      expect(growiInfo.appSiteUrlHashed).toBeTruthy();
+      expect(growiInfo.appSiteUrlHashed).not.toBe('http://growi.test.jp');
+      expect(growiInfo.osInfo.type).toBeTruthy();
+      expect(growiInfo.osInfo.platform).toBeTruthy();
+      expect(growiInfo.osInfo.arch).toBeTruthy();
+      expect(growiInfo.osInfo.totalmem).toBeTruthy();
+
+      delete growiInfo.appSiteUrlHashed;
+      delete growiInfo.currentActiveUsersCount;
+      delete growiInfo.currentUsersCount;
+      delete growiInfo.osInfo;
+
+      expect(growiInfo).toEqual({
+        activeExternalAccountTypes: ['saml', 'github'],
+        appSiteUrl: 'http://growi.test.jp',
+        attachmentType: 'aws',
+        deploymentType: 'growi-docker-compose',
+        type: 'on-premise',
+        version: crowi.version,
+        wikiType: 'open',
+      });
+    });
+
+    describe('When url hash settings is on', () => {
+      beforeEach(async() => {
+        process.env.QUESTIONNAIRE_IS_APP_SITE_URL_HASHED = 'true';
+        await crowi.setupConfigManager();
+      });
+
+      test('Should return app url string', async() => {
+        const growiInfo = await crowi.questionnaireService.getGrowiInfo();
+        expect(growiInfo.appSiteUrl).toBe(null);
+        expect(growiInfo.appSiteUrlHashed).not.toBe('http://growi.test.jp');
+        expect(growiInfo.appSiteUrlHashed).toBeTruthy();
+      });
+    });
+  });
+
+  describe('getUserInfo', () => {
+    test('Should get correct user info when user given', () => {
+      const userInfo = crowi.questionnaireService.getUserInfo(user, 'growiurlhashfortest');
+      expect(userInfo.userIdHash).toBeTruthy();
+      expect(userInfo.userIdHash).not.toBe(user._id);
+
+      delete userInfo.userIdHash;
+
+      expect(userInfo).toEqual({ type: 'general', userCreatedAt: new Date('2000-01-01') });
+    });
+
+    test('Should get correct user info when user is null', () => {
+      const userInfo = crowi.questionnaireService.getUserInfo(null, '');
+      expect(userInfo).toEqual({ type: 'guest' });
+    });
+  });
+
+  describe('getQuestionnaireOrdersToShow', () => {
+    beforeAll(async() => {
+      const questionnaireToBeShown = {
+        _id: '63b8354837e7aa378e16f0b1',
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2022-12-11',
+        showUntil: '2100-12-12',
+        condition: {
+          user: {
+            types: ['general'],
+            daysSinceCreation: {
+              moreThanOrEqualTo: 365,
+              lessThanOrEqualTo: 365 * 1000,
+            },
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [crowi.version],
+          },
+        },
+        createdAt: '2023-01-01',
+        updatedAt: '2023-01-01',
+      };
+
+      // insert initial db data
+      await QuestionnaireOrder.insertMany([
+        questionnaireToBeShown,
+        // finished
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b2',
+          showFrom: '2020-12-11',
+          showUntil: '2021-12-12',
+        },
+        // for admin or guest
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b3',
+          condition: {
+            user: {
+              types: ['admin', 'guest'],
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [crowi.version],
+            },
+          },
+        },
+        // answered
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b4',
+        },
+        // skipped
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b5',
+        },
+        // denied
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b6',
+        },
+        // for different growi type
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b7',
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['cloud'],
+              versionRegExps: [crowi.version],
+            },
+          },
+        },
+        // for different growi version
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b8',
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: ['1.0.0-alpha'],
+            },
+          },
+        },
+        // for users that used GROWI for less than or equal to a year
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0b9',
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                lessThanOrEqualTo: 365,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [crowi.version],
+            },
+          },
+        },
+        // for users that used GROWI for more than or equal to 1000 years
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0c1',
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [crowi.version],
+            },
+          },
+        },
+        // for users that used GROWI for more than a month and less than 6 months
+        {
+          ...questionnaireToBeShown,
+          _id: '63b8354837e7aa378e16f0c2',
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 30,
+                lessThanOrEqualTo: 30 * 6,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [crowi.version],
+            },
+          },
+        },
+      ]);
+
+      await QuestionnaireAnswerStatus.insertMany([
+        {
+          user: user._id,
+          questionnaireOrderId: '63b8354837e7aa378e16f0b4',
+          status: StatusType.answered,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: '63b8354837e7aa378e16f0b5',
+          status: StatusType.skipped,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: '63b8354837e7aa378e16f0b6',
+          status: StatusType.skipped,
+        },
+      ]);
+    });
+
+    test('Should get questionnaire orders to show', async() => {
+      const growiInfo = await crowi.questionnaireService.getGrowiInfo();
+      const userInfo = crowi.questionnaireService.getUserInfo(user, growiInfo.appSiteUrlHashed);
+      const questionnaireOrderDocuments = await crowi.questionnaireService.getQuestionnaireOrdersToShow(userInfo, growiInfo, user._id);
+      const questionnaireOrderObjects = questionnaireOrderDocuments.map((document) => {
+        const qo = document.toObject();
+        delete qo.condition._id;
+        return { ...qo, _id: qo._id.toString() };
+      });
+      expect(questionnaireOrderObjects).toEqual([
+        {
+          _id: '63b8354837e7aa378e16f0b1',
+          __v: 0,
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: new Date('2022-12-11'),
+          showUntil: new Date('2100-12-12'),
+          questions: [],
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365,
+                lessThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [crowi?.version],
+            },
+          },
+          createdAt: new Date('2023-01-01'),
+          updatedAt: new Date('2023-01-01'),
+        },
+      ]);
+    });
+  });
+});

+ 1 - 0
packages/core/src/interfaces/user.ts

@@ -22,6 +22,7 @@ export type IUser = {
   lastLoginAt?: Date,
   introduction: string,
   status: IUserStatus,
+  isQuestionnaireEnabled: boolean,
 }
 
 export type IUserGroupRelation = {

+ 3 - 3
packages/ui/src/components/Attachment.tsx

@@ -1,13 +1,13 @@
 import React from 'react';
 
-import { HasObjectId, IAttachment } from '@growi/core';
+import { IAttachmentHasId } from '@growi/core';
 
 import { UserPicture } from './User/UserPicture';
 
 type AttachmentProps = {
-  attachment: IAttachment & HasObjectId,
+  attachment: IAttachmentHasId,
   inUse: boolean,
-  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
 };
 

+ 7 - 0
yarn.lock

@@ -11971,6 +11971,13 @@ nocache@^3.0.1:
   resolved "https://registry.yarnpkg.com/nocache/-/nocache-3.0.1.tgz#54d8b53a7e0a0aa1a288cfceab8a3cefbcde67d4"
   integrity sha512-Gh39xwJwBKy0OvFmWfBs/vDO4Nl7JhnJtkqNP76OUinQz7BiMoszHYrIDHHAaqVl/QKVxCEy4ZxC/XZninu7nQ==
 
+node-cron@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.2.tgz#bb0681342bd2dfb568f28e464031280e7f06bd01"
+  integrity sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==
+  dependencies:
+    uuid "8.3.2"
+
 node-domexception@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"