Browse Source

Merge pull request #9516 from weseek/imprv/start-instrumentation-at-first

imprv: Start instrumentation at first
Yuki Takei 1 year ago
parent
commit
577c8b7970
38 changed files with 551 additions and 256 deletions
  1. 2 1
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  2. 2 1
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  3. 1 1
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  4. 3 2
      apps/app/src/client/components/Me/DisassociateModal.tsx
  5. 2 1
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  6. 1 2
      apps/app/src/features/questionnaire/interfaces/condition.ts
  7. 14 0
      apps/app/src/features/questionnaire/interfaces/growi-app-info.ts
  8. 0 35
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  9. 16 3
      apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts
  10. 14 4
      apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts
  11. 3 2
      apps/app/src/features/questionnaire/server/models/questionnaire-order.ts
  12. 1 2
      apps/app/src/features/questionnaire/server/models/schema/condition.ts
  13. 24 13
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  14. 8 3
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  15. 14 5
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  16. 146 100
      apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts
  17. 36 33
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  18. 9 6
      apps/app/src/features/questionnaire/server/util/condition.ts
  19. 83 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts
  20. 31 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts
  21. 9 0
      apps/app/src/interfaces/external-auth-provider.ts
  22. 1 1
      apps/app/src/pages/login/index.page.tsx
  23. 6 0
      apps/app/src/server/crowi/index.js
  24. 4 5
      apps/app/src/server/models/external-account.ts
  25. 2 1
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  26. 3 3
      apps/app/src/server/service/config-manager/config-definition.ts
  27. 1 1
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  28. 1 1
      apps/app/src/server/service/config-manager/legacy/config-loader.ts
  29. 1 1
      apps/app/src/server/service/external-account.ts
  30. 1 1
      apps/app/src/server/service/passport.ts
  31. 3 2
      apps/app/src/stores/personal-settings.tsx
  32. 72 8
      apps/app/test/integration/service/questionnaire-cron.test.ts
  33. 1 0
      packages/core/src/consts/index.ts
  34. 0 0
      packages/core/src/consts/system.ts
  35. 2 15
      packages/core/src/interfaces/external-account.ts
  36. 31 0
      packages/core/src/interfaces/growi-app-info.ts
  37. 1 0
      packages/core/src/interfaces/index.ts
  38. 2 3
      packages/core/src/interfaces/user.ts

+ 2 - 1
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -76,7 +77,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
           </tr>
         </thead>
         <tbody>
