Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datatrak): RN-1373: Task question #5809

Merged
merged 34 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b8d00ae
task creation handler
tcaiger Jul 24, 2024
122af7a
Merge branch 'epic-tasks' into rn-1373-task-question
tcaiger Jul 24, 2024
4de1712
get answers for task questions
tcaiger Jul 25, 2024
0d510c6
handle saving string values
tcaiger Jul 25, 2024
cc5dc3d
Update TaskCreationHandler.test.js
tcaiger Jul 25, 2024
0446647
Update surveyScreenComponent.ts
tcaiger Jul 25, 2024
6be46df
Merge branch 'epic-tasks' into rn-1373-task-question
tcaiger Jul 25, 2024
999ad85
move change handler to central server
tcaiger Jul 26, 2024
704160f
move helpers to central server
tcaiger Jul 26, 2024
efd0f63
handle primary entity question
tcaiger Jul 26, 2024
349bf1a
generate types
tcaiger Jul 26, 2024
6e6fd01
clean up
tcaiger Jul 26, 2024
b176ac9
Update TaskCreationHandler.js
tcaiger Jul 26, 2024
b201138
move back to database
tcaiger Jul 28, 2024
8c22296
fix fe tests
tcaiger Jul 28, 2024
035c8c3
handle success message
tcaiger Jul 29, 2024
9371d83
handle should_create_task
tcaiger Jul 29, 2024
b8fc03c
fix tests
tcaiger Jul 29, 2024
389c576
fix tests
tcaiger Jul 29, 2024
7003c52
remove test code
tcaiger Jul 29, 2024
47c5413
update types
tcaiger Jul 29, 2024
fb705b6
refactor
tcaiger Jul 29, 2024
8a26fb6
Merge branch 'epic-tasks' into rn-1373-task-question
tcaiger Jul 29, 2024
ae96885
Update getTaskQuestionField.ts
tcaiger Jul 29, 2024
2176640
remove toast message
tcaiger Jul 29, 2024
c9ee0f5
hide task questions on the server side
tcaiger Jul 29, 2024
6fef443
Update SurveyRoute.ts
tcaiger Jul 29, 2024
171b9f6
remove survey_response_id
tcaiger Jul 31, 2024
11a6963
fix tests
tcaiger Aug 1, 2024
6a71272
Merge branch 'epic-tasks' into rn-1373-task-question
tcaiger Aug 1, 2024
c360902
update types
tcaiger Aug 1, 2024
8086f24
test multiple task questions
tcaiger Aug 1, 2024
8035794
update types
tcaiger Aug 1, 2024
d0f84c9
Update schemas.ts
tcaiger Aug 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,168 @@
/*
* 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,
},
},
],
};

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);
const { surveyResponse } = await buildSurveyResponse(models, survey.code, answers);
await models.database.waitForAllChangeHandlers();
const task = await models.task.findOne({ survey_response_id: surveyResponse.id });

expect(task).toMatchObject({
repeat_schedule: null,
due_date: null,
survey_response_id: surveyResponse.id,
status: 'to_do',
...result,
});
});

it('Does not create a task if shouldCreateTask is false', async () => {
const { survey } = await buildTaskCreationSurvey(models, { shouldCreateTask: 'TEST_01' });
const { surveyResponse } = await buildSurveyResponse(models, survey.code, { TEST_01: false });
await models.database.waitForAllChangeHandlers();
const task = await models.task.findOne({ survey_response_id: surveyResponse.id });

expect(task).toBeNull();
});
});
107 changes: 107 additions & 0 deletions packages/database/src/changeHandlers/TaskCreationHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 getSurveyCode = async (models, config) => {
tcaiger marked this conversation as resolved.
Show resolved Hide resolved
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 mark tasks as completed when a survey response is created, not when it is updated
tcaiger marked this conversation as resolved.
Show resolved Hide resolved
*/
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 taskQuestion = questions.find(question => question.type === 'Task');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if there were multiple task questions? Technically this is allowed, as there is not validation to stop this happening

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for raising that - I hadn't considered that. I have updated the change handler to handle multiple task questions


if (!taskQuestion) {
continue;
}
const config = taskQuestion.config.task;
const answers = await sr.getAnswers();
const getAnswer = getAnswerWrapper(config, answers);

if (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 getSurveyCode(models, config);

await models.task.create({
survey_id: surveyId,
entity_id: entityId,
assignee_id: getAnswer('assignee'),
due_date: getAnswer('dueDate'),
status: 'to_do',
survey_response_id: response.id,
tcaiger marked this conversation as resolved.
Show resolved Hide resolved
});
}
}
}
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
tcaiger marked this conversation as resolved.
Show resolved Hide resolved
.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
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const TasksSection = () => {
} = useTasks({ projectId, filters, pageSize: 5 });
const tasks = data.tasks;
const showTasksDashboardLink = data.numberOfPages > 1;
const hasTasks = isSuccess && tasks.length > 0;
const hasTasks = isSuccess && tasks?.length > 0;

let Contents: React.ReactNode;
if (isLoading) {
Expand Down
Loading
Loading