diff --git a/packages/central-server/src/index.js b/packages/central-server/src/index.js index a445a81f48..b67208e158 100644 --- a/packages/central-server/src/index.js +++ b/packages/central-server/src/index.js @@ -11,11 +11,11 @@ import { ModelRegistry, SurveyResponseOutdater, TaskCompletionHandler, + TaskCreationHandler, TupaiaDatabase, getDbMigrator, } from '@tupaia/database'; import { isFeatureEnabled } from '@tupaia/utils'; - import { MeditrakSyncQueue } from './database'; import * as modelClasses from './database/models'; import { startSyncWithDhis } from './dhis'; @@ -60,6 +60,10 @@ configureEnv(); const taskCompletionHandler = new TaskCompletionHandler(models); taskCompletionHandler.listenForChanges(); + // Add listener to handle creating tasks when submitting survey responses + const taskCreationHandler = new TaskCreationHandler(models); + taskCreationHandler.listenForChanges(); + /** * Set up actual app with routes etc. */ diff --git a/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js new file mode 100644 index 0000000000..42de37f00b --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js @@ -0,0 +1,184 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { TaskCreationHandler } from '../../changeHandlers'; +import { + buildAndInsertSurvey, + buildAndInsertSurveyResponses, + getTestModels, + upsertDummyRecord, +} from '../../testUtilities'; +import { generateId } from '../../utilities'; + +const userId = generateId(); +const entityId = generateId(); +const entityCode = 'TO'; +const taskSurveyId = generateId(); +const taskSurveyCode = 'TEST_TASK_SURVEY'; + +const buildEntity = async (models, data) => { + return upsertDummyRecord(models.entity, { id: entityId, code: entityCode, ...data }); +}; +const buildTaskCreationSurvey = async (models, config) => { + const survey = { + id: generateId(), + code: generateId(), + questions: [ + { + id: 'TEST_ID_00', + code: 'TEST_CODE_00', + type: 'PrimaryEntity', + }, + { + id: 'TEST_ID_01', + code: 'TEST_CODE_01', + type: 'Binary', + }, + { + id: 'TEST_ID_02', + code: 'TEST_CODE_02', + type: 'Date', + }, + { + id: 'TEST_ID_03', + code: 'TEST_CODE_03', + type: 'FreeText', + }, + { + id: 'TEST_ID_04', + code: 'TEST_CODE_04', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + { + id: 'TEST_ID_05', + code: 'TEST_CODE_05', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + ], + }; + + await Promise.all( + survey.questions.map(q => { + return upsertDummyRecord(models.question, q); + }), + ); + + return buildAndInsertSurvey(models, survey); +}; + +const buildSurveyResponse = async (models, surveyCode, answers) => { + const surveyResponse = { + date: '2024-07-20', + entityCode, + surveyCode, + answers, + }; + + const surveyResponses = await buildAndInsertSurveyResponses(models, [surveyResponse]); + return surveyResponses[0]; +}; + +const TEST_DATA = [ + [ + 'Sets task values based on configured question values', + { + config: { + task: { + surveyCode: taskSurveyCode, + entityId: { questionId: 'TEST_ID_00' }, + shouldCreateTask: { questionId: 'TEST_ID_01' }, + dueDate: { questionId: 'TEST_ID_02' }, + assignee: { questionId: 'TEST_ID_03' }, + }, + }, + answers: { + TEST_CODE_00: entityId, + TEST_CODE_01: true, + TEST_CODE_02: '2024/06/06 00:00:00+00', + TEST_CODE_03: userId, + }, + }, + { + survey_id: taskSurveyId, + due_date: '2024-06-06 00:00:00', + assignee_id: userId, + entity_id: entityId, + }, + ], + [ + 'Handles optional and missing values', + { + config: { + task: { + surveyCode: taskSurveyCode, + entityId: { questionId: 'TEST_ID_00' }, + }, + }, + answers: { + TEST_CODE_00: entityId, + }, + }, + { entity_id: entityId, survey_id: taskSurveyId }, + ], +]; + +describe('TaskCreationHandler', () => { + const models = getTestModels(); + const taskCreationHandler = new TaskCreationHandler(models); + taskCreationHandler.setDebounceTime(50); // short debounce time so tests run more quickly + + beforeAll(async () => { + await buildEntity(models); + await buildAndInsertSurvey(models, { id: taskSurveyId, code: taskSurveyCode }); + await upsertDummyRecord(models.user, { id: userId }); + }); + + beforeEach(async () => { + taskCreationHandler.listenForChanges(); + }); + + afterEach(async () => { + taskCreationHandler.stopListeningForChanges(); + await models.surveyResponse.delete({ survey_id: taskSurveyId }); + }); + + it.each(TEST_DATA)('%s', async (_name, { config, answers = {} }, result) => { + const { survey } = await buildTaskCreationSurvey(models, config); + await buildSurveyResponse(models, survey.code, answers); + await models.database.waitForAllChangeHandlers(); + const tasks = await models.task.find({ entity_id: entityId }, { sort: ['created_at DESC'] }); + + const { survey_id, entity_id, status, due_date, assignee_id, repeat_schedule } = tasks[0]; + + expect({ + survey_id, + entity_id, + assignee_id, + due_date, + status, + repeat_schedule, + }).toMatchObject({ + repeat_schedule: null, + due_date: null, + status: 'to_do', + ...result, + }); + }); + + it('Does not create a task if shouldCreateTask is false', async () => { + const beforeTasks = await models.task.find({ survey_id: taskSurveyId }); + const { survey } = await buildTaskCreationSurvey(models, { shouldCreateTask: 'TEST_01' }); + await buildSurveyResponse(models, survey.code, { TEST_01: false }); + await models.database.waitForAllChangeHandlers(); + const afterTasks = await models.task.find({ survey_id: taskSurveyId }); + expect(beforeTasks.length).toEqual(afterTasks.length); + }); +}); diff --git a/packages/database/src/changeHandlers/TaskCreationHandler.js b/packages/database/src/changeHandlers/TaskCreationHandler.js new file mode 100644 index 0000000000..4b4df305e9 --- /dev/null +++ b/packages/database/src/changeHandlers/TaskCreationHandler.js @@ -0,0 +1,110 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import keyBy from 'lodash.keyby'; +import { ChangeHandler } from './ChangeHandler'; + +const getAnswerWrapper = (config, answers) => { + const answersByQuestionId = keyBy(answers, 'question_id'); + + return questionKey => { + const questionId = config[questionKey]?.questionId; + if (!questionId) { + return null; + } + const answer = answersByQuestionId[questionId]; + return answer?.text; + }; +}; + +const isPrimaryEntityQuestion = (config, questions) => { + const primaryEntityQuestion = questions.find(question => question.type === 'PrimaryEntity'); + const { questionId } = config['entityId']; + return primaryEntityQuestion.id === questionId; +}; + +const getSurveyId = async (models, config) => { + const surveyCode = config.surveyCode; + const survey = await models.survey.findOne({ code: surveyCode }); + return survey.id; +}; + +const getQuestions = (models, surveyId) => { + return models.database.executeSql( + ` + SELECT q.*, ssc.config::json as config + FROM question q + JOIN survey_screen_component ssc ON ssc.question_id = q.id + JOIN survey_screen ss ON ss.id = ssc.screen_id + WHERE ss.survey_id = ?; + `, + [surveyId], + ); +}; + +export class TaskCreationHandler extends ChangeHandler { + constructor(models) { + super(models, 'task-creation-handler'); + + this.changeTranslators = { + surveyResponse: change => this.getNewSurveyResponses(change), + }; + } + + /** + * @private + * Only get the new survey responses that are created, as we only want to create new tasks when a survey response is created, not when it is updated + */ + getNewSurveyResponses(changeDetails) { + const { type, new_record: newRecord, old_record: oldRecord } = changeDetails; + + // if the change is not a create, we don't need to do anything. This is because once a task is marked as complete, it will never be undone + if (type !== 'update' || !!oldRecord) { + return []; + } + return [newRecord]; + } + + async handleChanges(models, changedResponses) { + // if there are no changed responses, we don't need to do anything + if (changedResponses.length === 0) return; + + for (const response of changedResponses) { + const sr = await models.surveyResponse.findById(response.id); + const questions = await getQuestions(models, response.survey_id); + + const taskQuestions = questions.filter(question => question.type === 'Task'); + + if (!taskQuestions) { + continue; + } + + const answers = await sr.getAnswers(); + + for (const taskQuestion of taskQuestions) { + const config = taskQuestion.config.task; + const getAnswer = getAnswerWrapper(config, answers); + + if (!config || getAnswer('shouldCreateTask') === false) { + continue; + } + + // PrimaryEntity question is a special case, where the entity_id is saved against the survey + // response directly rather than the answers + const entityId = isPrimaryEntityQuestion(config, questions) + ? response.entity_id + : getAnswer('entityId'); + const surveyId = await getSurveyId(models, config); + + await models.task.create({ + survey_id: surveyId, + entity_id: entityId, + assignee_id: getAnswer('assignee'), + due_date: getAnswer('dueDate'), + status: 'to_do', + }); + } + } + } +} diff --git a/packages/database/src/changeHandlers/index.js b/packages/database/src/changeHandlers/index.js index 395ebbe044..789e991d2e 100644 --- a/packages/database/src/changeHandlers/index.js +++ b/packages/database/src/changeHandlers/index.js @@ -8,3 +8,4 @@ export { ChangeHandler } from './ChangeHandler'; export { EntityHierarchyCacher } from './entityHierarchyCacher'; export { SurveyResponseOutdater } from './surveyResponseOutdater'; export { TaskCompletionHandler } from './TaskCompletionHandler'; +export { TaskCreationHandler } from './TaskCreationHandler'; diff --git a/packages/database/src/modelClasses/SurveyResponse.js b/packages/database/src/modelClasses/SurveyResponse.js index da58cce8e6..4bffa63794 100644 --- a/packages/database/src/modelClasses/SurveyResponse.js +++ b/packages/database/src/modelClasses/SurveyResponse.js @@ -25,6 +25,10 @@ const INTERNAL_EMAIL = ['@beyondessential.com.au', '@bes.au']; export class SurveyResponseRecord extends DatabaseRecord { static databaseRecord = RECORDS.SURVEY_RESPONSE; + + async getAnswers(conditions = {}) { + return this.otherModels.answer.find({ survey_response_id: this.id, ...conditions }); + } } export class SurveyResponseModel extends MaterializedViewLogDatabaseModel { diff --git a/packages/datatrak-web-server/src/routes/SurveyRoute.ts b/packages/datatrak-web-server/src/routes/SurveyRoute.ts index dbc8ab9714..5b36808ac4 100644 --- a/packages/datatrak-web-server/src/routes/SurveyRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveyRoute.ts @@ -6,7 +6,12 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebSurveyRequest, WebServerProjectRequest } from '@tupaia/types'; +import { + DatatrakWebSurveyRequest, + WebServerProjectRequest, + Question, + QuestionType, +} from '@tupaia/types'; import { PermissionsError } from '@tupaia/utils'; export type SurveyRequest = Request< @@ -116,6 +121,9 @@ export class SurveyRoute extends Route { .sort((a: any, b: any) => a.componentNumber - b.componentNumber), }; }) + // Hide Task questions from the survey. They are not displayed in the web app and are + // just used to trigger new tasks in the TaskCreationHandler + .filter((question: Question) => question.type !== QuestionType.Task) .sort((a: any, b: any) => a.screenNumber - b.screenNumber); // renaming survey_questions to screens to make it make more representative of what it is, since questions is more representative of the component within the screen