-          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
             return (
               <tr key={ea._id}>
                 <td><span>{ea.providerType}</span></td>

+ 2 - 1
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,8 +1,9 @@
 import { useCallback } from 'react';
 
-import { IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
 const authIcon = {
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,

+ 1 - 1
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, useEffect, useCallback,
 } from 'react';
 
-import type { IExternalAuthProviderType } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
@@ -13,6 +12,7 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 

+ 3 - 2
apps/app/src/client/components/Me/DisassociateModal.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 
-import type { IExternalAccountHasId } from '@growi/core';
+import type { HasObjectId, IExternalAccount } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -10,12 +10,13 @@ import {
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 type Props = {
   isOpen: boolean,
   onClose: () => void,
-  accountForDisassociate: IExternalAccountHasId,
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId,
 }
 
 

+ 2 - 1
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -1,4 +1,4 @@
-import type { IExternalAuthProviderType, IUserHasId } from '@growi/core';
+import type { IUserHasId } from '@growi/core';
 
 import { SocketEventName } from '~/interfaces/websocket';
 import ExternalAccount from '~/server/models/external-account';
@@ -16,6 +16,7 @@ import type {
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 

+ 1 - 2
apps/app/src/features/questionnaire/interfaces/condition.ts

@@ -1,6 +1,5 @@
 import type { HasObjectId } from '@growi/core';
-
-import type { GrowiServiceType } from '~/interfaces/system';
+import type { GrowiServiceType } from '@growi/core/dist/consts';
 
 import type { UserType } from './user-info';
 

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

@@ -0,0 +1,14 @@
+import type { IGrowiAdditionalInfo, IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { AttachmentMethodType } from '~/interfaces/attachment';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
+
+export type IGrowiAppAdditionalInfo = IGrowiAdditionalInfo & {
+  attachmentType: AttachmentMethodType
+  activeExternalAccountTypes?: IExternalAuthProviderType[]
+}
+
+// legacy properties (extracted from additionalInfo for growi-questionnaire)
+// see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+export type IGrowiAppInfoLegacy = Omit<IGrowiInfo<IGrowiAppAdditionalInfo>, 'additionalInfo'> & IGrowiAppAdditionalInfo;

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

@@ -1,35 +0,0 @@
-import type * as os from 'node:os';
-
-import { IExternalAuthProviderType } from '@growi/core';
-
-import type { AttachmentMethodType } from '~/interfaces/attachment';
-import type { GrowiDeploymentType, GrowiServiceType } from '~/interfaces/system';
-
-export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
-type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
-
-export const GrowiExternalAuthProviderType = IExternalAuthProviderType;
-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
-  installedAt: Date
-  installedAtByOldestUser: Date
-  type: GrowiServiceType
-  currentUsersCount: number
-  currentActiveUsersCount: number
-  wikiType: GrowiWikiType
-  attachmentType: AttachmentMethodType
-  activeExternalAccountTypes?: GrowiExternalAuthProviderType[]
-  osInfo?: IGrowiOSInfo
-  deploymentType?: GrowiDeploymentType
-}

+ 16 - 3
apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

@@ -1,11 +1,24 @@
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 
 export interface IProactiveQuestionnaireAnswer {
   satisfaction: number,
   commentText: string,
-  growiInfo: IGrowiInfo,
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>,
+  userInfo: IUserInfo,
+  answeredAt: Date,
+  lengthOfExperience?: string,
+  position?: string,
+  occupation?: string,
+}
+
+export interface IProactiveQuestionnaireAnswerLegacy {
+  satisfaction: number,
+  commentText: string,
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo,
   answeredAt: Date,
   lengthOfExperience?: string,

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

@@ -1,11 +1,21 @@
-import { IAnswer } from './answer';
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IAnswer } from './answer';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 export interface IQuestionnaireAnswer<ID = string> {
   answers: IAnswer[]
   answeredAt: Date
-  growiInfo: IGrowiInfo
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>
+  userInfo: IUserInfo
+  questionnaireOrder: ID
+}
+
+export interface IQuestionnaireAnswerLegacy<ID = string> {
+  answers: IAnswer[]
+  answeredAt: Date
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo
   questionnaireOrder: ID
 }

+ 3 - 2
apps/app/src/features/questionnaire/server/models/questionnaire-order.ts

@@ -1,8 +1,9 @@
-import { Model, Schema, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 
 import conditionSchema from './schema/condition';
 import questionSchema from './schema/question';

+ 1 - 2
apps/app/src/features/questionnaire/server/models/schema/condition.ts

@@ -1,7 +1,6 @@
+import { GrowiServiceType } from '@growi/core/dist/consts';
 import { Schema } from 'mongoose';
 
-import { GrowiServiceType } from '~/interfaces/system';
-
 import type { ICondition } from '../../../interfaces/condition';
 import { UserType } from '../../../interfaces/user-info';
 

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

@@ -1,26 +1,27 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
 import { Schema } from 'mongoose';
 
+import type { IGrowiAppAdditionalInfo } from '~/features/questionnaire/interfaces/growi-app-info';
 import { AttachmentMethodType } from '~/interfaces/attachment';
-import { GrowiDeploymentType, GrowiServiceType } from '~/interfaces/system';
-
-import type { IGrowiInfo } from '../../../interfaces/growi-info';
-import {
-  GrowiExternalAuthProviderType, GrowiWikiType,
-} from '../../../interfaces/growi-info';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
+const growiAdditionalInfoSchema = new Schema<IGrowiAppAdditionalInfo>({
+  installedAt: { type: Date, required: true },
+  installedAtByOldestUser: { type: Date, required: true },
+  currentUsersCount: { type: Number, required: true },
+  currentActiveUsersCount: { type: Number, required: true },
+  attachmentType: { type: String, required: true, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
+});
 
-export const growiInfoSchema = new Schema<IGrowiInfo>({
+export const growiInfoSchema = new Schema<IGrowiInfo<IGrowiAppAdditionalInfo> & IGrowiAppAdditionalInfo>({
   version: { type: String, required: true },
   appSiteUrl: { type: String },
   appSiteUrlHashed: { type: String, required: true },
-  installedAt: { type: Date, required: true },
-  installedAtByOldestUser: { type: Date, 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(AttachmentMethodType) },
-  activeExternalAccountTypes: [{ type: String, enum: Object.values(GrowiExternalAuthProviderType) }],
   osInfo: {
     type: { type: String },
     platform: String,
@@ -28,4 +29,14 @@ export const growiInfoSchema = new Schema<IGrowiInfo>({
     totalmem: Number,
   },
   deploymentType: { type: String, enum: (<(string | null)[]>Object.values(GrowiDeploymentType)).concat([null]) },
+  additionalInfo: growiAdditionalInfoSchema,
+
+  // legacy properties (extracted from additionalInfo for growi-questionnaire)
+  // see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+  installedAt: { type: Date },
+  installedAtByOldestUser: { type: Date },
+  currentUsersCount: { type: Number },
+  currentActiveUsersCount: { type: Number },
+  attachmentType: { type: String, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
 });

+ 8 - 3
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -17,6 +17,7 @@ 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';
+import { convertToLegacyFormat } from '../../util/convert-to-legacy-format';
 
 
 const logger = loggerFactory('growi:routes:apiv3:questionnaire');
@@ -81,7 +82,7 @@ module.exports = (crowi: Crowi): Router => {
 
   router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async() => {
-      const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
+      const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
       const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
       const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
 
@@ -96,8 +97,10 @@ module.exports = (crowi: Crowi): Router => {
         answeredAt: new Date(),
       };
 
+      const proactiveQuestionnaireAnswerLegacy = convertToLegacyFormat(proactiveQuestionnaireAnswer);
+
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswerLegacy);
       }
       catch (err) {
         if (err.request != null) {
@@ -139,8 +142,10 @@ module.exports = (crowi: Crowi): Router => {
         questionnaireOrder: req.body.questionnaireOrderId,
       };
 
+      const questionnaireAnswerLegacy = convertToLegacyFormat(questionnaireAnswer);
+
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswerLegacy);
       }
       catch (err) {
         if (err.request != null) {

+ 14 - 5
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -4,11 +4,12 @@ import loggerFactory from '~/utils/logger';
 import { getRandomIntInRange } from '~/utils/rand';
 
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { 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';
+import { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 
 const logger = loggerFactory('growi:service:questionnaire-cron');
 
@@ -75,15 +76,23 @@ class QuestionnaireCronService {
 
     const resendQuestionnaireAnswers = async() => {
       const questionnaireAnswers = await QuestionnaireAnswer.find()
-        .select('-_id -answers._id  -growiInfo._id -userInfo._id');
+        .select('-_id -answers._id  -growiInfo._id -userInfo._id')
+        .lean();
       const proactiveQuestionnaireAnswers = await ProactiveQuestionnaireAnswer.find()
-        .select('-_id -growiInfo._id -userInfo._id');
+        .select('-_id -growiInfo._id -userInfo._id')
+        .lean();
 
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, { questionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, {
+        // convert to legacy format
+        questionnaireAnswers: questionnaireAnswers.map(answer => convertToLegacyFormat(answer)),
+      })
         .then(async() => {
           await QuestionnaireAnswer.deleteMany();
         });
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, { proactiveQuestionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, {
+        // convert to legacy format
+        proactiveQuestionnaireAnswers: proactiveQuestionnaireAnswers.map(answer => convertToLegacyFormat(answer)),
+      })
         .then(async() => {
           await ProactiveQuestionnaireAnswer.deleteMany();
         });

+ 146 - 100
apps/app/test/integration/service/questionnaire.test.ts → apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts

@@ -1,34 +1,65 @@
-import mongoose from 'mongoose';
+import { Types } from 'mongoose';
+import { mock } from 'vitest-mock-extended';
+
+import pkg from '^/package.json';
+
+
+import type UserEvent from '~/server/events/user';
+import { Config } from '~/server/models/config';
+import { configManager } from '~/server/service/config-manager';
+
+import type Crowi from '../../../../server/crowi';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { UserType } from '../../interfaces/user-info';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
+
+import QuestionnaireService from './questionnaire';
 
-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;
+  const appVersion = pkg.version;
+
+  let questionnaireService: QuestionnaireService;
+
+  let User;
   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.updateConfigs({
+    await configManager.loadConfigs();
+    await configManager.updateConfigs({
       'security:passport-saml:isEnabled': true,
       'security:passport-github:isEnabled': true,
     });
 
-    await mongoose.model('Config').create({
+    await Config.create({
       key: 'app:installed',
       value: true,
       createdAt: '2000-01-01',
     });
 
-    crowi.setupQuestionnaireService();
+    const crowiMock = mock<Crowi>({
+      version: appVersion,
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+      appService: {
+        getSiteUrl: () => 'http://growi.test.jp',
+      },
+    });
+    const userModelFactory = (await import('~/server/models/user')).default;
+    User = userModelFactory(crowiMock);
+    questionnaireService = new QuestionnaireService(crowiMock);
 
-    const User = crowi.model('User');
+    await User.deleteMany({}); // clear users
     user = await User.create({
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire test user',
@@ -40,42 +71,47 @@ describe('QuestionnaireService', () => {
 
   describe('getGrowiInfo', () => {
     test('Should get correct GROWI info', async() => {
-      const growiInfo = await crowi.questionnaireService.getGrowiInfo();
+      const growiInfo = await questionnaireService.getGrowiInfo();
+
+      assert(growiInfo != null);
 
       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;
+      expect(growiInfo.osInfo?.type).toBeTruthy();
+      expect(growiInfo.osInfo?.platform).toBeTruthy();
+      expect(growiInfo.osInfo?.arch).toBeTruthy();
+      expect(growiInfo.osInfo?.totalmem).toBeTruthy();
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      delete (growiInfo as any).appSiteUrlHashed;
       delete growiInfo.osInfo;
 
       expect(growiInfo).toEqual({
-        activeExternalAccountTypes: ['saml', 'github'],
+        version: appVersion,
         appSiteUrl: 'http://growi.test.jp',
-        installedAt: new Date('2000-01-01'),
-        installedAtByOldestUser: new Date('2000-01-01'),
-        attachmentType: 'aws',
-        deploymentType: 'growi-docker-compose',
         type: 'on-premise',
-        version: crowi.version,
         wikiType: 'closed',
+        deploymentType: 'growi-docker-compose',
+        additionalInfo: {
+          installedAt: new Date('2000-01-01'),
+          installedAtByOldestUser: new Date('2000-01-01'),
+          currentUsersCount: 1,
+          currentActiveUsersCount: 1,
+          attachmentType: 'aws',
+          activeExternalAccountTypes: ['saml', 'github'],
+        },
       });
     });
 
     describe('When url hash settings is on', () => {
       beforeEach(async() => {
         process.env.QUESTIONNAIRE_IS_APP_SITE_URL_HASHED = 'true';
-        await crowi.setupConfigManager();
+        await configManager.loadConfigs();
       });
 
       test('Should return app url string', async() => {
-        const growiInfo = await crowi.questionnaireService.getGrowiInfo();
-        expect(growiInfo.appSiteUrl).toBe(null);
+        const growiInfo = await questionnaireService.getGrowiInfo();
+        expect(growiInfo.appSiteUrl).toBeUndefined();
         expect(growiInfo.appSiteUrlHashed).not.toBe('http://growi.test.jp');
         expect(growiInfo.appSiteUrlHashed).toBeTruthy();
       });
@@ -84,25 +120,44 @@ describe('QuestionnaireService', () => {
 
   describe('getUserInfo', () => {
     test('Should get correct user info when user given', () => {
-      const userInfo = crowi.questionnaireService.getUserInfo(user, 'growiurlhashfortest');
+      const userInfo = questionnaireService.getUserInfo(user, 'growiurlhashfortest');
+      expect(userInfo).not.toBeNull();
+      assert(userInfo != null);
+
+      expect(userInfo.type).equal(UserType.general);
+      assert(userInfo.type === UserType.general);
+
       expect(userInfo.userIdHash).toBeTruthy();
       expect(userInfo.userIdHash).not.toBe(user._id);
 
-      delete userInfo.userIdHash;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      delete (userInfo as any).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, '');
+      const userInfo = questionnaireService.getUserInfo(null, '');
       expect(userInfo).toEqual({ type: 'guest' });
     });
   });
 
   describe('getQuestionnaireOrdersToShow', () => {
+    let doc1;
+    let doc2;
+    let doc3;
+    let doc4;
+    let doc5;
+    let doc6;
+    let doc7;
+    let doc8;
+    let doc9;
+    let doc10;
+    let doc11;
+    let doc12;
+
     beforeAll(async() => {
       const questionnaireToBeShown = {
-        _id: '63b8354837e7aa378e16f0b1',
         shortTitle: {
           ja_JP: 'GROWI に関するアンケート',
           en_US: 'Questions about GROWI',
@@ -123,7 +178,7 @@ describe('QuestionnaireService', () => {
           },
           growi: {
             types: ['on-premise'],
-            versionRegExps: [crowi.version],
+            versionRegExps: [appVersion],
           },
         },
         createdAt: '2023-01-01',
@@ -131,62 +186,51 @@ describe('QuestionnaireService', () => {
       };
 
       // 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],
-            },
+      doc1 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert finished data
+      doc2 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        showFrom: '2020-12-11',
+        showUntil: '2021-12-12',
+      });
+      // insert data for admin or guest
+      doc3 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        condition: {
+          user: {
+            types: ['admin', 'guest'],
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [appVersion],
           },
         },
-        // answered
-        {
-          ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b4',
-        },
-        // skipped
-        {
-          ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b5',
-        },
-        // denied
-        {
-          ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b6',
-        },
-        // for different growi type
+      });
+      // insert answered data
+      doc4 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert skipped data
+      doc5 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert denied data
+      doc6 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert data for different growi type
+      doc7 = await QuestionnaireOrder.create(
         {
           ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b7',
           condition: {
             user: {
               types: ['general'],
             },
             growi: {
               types: ['cloud'],
-              versionRegExps: [crowi.version],
+              versionRegExps: [appVersion],
             },
           },
         },
-        // for different growi version
+      );
+      // insert data for different growi version
+      doc8 = await QuestionnaireOrder.create(
         {
           ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b8',
           condition: {
             user: {
               types: ['general'],
@@ -197,10 +241,11 @@ describe('QuestionnaireService', () => {
             },
           },
         },
-        // for users that used GROWI for less than or equal to a year
+      );
+      // insert data for users that used GROWI for less than or equal to a year
+      doc9 = await QuestionnaireOrder.create(
         {
           ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0b9',
           condition: {
             user: {
               types: ['general'],
@@ -210,14 +255,15 @@ describe('QuestionnaireService', () => {
             },
             growi: {
               types: ['on-premise'],
-              versionRegExps: [crowi.version],
+              versionRegExps: [appVersion],
             },
           },
         },
-        // for users that used GROWI for more than or equal to 1000 years
+      );
+      // insert data for users that used GROWI for more than or equal to 1000 years
+      doc10 = await QuestionnaireOrder.create(
         {
           ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0c1',
           condition: {
             user: {
               types: ['general'],
@@ -227,14 +273,15 @@ describe('QuestionnaireService', () => {
             },
             growi: {
               types: ['on-premise'],
-              versionRegExps: [crowi.version],
+              versionRegExps: [appVersion],
             },
           },
         },
-        // for users that used GROWI for more than a month and less than 6 months
+      );
+      // insert data for users that used GROWI for more than a month and less than 6 months
+      doc11 = await QuestionnaireOrder.create(
         {
           ...questionnaireToBeShown,
-          _id: '63b8354837e7aa378e16f0c2',
           condition: {
             user: {
               types: ['general'],
@@ -245,43 +292,39 @@ describe('QuestionnaireService', () => {
             },
             growi: {
               types: ['on-premise'],
-              versionRegExps: [crowi.version],
+              versionRegExps: [appVersion],
             },
           },
         },
-      ]);
+      );
 
       await QuestionnaireAnswerStatus.insertMany([
         {
           user: user._id,
-          questionnaireOrderId: '63b8354837e7aa378e16f0b4',
+          questionnaireOrderId: doc4._id,
           status: StatusType.answered,
         },
         {
           user: user._id,
-          questionnaireOrderId: '63b8354837e7aa378e16f0b5',
+          questionnaireOrderId: doc5._id,
           status: StatusType.skipped,
         },
         {
           user: user._id,
-          questionnaireOrderId: '63b8354837e7aa378e16f0b6',
+          questionnaireOrderId: doc6._id,
           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([
+      const growiInfo = await questionnaireService.getGrowiInfo();
+      const userInfo = questionnaireService.getUserInfo(user, growiInfo.appSiteUrlHashed);
+
+      const questionnaireOrderDocuments = await questionnaireService.getQuestionnaireOrdersToShow(userInfo, growiInfo, user._id);
+
+      expect(questionnaireOrderDocuments[0].toObject()).toMatchObject(
         {
-          _id: '63b8354837e7aa378e16f0b1',
           __v: 0,
           shortTitle: {
             ja_JP: 'GROWI に関するアンケート',
@@ -304,13 +347,16 @@ describe('QuestionnaireService', () => {
             },
             growi: {
               types: ['on-premise'],
-              versionRegExps: [crowi?.version],
+              versionRegExps: [appVersion],
             },
           },
           createdAt: new Date('2023-01-01'),
           updatedAt: new Date('2023-01-01'),
         },
-      ]);
+      );
+
     });
+
   });
+
 });

+ 36 - 33
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -1,20 +1,22 @@
 import crypto from 'crypto';
 import * as os from 'node:os';
 
+
 import type { IUserHasId } from '@growi/core';
+import type { IGrowiInfo, IUser } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
+import type { Model } from 'mongoose';
+import mongoose from 'mongoose';
 
-import { AttachmentMethodType } from '~/interfaces/attachment';
-import { GrowiDeploymentType, GrowiServiceType } from '~/interfaces/system';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+import type Crowi from '~/server/crowi';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-// eslint-disable-next-line import/no-named-as-default
 import { Config } from '~/server/models/config';
 import { aclService } from '~/server/service/acl';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-import type { IGrowiInfo } from '../../interfaces/growi-info';
-import {
-  GrowiWikiType, GrowiExternalAuthProviderType,
-} from '../../interfaces/growi-info';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
@@ -27,15 +29,16 @@ const logger = loggerFactory('growi:service:questionnaire');
 
 class QuestionnaireService {
 
-  crowi: any;
+  crowi: Crowi;
 
   // 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');
+  async getGrowiInfo(): Promise<IGrowiInfo<IGrowiAppAdditionalInfo>> {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const User = mongoose.model<IUser, Model<IUser>>('User');
 
     const appSiteUrl = this.crowi.appService.getSiteUrl();
     const hasher = crypto.createHash('sha256');
@@ -50,27 +53,23 @@ class QuestionnaireService {
     const installedAtByOldestUser = user ? user.createdAt : null;
 
     const appInstalledConfig = await Config.findOne({ key: 'app:installed' });
-    const installedAt = appInstalledConfig != null && appInstalledConfig.createdAt != null ? appInstalledConfig.createdAt : installedAtByOldestUser;
+    const oldestConfig = await Config.findOne().sort({ createdAt: 1 });
+
+    // oldestConfig must not be null because there is at least one config
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installedAt = installedAtByOldestUser ?? appInstalledConfig?.createdAt ?? oldestConfig!.createdAt ?? null;
 
     const currentUsersCount = await User.countDocuments();
-    const currentActiveUsersCount = await User.countActiveUsers();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const currentActiveUsersCount = await (User as any).countActiveUsers();
 
     const isGuestAllowedToRead = aclService.isGuestAllowedToRead();
     const wikiType = isGuestAllowedToRead ? GrowiWikiType.open : GrowiWikiType.closed;
 
-    const activeExternalAccountTypes: GrowiExternalAuthProviderType[] = Object.values(GrowiExternalAuthProviderType).filter((type) => {
-      return this.crowi.configManager.getConfig(`security:passport-${type}:isEnabled`);
+    const activeExternalAccountTypes: IExternalAuthProviderType[] = Object.values(IExternalAuthProviderType).filter((type) => {
+      return configManager.getConfig(`security:passport-${type}:isEnabled`);
     });
 
-    const typeStr = this.crowi.configManager.getConfig('app:serviceType');
-    const type = Object.values(GrowiServiceType).includes(typeStr) ? typeStr : null;
-
-    const attachmentTypeStr = this.crowi.configManager.getConfig('app:fileUploadType');
-    const attachmentType = Object.values(AttachmentMethodType).includes(attachmentTypeStr) ? attachmentTypeStr : null;
-
-    const deploymentTypeStr = this.crowi.configManager.getConfig('app:deploymentType');
-    const deploymentType = Object.values(GrowiDeploymentType).includes(deploymentTypeStr) ? deploymentTypeStr : null;
-
     return {
       version: this.crowi.version,
       osInfo: {
@@ -79,17 +78,19 @@ class QuestionnaireService {
         arch: os.arch(),
         totalmem: os.totalmem(),
       },
-      appSiteUrl: this.crowi.configManager.getConfig('questionnaire:isAppSiteUrlHashed') ? null : appSiteUrl,
+      appSiteUrl: configManager.getConfig('questionnaire:isAppSiteUrlHashed') ? undefined : appSiteUrl,
       appSiteUrlHashed,
-      installedAt,
-      installedAtByOldestUser,
-      type,
-      currentUsersCount,
-      currentActiveUsersCount,
+      type: configManager.getConfig('app:serviceType'),
       wikiType,
-      attachmentType,
-      activeExternalAccountTypes,
-      deploymentType,
+      deploymentType: configManager.getConfig('app:deploymentType'),
+      additionalInfo: {
+        installedAt,
+        installedAtByOldestUser,
+        currentUsersCount,
+        currentActiveUsersCount,
+        attachmentType: configManager.getConfig('app:fileUploadType'),
+        activeExternalAccountTypes,
+      },
     };
   }
 
@@ -108,7 +109,9 @@ class QuestionnaireService {
     return { type: UserType.guest };
   }
 
-  async getQuestionnaireOrdersToShow(userInfo: IUserInfo, growiInfo: IGrowiInfo, userId: ObjectIdLike | null): Promise<QuestionnaireOrderDocument[]> {
+  async getQuestionnaireOrdersToShow(
+      userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>, userId: ObjectIdLike | null,
+  ): Promise<QuestionnaireOrderDocument[]> {
     const currentDate = new Date();
 
     let questionnaireOrders = await QuestionnaireOrder.find({

+ 9 - 6
apps/app/src/features/questionnaire/server/util/condition.ts

@@ -1,7 +1,10 @@
-import { ICondition } from '../../interfaces/condition';
-import { IGrowiInfo } from '../../interfaces/growi-info';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
-import { IUserInfo, UserType } from '../../interfaces/user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { ICondition } from '../../interfaces/condition';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IUserInfo } from '../../interfaces/user-info';
+import { UserType } from '../../interfaces/user-info';
 
 
 const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
@@ -39,7 +42,7 @@ const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
   return true;
 };
 
-const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean => {
+const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { growi: { types, versionRegExps } } = condition;
 
   if (!types.includes(growiInfo.type)) {
@@ -53,7 +56,7 @@ const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean =
   return true;
 };
 
-export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo): boolean => {
+export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { condition } = order;
 
   if (!checkUserInfo(condition, userInfo)) {

+ 83 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts

@@ -0,0 +1,83 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
+import { describe, test, expect } from 'vitest';
+
+import { AttachmentMethodType } from '../../../../interfaces/attachment';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+import { convertToLegacyFormat } from './convert-to-legacy-format';
+
+describe('convertToLegacyFormat', () => {
+  test('should return same object when input is already in legacy format', () => {
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: 'hashedUrl',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+
+      // legacy properties
+      installedAt: new Date(),
+      installedAtByOldestUser: new Date(),
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+
+    const legacyData = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(legacyData);
+    expect(result).toStrictEqual(legacyData);
+  });
+
+  test('should convert new format to legacy format', () => {
+    const growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo> = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: 'hashedUrl',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+
+      additionalInfo: {
+        installedAt: new Date(),
+        installedAtByOldestUser: new Date(),
+        currentUsersCount: 1,
+        currentActiveUsersCount: 1,
+        attachmentType: AttachmentMethodType.local,
+      },
+    };
+    const newFormatData = {
+      someData: 'test',
+      growiInfo,
+    };
+
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: 'hashedUrl',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+
+      // legacy properties
+      installedAt: new Date(),
+      installedAtByOldestUser: new Date(),
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+    const expected = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(newFormatData);
+    expect(result).toStrictEqual(expected);
+  });
+});

+ 31 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts

@@ -0,0 +1,31 @@
+import assert from 'assert';
+
+import type { IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+
+type IHasGrowiAppInfoLegacy<T> = T & {
+  growiInfo: IGrowiAppInfoLegacy;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function isLegacy<T extends { growiInfo: any }>(data: T): data is IHasGrowiAppInfoLegacy<T> {
+  return !('additionalInfo' in data.growiInfo);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function convertToLegacyFormat<T extends { growiInfo: any }>(questionnaireAnswer: T): IHasGrowiAppInfoLegacy<T> {
+  if (isLegacy(questionnaireAnswer)) {
+    return questionnaireAnswer;
+  }
+
+  const { additionalInfo, ...rest } = questionnaireAnswer.growiInfo;
+  assert(additionalInfo != null);
+
+  return {
+    ...questionnaireAnswer,
+    growiInfo: {
+      ...rest,
+      ...additionalInfo,
+    },
+  };
+}

+ 9 - 0
apps/app/src/interfaces/external-auth-provider.ts

@@ -0,0 +1,9 @@
+export const IExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oidc: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]

+ 1 - 1
apps/app/src/pages/login/index.page.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 
-import { IExternalAuthProviderType } from '@growi/core';
 import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -13,6 +12,7 @@ import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { isExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import type { CommonProps } from '~/pages/utils/commons';
 import { getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig } from '~/pages/utils/commons';

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

@@ -76,6 +76,12 @@ class Crowi {
   /** @type {PassportService} */
   passportService;
 
+  /** @type {QuestionnaireService} */
+  questionnaireService;
+
+  /** @type {QuestionnaireCronService} */
+  questionnaireCronService;
+
   /** @type {SearchService} */
   searchService;
 

+ 4 - 5
apps/app/src/server/models/external-account.ts

@@ -1,12 +1,12 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
-import type { IUser } from '@growi/core/dist/interfaces';
-import { type IExternalAccount, type IExternalAccountHasId, type IUserHasId } from '@growi/core/dist/interfaces';
+import type { IUser, IUserHasId, IExternalAccount } from '@growi/core/dist/interfaces';
 import type { Model, Document, HydratedDocument } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +14,7 @@ import { getOrCreateModel } from '../util/mongoose-utils';
 
 const logger = loggerFactory('growi:models:external-account');
 
-
-export interface ExternalAccountDocument extends IExternalAccount, Document {}
+export interface ExternalAccountDocument extends IExternalAccount<IExternalAuthProviderType>, Document {}
 
 export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
   [x:string]: any, // for old methods
@@ -71,7 +70,7 @@ schema.statics.findOrRegister = function(
     usernameToBeRegistered?: string,
     nameToBeRegistered?: string,
     mailToBeRegistered?: string,
-): Promise<IExternalAccountHasId> {
+): Promise<HydratedDocument<IExternalAccount<IExternalAuthProviderType>>> {
   return this.findOne({ providerType, accountId })
     .then((account) => {
     // ExternalAccount is found

+ 2 - 1
apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts

@@ -1,6 +1,7 @@
-import type { IExternalAuthProviderType } from '@growi/core';
 import mongoose from 'mongoose';
 
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
 interface AggregateResult {
   count: number;
 }

+ 3 - 3
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,3 +1,4 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import type { ConfigDefinition, Lang } from '@growi/core/dist/interfaces';
 import { defineConfig } from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
@@ -7,7 +8,6 @@ import { AttachmentMethodType } from '~/interfaces/attachment';
 import type { IPageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
-import { GrowiServiceType } from '~/interfaces/system';
 
 /*
  * Sort order for top level keys:
@@ -478,9 +478,9 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'SERVICE_TYPE',
     defaultValue: GrowiServiceType.onPremise,
   }),
-  'app:deploymentType': defineConfig<string | undefined>({
+  'app:deploymentType': defineConfig<GrowiDeploymentType>({
     envVarName: 'DEPLOYMENT_TYPE',
-    defaultValue: undefined,
+    defaultValue: GrowiDeploymentType.others,
   }),
   'app:ssrMaxRevisionBodyLength': defineConfig<number>({
     envVarName: 'SSR_MAX_REVISION_BODY_LENGTH',

+ 1 - 1
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -1,6 +1,6 @@
 import { mock } from 'vitest-mock-extended';
 
-import { GrowiDeploymentType, GrowiServiceType } from '~/interfaces/system';
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 
 import { Config } from '../../models/config';
 import type { S2sMessagingService } from '../s2s-messaging/base';

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

@@ -1,7 +1,7 @@
+import { GrowiServiceType } from '@growi/core/dist/consts';
 import { envUtils } from '@growi/core/dist/utils';
 import { parseISO } from 'date-fns/parseISO';
 
-import { GrowiServiceType } from '~/interfaces/system';
 import loggerFactory from '~/utils/logger';
 
 import {

+ 1 - 1
apps/app/src/server/service/external-account.ts

@@ -1,7 +1,7 @@
-import type { IExternalAuthProviderType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import loggerFactory from '~/utils/logger';
 
 import { NullUsernameToBeRegisteredError } from '../models/errors';

+ 1 - 1
apps/app/src/server/service/passport.ts

@@ -1,6 +1,5 @@
 import type { IncomingMessage } from 'http';
 
-import type { IExternalAuthProviderType } from '@growi/core';
 import axiosRetry from 'axios-retry';
 import luceneQueryParser from 'lucene-query-parser';
 import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
@@ -14,6 +13,7 @@ import type { Profile, VerifiedCallback } from 'passport-saml';
 import { Strategy as SamlStrategy } from 'passport-saml';
 import urljoin from 'url-join';
 
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';

+ 3 - 2
apps/app/src/stores/personal-settings.tsx

@@ -1,8 +1,9 @@
-import type { IExternalAccountHasId, IExternalAuthProviderType, IUser } from '@growi/core';
+import type { HasObjectId, IExternalAccount, IUser } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import useSWR from 'swr';
 
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { useIsGuestUser } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
@@ -103,7 +104,7 @@ export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUse
   };
 };
 
-export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccountHasId[], Error> => {
+export const useSWRxPersonalExternalAccounts = (): SWRResponse<(IExternalAccount<IExternalAuthProviderType> & HasObjectId)[], Error> => {
   return useSWR(
     '/personal-setting/external-accounts',
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),

+ 72 - 8
apps/app/test/integration/service/questionnaire-cron.test.ts

@@ -1,14 +1,18 @@
+import { GrowiDeploymentType, GrowiServiceType, GrowiWikiType } from '@growi/core';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
 
-import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
-import { IQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/questionnaire-answer';
+import type {
+  IProactiveQuestionnaireAnswer, IProactiveQuestionnaireAnswerLegacy,
+} from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
+import type { IQuestionnaireAnswer, IQuestionnaireAnswerLegacy } 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 { AttachmentMethodType } from '../../../src/interfaces/attachment';
 import { getInstance } from '../setup-crowi';
 
 const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
@@ -139,6 +143,7 @@ describe('QuestionnaireCronService', () => {
   beforeAll(async() => {
     crowi = await getInstance();
     const User = crowi.model('User');
+    User.deleteMany({}); // clear users
     await User.create({
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
@@ -275,13 +280,42 @@ describe('QuestionnaireCronService', () => {
       growiInfo: {
         version: '1.0',
         appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        additionalInfo: {
+          installedAt: new Date('2000-01-01'),
+          installedAtByOldestUser: new Date('2020-01-01'),
+          currentUsersCount: 100,
+          currentActiveUsersCount: 50,
+          attachmentType: AttachmentMethodType.aws,
+        },
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      questionnaireOrder: '63a8354837e7aa378e16f0b1',
+    };
+
+    const validQuestionnaireAnswerLegacy: IQuestionnaireAnswerLegacy = {
+      answers: [{
+        question: '63c6da88143e531d95346188',
+        value: '1',
+      }],
+      answeredAt: new Date(),
+      growiInfo: {
+        version: '1.0',
+        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
         installedAt: new Date('2000-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
-        type: 'cloud',
         currentUsersCount: 100,
         currentActiveUsersCount: 50,
-        wikiType: 'open',
-        attachmentType: 'aws',
+        attachmentType: AttachmentMethodType.aws,
       },
       userInfo: {
         userIdHash: '542bcc3bc5bc61b840017a18',
@@ -295,6 +329,8 @@ describe('QuestionnaireCronService', () => {
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
+      validQuestionnaireAnswerLegacy,
+      validQuestionnaireAnswerLegacy,
     ]);
 
     const validProactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
@@ -303,13 +339,39 @@ describe('QuestionnaireCronService', () => {
       growiInfo: {
         version: '1.0',
         appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        additionalInfo: {
+          installedAt: new Date('2000-01-01'),
+          installedAtByOldestUser: new Date('2020-01-01'),
+          currentUsersCount: 100,
+          currentActiveUsersCount: 50,
+          attachmentType: AttachmentMethodType.aws,
+        },
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      answeredAt: new Date(),
+    };
+    const validProactiveQuestionnaireAnswerLegacy: IProactiveQuestionnaireAnswerLegacy = {
+      satisfaction: 1,
+      commentText: 'answer text',
+      growiInfo: {
+        version: '1.0',
+        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        // legacy properties
         installedAt: new Date('2000-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
-        type: 'cloud',
         currentUsersCount: 100,
         currentActiveUsersCount: 50,
-        wikiType: 'open',
-        attachmentType: 'aws',
+        attachmentType: AttachmentMethodType.aws,
       },
       userInfo: {
         userIdHash: '542bcc3bc5bc61b840017a18',
@@ -323,6 +385,8 @@ describe('QuestionnaireCronService', () => {
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
+      validProactiveQuestionnaireAnswerLegacy,
+      validProactiveQuestionnaireAnswerLegacy,
     ]);
 
     crowi.setupCron();

+ 1 - 0
packages/core/src/consts/index.ts

@@ -1,3 +1,4 @@
 export * from './accepted-upload-file-type';
 export * from './growi-plugin';
+export * from './system';
 export * from './ydoc-status';

+ 0 - 0
apps/app/src/interfaces/system.ts → packages/core/src/consts/system.ts


+ 2 - 15
packages/core/src/interfaces/external-account.ts

@@ -1,21 +1,8 @@
 import type { Ref } from './common';
-import type { HasObjectId } from './has-object-id';
 import type { IUser } from './user';
 
-export const IExternalAuthProviderType = {
-  ldap: 'ldap',
-  saml: 'saml',
-  oidc: 'oidc',
-  google: 'google',
-  github: 'github',
-} as const;
-
-export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]
-
-export type IExternalAccount = {
-  providerType: IExternalAuthProviderType,
+export type IExternalAccount<P> = {
+  providerType: P,
   accountId: string,
   user: Ref<IUser>,
 }
-
-export type IExternalAccountHasId = IExternalAccount & HasObjectId

+ 31 - 0
packages/core/src/interfaces/growi-app-info.ts

@@ -0,0 +1,31 @@
+import type * as os from 'node:os';
+
+import type { GrowiDeploymentType, GrowiServiceType } from '../consts/system';
+
+export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
+type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
+
+interface IGrowiOSInfo {
+  type?: ReturnType<typeof os.type>
+  platform?: ReturnType<typeof os.platform>
+  arch?: ReturnType<typeof os.arch>
+  totalmem?: ReturnType<typeof os.totalmem>
+}
+
+export interface IGrowiAdditionalInfo {
+  installedAt: Date
+  installedAtByOldestUser: Date | null
+  currentUsersCount: number
+  currentActiveUsersCount: number
+}
+
+export interface IGrowiInfo<A extends IGrowiAdditionalInfo> {
+  version: string
+  appSiteUrl?: string
+  appSiteUrlHashed: string
+  type: GrowiServiceType
+  wikiType: GrowiWikiType
+  deploymentType: GrowiDeploymentType
+  osInfo?: IGrowiOSInfo
+  additionalInfo?: A
+}

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

@@ -4,6 +4,7 @@ export * from './color-scheme';
 export * from './config-manager';
 export * from './common';
 export * from './external-account';
+export * from './growi-app-info';
 export * from './growi-facade';
 export * from './growi-theme-metadata';
 export * from './has-object-id';

+ 2 - 3
packages/core/src/interfaces/user.ts

@@ -1,6 +1,5 @@
 import type { IAttachment } from './attachment';
 import type { Ref } from './common';
-import type { IExternalAuthProviderType } from './external-account';
 import type { HasObjectId } from './has-object-id';
 import type { Lang } from './lang';
 
@@ -53,9 +52,9 @@ export type IUserHasId = IUser & HasObjectId;
 export type IUserGroupHasId = IUserGroup & HasObjectId;
 export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
 
-export type IAdminExternalAccount = {
+export type IAdminExternalAccount<P> = {
   _id: string,
-  providerType: IExternalAuthProviderType,
+  providerType: P,
   accountId: string,
   user: IUser,
   createdAt: Date,