Skip to content

Commit

Permalink
feat(datatrakWeb): RN-1338: Mark tasks as completed when survey respo…
Browse files Browse the repository at this point in the history
…nses are submitted (#5766)

* Create migration for created_at column

* Create change handler
  • Loading branch information
alexd-bes authored Jul 11, 2024
1 parent 1e184b2 commit df28a89
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 0 deletions.
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
*/
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.

0 comments on commit df28a89

Please sign in to comment.