Skip to content

Commit

Permalink
feat(datatrak): RN-1373: Task question (#5809)
Browse files Browse the repository at this point in the history
* task creation handler

* get answers for task questions

* handle saving string values

* Update TaskCreationHandler.test.js

* Update surveyScreenComponent.ts

* move change handler to central server

* move helpers to central server

* handle primary entity question

* generate types

* clean up

* Update TaskCreationHandler.js

* move back to database

* fix fe tests

* handle success message

* handle should_create_task

* fix tests

* fix tests

* remove test code

* update types

* refactor

* Update getTaskQuestionField.ts

* remove toast message

* hide task questions on the server side

* Update SurveyRoute.ts

* remove survey_response_id

* fix tests

* update types

* test multiple task questions

* update types

* Update schemas.ts
  • Loading branch information
tcaiger authored Aug 2, 2024
1 parent 703f7ce commit e877115
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 2 deletions.
6 changes: 5 additions & 1 deletion packages/central-server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
110 changes: 110 additions & 0 deletions packages/database/src/changeHandlers/TaskCreationHandler.js
Original file line number Diff line number Diff line change
@@ -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',
});
}
}
}
}
1 change: 1 addition & 0 deletions packages/database/src/changeHandlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { ChangeHandler } from './ChangeHandler';
export { EntityHierarchyCacher } from './entityHierarchyCacher';
export { SurveyResponseOutdater } from './surveyResponseOutdater';
export { TaskCompletionHandler } from './TaskCompletionHandler';
export { TaskCreationHandler } from './TaskCreationHandler';
4 changes: 4 additions & 0 deletions packages/database/src/modelClasses/SurveyResponse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion packages/datatrak-web-server/src/routes/SurveyRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -116,6 +121,9 @@ export class SurveyRoute extends Route<SurveyRequest> {
.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
Expand Down

0 comments on commit e877115

Please sign in to comment.