From 04ba92249b5b1fce04a06deb499abab945266021 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 8 Jul 2024 09:41:53 +1200 Subject: [PATCH 1/2] Create migration for created_at column --- ...310-AddCreatedAtToTasks-modifies-schema.js | 30 +++++++++++++++++++ packages/types/src/schemas/schemas.ts | 13 ++++++++ packages/types/src/types/models.ts | 3 ++ 3 files changed, 46 insertions(+) create mode 100644 packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js diff --git a/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js b/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js new file mode 100644 index 0000000000..d5d874547d --- /dev/null +++ b/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js @@ -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, +}; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index d8d562a033..9947ced759 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85704,6 +85704,10 @@ export const TaskSchema = { "assignee_id": { "type": "string" }, + "created_at": { + "type": "string", + "format": "date-time" + }, "due_date": { "type": "string", "format": "date-time" @@ -85732,6 +85736,7 @@ export const TaskSchema = { }, "additionalProperties": false, "required": [ + "created_at", "entity_id", "id", "survey_id" @@ -85744,6 +85749,10 @@ export const TaskCreateSchema = { "assignee_id": { "type": "string" }, + "created_at": { + "type": "string", + "format": "date-time" + }, "due_date": { "type": "string", "format": "date-time" @@ -85780,6 +85789,10 @@ export const TaskUpdateSchema = { "assignee_id": { "type": "string" }, + "created_at": { + "type": "string", + "format": "date-time" + }, "due_date": { "type": "string", "format": "date-time" diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index d2cf4857b8..62e6c132f8 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1535,6 +1535,7 @@ export interface SyncGroupLogUpdate { } export interface Task { 'assignee_id'?: string | null; + 'created_at': Date; 'due_date'?: Date | null; 'entity_id': string; 'id': string; @@ -1544,6 +1545,7 @@ export interface Task { } export interface TaskCreate { 'assignee_id'?: string | null; + 'created_at'?: Date; 'due_date'?: Date | null; 'entity_id': string; 'repeat_schedule'?: {} | null; @@ -1552,6 +1554,7 @@ export interface TaskCreate { } export interface TaskUpdate { 'assignee_id'?: string | null; + 'created_at'?: Date; 'due_date'?: Date | null; 'entity_id'?: string; 'id'?: string; From 25f377367cddc048566bfa2b9729d90ee0377417 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 8 Jul 2024 11:18:17 +1200 Subject: [PATCH 2/2] Create change handler --- packages/central-server/src/index.js | 5 + .../TaskCompletionHandler.test.js | 112 ++++++++++++++++++ .../changeHandlers/TaskCompletionHandler.js | 79 ++++++++++++ packages/database/src/changeHandlers/index.js | 1 + 4 files changed, 197 insertions(+) create mode 100644 packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js create mode 100644 packages/database/src/changeHandlers/TaskCompletionHandler.js diff --git a/packages/central-server/src/index.js b/packages/central-server/src/index.js index 40296a5943..a445a81f48 100644 --- a/packages/central-server/src/index.js +++ b/packages/central-server/src/index.js @@ -10,6 +10,7 @@ import { EntityHierarchyCacher, ModelRegistry, SurveyResponseOutdater, + TaskCompletionHandler, TupaiaDatabase, getDbMigrator, } from '@tupaia/database'; @@ -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. */ diff --git a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js new file mode 100644 index 0000000000..770a54b610 --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js @@ -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'); + }); + }); +}); diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js new file mode 100644 index 0000000000..3fe85d2b4a --- /dev/null +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -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' }, + ); + } +} diff --git a/packages/database/src/changeHandlers/index.js b/packages/database/src/changeHandlers/index.js index f364f0a0d9..395ebbe044 100644 --- a/packages/database/src/changeHandlers/index.js +++ b/packages/database/src/changeHandlers/index.js @@ -7,3 +7,4 @@ export { AnalyticsRefresher } from './AnalyticsRefresher'; export { ChangeHandler } from './ChangeHandler'; export { EntityHierarchyCacher } from './entityHierarchyCacher'; export { SurveyResponseOutdater } from './surveyResponseOutdater'; +export { TaskCompletionHandler } from './TaskCompletionHandler';