Browse Source

Merge pull request #7211 from arafubeatbox/feat/110280-112131-112133-questionnaire-order-save

Feat/110280 112131 112133 questionnaire order save
Haku Mizuki 3 years ago
parent
commit
60c0ce61b1

+ 16 - 0
packages/app/src/interfaces/questionnaire/condition.ts

@@ -0,0 +1,16 @@
+import { GrowiServiceType } from './growi-info';
+import { UserType } from './user-info';
+
+interface UserCondition {
+  types: UserType[] // user types to show questionnaire
+}
+
+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
+}

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

@@ -0,0 +1,48 @@
+import * as os from 'node:os';
+
+export const GrowiServiceType = {
+  cloud: 'cloud',
+  privateCloud: 'private-cloud',
+  onPremise: 'on-premise',
+  others: 'others',
+} as const;
+const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
+const GrowiAttachmentType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  gridfs: 'gridfs',
+  local: 'local',
+  none: 'none',
+} as const;
+const GrowiDeploymentType = {
+  officialHelmChart: 'official-helm-chart',
+  growiDockerCompose: 'growi-docker-compose',
+  node: 'node',
+  others: 'others',
+} as const;
+
+export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
+type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
+type GrowiAttachmentType = typeof GrowiAttachmentType[keyof typeof GrowiAttachmentType]
+type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
+
+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?: string
+  osInfo?: IGrowiOSInfo
+  deploymentType?: GrowiDeploymentType
+}

+ 8 - 0
packages/app/src/interfaces/questionnaire/question.ts

@@ -0,0 +1,8 @@
+export const QuestionType = { points: 'points', text: 'text' } as const;
+
+type QuestionType = typeof QuestionType[keyof typeof QuestionType];
+
+export interface IQuestion {
+  type: QuestionType
+  text: string
+}

+ 9 - 0
packages/app/src/interfaces/questionnaire/questionnaire-order.ts

@@ -0,0 +1,9 @@
+import { ICondition } from './condition';
+import { IQuestion } from './question';
+
+export interface IQuestionnaireOrder {
+  showFrom: Date
+  showUntil: Date
+  questions: IQuestion[]
+  condition: ICondition
+}

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

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

+ 4 - 1
packages/app/src/server/crowi/index.js

@@ -81,6 +81,7 @@ function Crowi() {
   this.activityService = null;
   this.commentService = null;
   this.xss = new Xss();
+  this.questionnaireCronService = null;
 
   this.tokens = null;
 
@@ -305,10 +306,12 @@ Crowi.prototype.setupModels = async function() {
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
   });
