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(datatrakWeb): RN-1338: Mark tasks as completed when survey responses are submitted #5766

Merged
merged 3 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions packages/central-server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EntityHierarchyCacher,
ModelRegistry,
SurveyResponseOutdater,
TaskCompletionHandler,
TupaiaDatabase,
getDbMigrator,
} from '@tupaia/database';
Expand Down Expand Up @@ -55,6 +56,10 @@ configureEnv();
const surveyResponseOutdater = new SurveyResponseOutdater(models);
surveyResponseOutdater.listenForChanges();

// Add listener to handle survey response changes for tasks
const taskCompletionHandler = new TaskCompletionHandler(models);
taskCompletionHandler.listenForChanges();

/**
* Set up actual app with routes etc.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { TaskCompletionHandler } from '../../changeHandlers';
import {
buildAndInsertSurveys,
findOrCreateDummyRecord,
getTestModels,
populateTestData,
upsertDummyRecord,
} from '../../testUtilities';
import { generateId } from '../../utilities';

const buildSurvey = () => {
const code = 'Test_survey';

return {
id: generateId(),
code,
questions: [{ code: `${code}1`, type: 'Number' }],
};
};

const userId = generateId();

const SURVEY = buildSurvey();

describe('TaskCompletionHandler', () => {
const models = getTestModels();
const taskCompletionHandler = new TaskCompletionHandler(models);
taskCompletionHandler.setDebounceTime(50); // short debounce time so tests run more quickly

const createResponses = async data => {
const { surveyResponses } = await populateTestData(models, {
surveyResponse: data.map(({ date, ...otherFields }) => {
// append time if required
const datetime = date ?? `${date}T12:00:00`.slice(0, 'YYYY-MM-DDThh:mm:ss'.length);

return {
start_time: datetime,
end_time: datetime,
data_time: datetime,
user_id: userId,
survey_id: SURVEY.id,
...otherFields,
};
}),
});

return surveyResponses.map(sr => sr.id);
};

const assertTaskStatus = async (taskId, expectedStatus) => {
await models.database.waitForAllChangeHandlers();
const task = await models.task.findById(taskId);

expect(task.status).toBe(expectedStatus);
};

let tonga;
let task;

beforeAll(async () => {
await buildAndInsertSurveys(models, [SURVEY]);
tonga = await findOrCreateDummyRecord(models.entity, { code: 'TO' });
task = await findOrCreateDummyRecord(models.task, {
entity_id: tonga.id,
survey_id: SURVEY.id,
created_at: '2024-07-08',
status: 'to_do',
due_date: '2024-07-25',
});
await upsertDummyRecord(models.user, { id: userId });
});

beforeEach(async () => {
taskCompletionHandler.listenForChanges();
});

afterEach(async () => {
taskCompletionHandler.stopListeningForChanges();
await models.surveyResponse.delete({ survey_id: SURVEY.id });
await models.task.update({ id: task.id }, { status: 'to_do' });
});

describe('creating a survey response', () => {
it('created response marks associated tasks as completed if created_time < data_time', async () => {
await createResponses([{ entity_id: tonga.id, date: '2024-07-20' }]);
await assertTaskStatus(task.id, 'completed');
});

it('created response marks associated tasks as completed if created_time === data_time', async () => {
await createResponses([{ entity_id: tonga.id, date: '2024-07-08' }]);
await assertTaskStatus(task.id, 'completed');
});

it('created response does not mark associated tasks as completed if created_time > data_time', async () => {
await createResponses([{ entity_id: tonga.id, date: '2021-07-08' }]);
await assertTaskStatus(task.id, 'to_do');
});
});

describe('updating a survey response', () => {
it('updating a survey response does not mark a task as completed', async () => {
await createResponses([{ entity_id: tonga.id, date: '2021-07-20' }]);
await models.surveyResponse.update({ entity_id: tonga.id }, { data_time: '2024-07-25' });
await assertTaskStatus(task.id, 'to_do');
});
});
});
79 changes: 79 additions & 0 deletions packages/database/src/changeHandlers/TaskCompletionHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { getUniqueEntries } from '@tupaia/utils';
import { ChangeHandler } from './ChangeHandler';
import { QUERY_CONJUNCTIONS } from '../TupaiaDatabase';

export class TaskCompletionHandler extends ChangeHandler {
constructor(models) {
super(models, 'task-completion-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
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice comments

*/
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];
}

/**
* @private Fetches all tasks that have the same survey_id and entity_id as the survey responses, and have a created_at date that is less than or equal to the data_time of the survey response
*/
async fetchTaskIdsToUpdate(surveyResponses) {
const surveyIdAndEntityIdPairs = getUniqueEntries(
surveyResponses.map(surveyResponse => ({
surveyId: surveyResponse.survey_id,
entityId: surveyResponse.entity_id,
dataTime: surveyResponse.data_time,
})),
);

const tasks = await this.models.task.find({
// only fetch tasks that have a status of 'to_do'
status: 'to_do',
[QUERY_CONJUNCTIONS.RAW]: {
sql: `${surveyIdAndEntityIdPairs
.map(() => `(survey_id = ? AND entity_id = ? AND created_at <= ?)`)
.join(' OR ')}`,
parameters: surveyIdAndEntityIdPairs.flatMap(({ surveyId, entityId, dataTime }) => [
surveyId,
entityId,
dataTime,
]),
},
});

return tasks.map(task => task.id);
}

async handleChanges(transactingModels, changedResponses) {
// if there are no changed responses, we don't need to do anything
if (changedResponses.length === 0) return;
const taskIdsToUpdate = await this.fetchTaskIdsToUpdate(changedResponses);

// if there are no tasks to update, we don't need to do anything
if (taskIdsToUpdate.length === 0) return;

// update the tasks to be completed
await transactingModels.task.update(
{
id: taskIdsToUpdate,
},
{ status: 'completed' },
);
}
}
1 change: 1 addition & 0 deletions packages/database/src/changeHandlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { AnalyticsRefresher } from './AnalyticsRefresher';
export { ChangeHandler } from './ChangeHandler';
export { EntityHierarchyCacher } from './entityHierarchyCacher';
export { SurveyResponseOutdater } from './surveyResponseOutdater';
export { TaskCompletionHandler } from './TaskCompletionHandler';
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

var dbm;
var type;
var seed;

/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function (options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};

exports.up = function (db) {
return db.runSql(`
ALTER TABLE task
ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now();
`);
};

exports.down = function (db) {
return db.runSql('ALTER TABLE task DROP COLUMN created_at;');
};

exports._meta = {
version: 1,
};
13 changes: 13 additions & 0 deletions packages/types/src/schemas/schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/types/src/types/models.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.