+
 };
 
 Crowi.prototype.setupCron = function() {
-  new QuestionnaireCronService(this).setUpCron();
+  this.questionnaireCronService = new QuestionnaireCronService(this);
+  this.questionnaireCronService.startCron();
 };
 
 Crowi.prototype.scanRuntimeVersions = async function() {

+ 26 - 0
packages/app/src/server/models/questionnaire/questionnaire-order.ts

@@ -0,0 +1,26 @@
+import { Model, Schema, Document } from 'mongoose';
+
+import { IQuestionnaireOrder } from '~/interfaces/questionnaire/questionnaire-order';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import conditionSchema from './schema/condition';
+import questionSchema from './schema/question';
+
+export interface QuestionnaireOrderDocument extends IQuestionnaireOrder, Document {}
+
+export type QuestionnaireOrderModel = Model<QuestionnaireOrderDocument>
+
+const questionnaireOrderSchema = new Schema<QuestionnaireOrderDocument>({
+  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);

+ 17 - 0
packages/app/src/server/models/questionnaire/schema/condition.ts

@@ -0,0 +1,17 @@
+import { Schema } from 'mongoose';
+
+import { ICondition } from '~/interfaces/questionnaire/condition';
+import { GrowiServiceType } from '~/interfaces/questionnaire/growi-info';
+import { UserType } from '~/interfaces/questionnaire/user-info';
+
+const conditionSchema = new Schema<ICondition>({
+  user: {
+    types: [{ type: String, enum: Object.values(UserType) }],
+  },
+  growi: {
+    types: [{ type: String, enum: Object.values(GrowiServiceType) }],
+    versionRegExps: [String],
+  },
+});
+
+export default conditionSchema;

+ 10 - 0
packages/app/src/server/models/questionnaire/schema/question.ts

@@ -0,0 +1,10 @@
+import { Schema } from 'mongoose';
+
+import { IQuestion, QuestionType } from '~/interfaces/questionnaire/question';
+
+const questionSchema = new Schema<IQuestion>({
+  type: { type: String, required: true, enum: Object.values(QuestionType) },
+  text: { type: String, required: true },
+}, { timestamps: true });
+
+export default questionSchema;

+ 31 - 8
packages/app/src/server/service/questionnaire-cron.ts

@@ -1,8 +1,14 @@
 import axiosRetry from 'axios-retry';
 
+import { IQuestionnaireOrder } from '~/interfaces/questionnaire/questionnaire-order';
+import loggerFactory from '~/utils/logger';
 import { getRandomIntInRange } from '~/utils/rand';
 import { sleep } from '~/utils/sleep';
 
+import QuestionnaireOrder from '../models/questionnaire/questionnaire-order';
+
+const logger = loggerFactory('growi:service:questionnaire-cron');
+
 const axios = require('axios').default;
 const nodeCron = require('node-cron');
 
@@ -12,35 +18,52 @@ class QuestionnaireCronService {
 
   crowi: any;
 
+  cronJob: any;
+
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(crowi) {
     this.crowi = crowi;
   }
 
-  setUpCron(): void {
+  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.questionnaireOrderGetCron(cronSchedule, maxSecondsUntilRequest);
+    this.cronJob = this.questionnaireOrderGetCron(cronSchedule, maxSecondsUntilRequest);
+    this.cronJob.start();
+  }
+
+  stopCron(): void {
+    this.cronJob.stop();
   }
 
-  questionnaireOrderGetCron(cronSchedule: string, maxSecondsUntilRequest: number): void {
+  private questionnaireOrderGetCron(cronSchedule: string, maxSecondsUntilRequest: number) {
     const growiQuestionnaireServerOrigin = this.crowi.configManager?.getConfig('crowi', 'app:growiQuestionnaireServerOrigin');
-    nodeCron.schedule(cronSchedule, async() => {
-      const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
+    const saveOrders = async(questionnaireOrders: IQuestionnaireOrder[]) => {
+      const currentDate = new Date(Date.now());
+      // save questionnaires that are not finished (doesn't have to be started)
+      const nonFinishedOrders = questionnaireOrders.filter(order => new Date(order.showUntil) > currentDate);
+      await QuestionnaireOrder.insertMany(nonFinishedOrders);
+    };
 
+    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 sleep(secToSleep * 1000);
 
       try {
         const response = await axios.get(`${growiQuestionnaireServerOrigin}/questionnaire-order/index`);
-        console.log(response.data);
+        const questionnaireOrders: IQuestionnaireOrder[] = response.data.questionnaireOrders;
+
+        await QuestionnaireOrder.deleteMany();
+        await saveOrders(questionnaireOrders);
       }
       catch (e) {
-        console.log(e);
+        logger.error(e);
       }
 
-    }).start();
+    });
   }
 
 }

+ 259 - 0
packages/app/test/integration/service/questionnaire-cron.test.ts

@@ -0,0 +1,259 @@
+import QuestionnaireOrder from '../../../src/server/models/questionnaire/questionnaire-order';
+import { getInstance } from '../setup-crowi';
+
+const axios = require('axios').default;
+
+const rand = require('../../../src/utils/rand');
+
+const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
+  axios,
+  'get',
+);
+
+const spyGetRandomIntInRange = jest.spyOn<typeof rand, 'getRandomIntInRange'>(
+  rand,
+  'getRandomIntInRange',
+);
+
+describe('QuestionnaireCronService', () => {
+  let crowi;
+
+  const maxSecondsUntilRequest = 4 * 60 * 60 * 1000;
+  const secondsUntilRequest = rand.getRandomIntInRange(0, maxSecondsUntilRequest);
+
+  const mockResponse = {
+    data: {
+      questionnaireOrders: [
+        // saved in db、not finished (user types is updated from the time it was saved)
+        {
+          _id: '63a8354837e7aa378e16f0b1',
+          showFrom: '2022-12-11',
+          showUntil: '2100-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: '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',
+          showFrom: '2021-12-11',
+          showUntil: '2100-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: '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',
+          showFrom: '2021-12-11',
+          showUntil: '2021-12-12',
+          questions: [
+            {
+              type: 'points',
+              text: '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() => {
+    process.env.QUESTIONNAIRE_CRON_SCHEDULE = '0 22 * * *';
+    process.env.QUESTIONNAIRE_CRON_MAX_HOURS_UNTIL_REQUEST = '4';
+
+    crowi = await getInstance();
+    // reload
+    await crowi.setupConfigManager();
+  });
+
+  beforeEach(async() => {
+    // insert initial db data
+    await QuestionnaireOrder.insertMany([
+      {
+        _id: '63a8354837e7aa378e16f0b1',
+        showFrom: '2022-12-11',
+        showUntil: '2100-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: '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',
+        showFrom: '2020-12-11',
+        showUntil: '2021-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: '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',
+        showFrom: '2020-12-11',
+        showUntil: '2100-12-12',
+        questions: [
+          {
+            type: 'points',
+            text: 'How would you rate the latest design?',
+          },
+        ],
+        condition: {
+          user: {
+            types: ['general'],
+          },
+          growi: {
+            types: ['cloud'],
+            versionRegExps: ['2\\.0\\.[0-9]', '1\\.9\\.[0-9]'],
+          },
+        },
+      },
+    ]);
+
+    // mock the date to 5 seconds before cronjob execution
+    const mockDate = new Date(2022, 0, 1, 21, 59, 55);
+    jest.useFakeTimers();
+    jest.setSystemTime(mockDate);
+
+    // must be after useFakeTimers for mockDate to be in effect
+    crowi.setupCron();
+
+    spyAxiosGet.mockResolvedValue(mockResponse);
+    spyGetRandomIntInRange.mockReturnValue(secondsUntilRequest); // static sleep time until request
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+    crowi.questionnaireCronService.stopCron();
+  });
+
+  test('Should save quesionnaire orders and delete outdated ones', async() => {
+    jest.advanceTimersByTime(5 * 1000); // advance unitl cronjob execution
+    jest.advanceTimersByTime(secondsUntilRequest); // advance until request execution
+    jest.useRealTimers(); // after cronjob starts, undo timer mocks so mongoose can work properly
+
+    await new Promise((resolve) => {
+      // wait until cronjob execution finishes
+      // refs: https://github.com/node-cron/node-cron/blob/a0be3f4a7a5419af109cecf4a41071ea559b9b3d/src/task.js#L24
+      crowi.questionnaireCronService.cronJob._task.once('task-finished', resolve);
+    });
+
+    const savedOrders = await QuestionnaireOrder.find()
+      .select('-condition._id -questions._id')
+      .sort({ _id: 1 });
+    expect(JSON.parse(JSON.stringify(savedOrders))).toEqual([
+      {
+        _id: '63a8354837e7aa378e16f0b1',
+        showFrom: '2022-12-11T00:00:00.000Z',
+        showUntil: '2100-12-12T00:00:00.000Z',
+        questions: [
+          {
+            type: 'points',
+            text: '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',
+        showFrom: '2021-12-11T00:00:00.000Z',
+        showUntil: '2100-12-12T00:00:00.000Z',
+        questions: [
+          {
+            type: 'points',
+            text: '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,
+      },
+    ]);
+  });
+});