From c9db4eb7fd543a5648af6383b2d285e0fac14a3d Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 19 Jul 2024 14:04:42 +1200 Subject: [PATCH 01/38] Create task comment table and generate types --- ...50-AddTaskCommentsTable-modifies-schema.js | 73 +++++++++++ packages/types/src/schemas/schemas.ts | 119 ++++++++++++++++++ packages/types/src/types/models.ts | 30 +++++ 3 files changed, 222 insertions(+) create mode 100644 packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js diff --git a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js new file mode 100644 index 0000000000..3741538f5d --- /dev/null +++ b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js @@ -0,0 +1,73 @@ +'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; +}; + +const createTypeEnum = db => { + return db.runSql(` + DROP TYPE IF EXISTS TASK_COMMENT_TYPE; + CREATE TYPE TASK_COMMENT_TYPE AS ENUM('user', 'system'); + + `); +}; + +const createForeignKey = (columnName, table, shouldCascade) => { + const rule = shouldCascade ? 'CASCADE' : 'SET NULL'; + return { + name: `task_${columnName}_fk`, + table, + mapping: 'id', + rules: { + onDelete: rule, + onUpdate: rule, + }, + }; +}; + +exports.up = async function (db) { + await createTypeEnum(db); + await db.createTable('task_comment', { + columns: { + id: { type: 'text', primaryKey: true }, + task_id: { + type: 'text', + notNull: true, + foreignKey: createForeignKey('task_id', 'task', true), + }, + user_id: { + type: 'text', + foreignKey: createForeignKey('user_id', 'user_account', false), + }, + user_name: { type: 'text', notNull: true }, + comment: { type: 'text', notNull: true }, + type: { type: 'TASK_COMMENT_TYPE', notNull: true }, + created_at: { type: 'timestamp', notNull: true }, + }, + ifNotExists: true, + }); + + return db.runSql(` + CREATE INDEX task_comment_task_id_idx ON task_comment USING btree (task_id); + CREATE INDEX task_comment_user_id_idx ON task_comment USING btree (user_id); + `); +}; + +exports.down = async function (db) { + await db.dropTable('task'); + return db.runSql('DROP TYPE TASK_COMMENT_TYPE;'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 4ba5bd293b..167eac7390 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85822,6 +85822,117 @@ export const TaskUpdateSchema = { "additionalProperties": false } +export const TaskCommentSchema = { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "comment", + "created_at", + "id", + "task_id", + "type", + "user_name" + ] +} + +export const TaskCommentCreateSchema = { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "task_id": { + "type": "string" + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "comment", + "created_at", + "task_id", + "type", + "user_name" + ] +} + +export const TaskCommentUpdateSchema = { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false +} + export const TupaiaWebSessionSchema = { "type": "object", "properties": { @@ -86371,6 +86482,14 @@ export const TaskStatusSchema = { "type": "string" } +export const TaskCommentTypeSchema = { + "enum": [ + "system", + "user" + ], + "type": "string" +} + export const SyncGroupSyncStatusSchema = { "enum": [ "ERROR", diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 62e6c132f8..47812a4b86 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1562,6 +1562,32 @@ export interface TaskUpdate { 'status'?: TaskStatus | null; 'survey_id'?: string; } +export interface TaskComment { + 'comment': string; + 'created_at': Date; + 'id': string; + 'task_id': string; + 'type': TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentCreate { + 'comment': string; + 'created_at': Date; + 'task_id': string; + 'type': TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentUpdate { + 'comment'?: string; + 'created_at'?: Date; + 'id'?: string; + 'task_id'?: string; + 'type'?: TaskCommentType; + 'user_id'?: string | null; + 'user_name'?: string; +} export interface TupaiaWebSession { 'access_policy': {}; 'access_token': string; @@ -1699,6 +1725,10 @@ export enum TaskStatus { 'cancelled' = 'cancelled', 'completed' = 'completed', } +export enum TaskCommentType { + 'user' = 'user', + 'system' = 'system', +} export enum SyncGroupSyncStatus { 'IDLE' = 'IDLE', 'SYNCING' = 'SYNCING', From 743a53a946932c0f95d2bc839f6965c9b2bd883b Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 19 Jul 2024 14:11:50 +1200 Subject: [PATCH 02/38] Create Model --- packages/database/src/modelClasses/Task.js | 26 +++++++++++++++++++ .../database/src/modelClasses/TaskComment.js | 18 +++++++++++++ packages/database/src/modelClasses/index.js | 3 +++ packages/database/src/records.js | 1 + 4 files changed, 48 insertions(+) create mode 100644 packages/database/src/modelClasses/TaskComment.js diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 077495a530..ac7da6f0ca 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -30,6 +30,20 @@ export class TaskRecord extends DatabaseRecord { joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`], fields: { name: 'survey_name', code: 'survey_code' }, }, + { + joinWith: RECORDS.TASK_COMMENT, + joinAs: 'comments', + joinType: JOIN_TYPES.LEFT, + joinCondition: ['id', `${RECORDS.TASK_COMMENT}.task_id`], + fields: { + id: 'comment_id', + user_id: 'comment_user_id', + user_name: 'comment_user_name', + comment: 'comment', + type: 'comment_type', + created_at: 'comment_created_at', + }, + }, ]; async entity() { @@ -43,6 +57,18 @@ export class TaskRecord extends DatabaseRecord { async survey() { return this.otherModels.survey.findById(this.survey_id); } + + async comments() { + return this.otherModels.taskComment.find({ task_id: this.id }); + } + + async userComments() { + return this.otherModels.taskComment.find({ task_id: this.id, type: 'user' }); + } + + async systemComments() { + return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); + } } export class TaskModel extends DatabaseModel { diff --git a/packages/database/src/modelClasses/TaskComment.js b/packages/database/src/modelClasses/TaskComment.js new file mode 100644 index 0000000000..2f471f41a8 --- /dev/null +++ b/packages/database/src/modelClasses/TaskComment.js @@ -0,0 +1,18 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { DatabaseModel } from '../DatabaseModel'; +import { DatabaseRecord } from '../DatabaseRecord'; +import { RECORDS } from '../records'; + +export class TaskCommentRecord extends DatabaseRecord { + static databaseRecord = RECORDS.TASK_COMMENT; +} + +export class TaskCommentModel extends DatabaseModel { + get DatabaseRecordClass() { + return TaskCommentRecord; + } +} diff --git a/packages/database/src/modelClasses/index.js b/packages/database/src/modelClasses/index.js index 57e288b195..037f2e1ede 100644 --- a/packages/database/src/modelClasses/index.js +++ b/packages/database/src/modelClasses/index.js @@ -60,6 +60,7 @@ import { DhisInstanceModel } from './DhisInstance'; import { DataElementDataServiceModel } from './DataElementDataService'; import { SupersetInstanceModel } from './SupersetInstance'; import { TaskModel } from './Task'; +import { TaskCommentModel } from './TaskComment'; // export all models to be used in constructing a ModelRegistry export const modelClasses = { @@ -116,6 +117,7 @@ export const modelClasses = { SurveyScreenComponent: SurveyScreenComponentModel, SyncGroupLog: SyncGroupLogModel, Task: TaskModel, + TaskComment: TaskCommentModel, User: UserModel, UserEntityPermission: UserEntityPermissionModel, UserFavouriteDashboardItem: UserFavouriteDashboardItemModel, @@ -184,3 +186,4 @@ export { DashboardRelationRecord, DashboardRelationModel } from './DashboardRela export { OneTimeLoginRecord, OneTimeLoginModel } from './OneTimeLogin'; export { AnswerModel, AnswerRecord } from './Answer'; export { TaskModel, TaskRecord } from './Task'; +export { TaskCommentModel, TaskCommentRecord } from './TaskComment'; diff --git a/packages/database/src/records.js b/packages/database/src/records.js index 1fecc79aa4..fd1ee80900 100644 --- a/packages/database/src/records.js +++ b/packages/database/src/records.js @@ -63,6 +63,7 @@ export const RECORDS = { SURVEY: 'survey', SYNC_GROUP_LOG: 'sync_group_log', TASK: 'task', + TASK_COMMENT: 'task_comment', USER_ACCOUNT: 'user_account', USER_ENTITY_PERMISSION: 'user_entity_permission', USER_FAVOURITE_DASHBOARD_ITEM: 'user_favourite_dashboard_item', From cc3bae3c3e09633d4934dc45a8138c9f8d34f73e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:09:50 +1200 Subject: [PATCH 03/38] WIP --- packages/central-server/src/apiV2/index.js | 3 + .../apiV2/taskComments/CreateTaskComment.js | 35 +++ .../src/apiV2/taskComments/GETTaskComments.js | 29 +++ .../assertTaskCommentPermissions.js | 64 +++++ .../src/apiV2/taskComments/index.js | 7 + .../constructNewRecordValidationRules.js | 9 + .../taskComments/CreateTaskComment.test.js | 153 ++++++++++++ .../taskComments/GETTaskComments.test.js | 224 ++++++++++++++++++ ...50-AddTaskCommentsTable-modifies-schema.js | 4 +- packages/database/src/modelClasses/Task.js | 6 +- 10 files changed, 529 insertions(+), 5 deletions(-) create mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js create mode 100644 packages/central-server/src/apiV2/taskComments/GETTaskComments.js create mode 100644 packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js create mode 100644 packages/central-server/src/apiV2/taskComments/index.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..5cc85ec3a7 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,6 +145,7 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; +import { CreateTaskComment, GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -269,6 +270,7 @@ apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); +apiV2.get('/taskComments/:recordId?', useRouteHandler(GETTaskComments)); /** * POST routes */ @@ -316,6 +318,7 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); +apiV2.post('/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js new file mode 100644 index 0000000000..cb3ab94268 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -0,0 +1,35 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { CreateHandler } from '../CreateHandler'; +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { assertUserHasPermissionToCreateTaskComment } from './assertTaskCommentPermissions'; +/** + * Handles POST endpoints: + * - /taskComments + */ + +export class CreateTaskComment extends CreateHandler { + async assertUserHasAccess() { + const createPermissionChecker = accessPolicy => + assertUserHasPermissionToCreateTaskComment(accessPolicy, this.models, this.newRecordData); + + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), + ); + } + + async createRecord() { + const { id: userId } = this.req.user; + const { task_id: taskId, ...restOfTaskComment } = this.newRecordData; + await this.models.wrapInTransaction(async transactingModels => { + const user = await transactingModels.user.findById(userId); // Check if user exists + if (!user) { + throw new Error(`User with id ${userId} not found`); + } + + const { user_full_name: userFullName } = user; + }); + } +} diff --git a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js new file mode 100644 index 0000000000..092cfecbdc --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js @@ -0,0 +1,29 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { GETHandler } from '../GETHandler'; +import { + assertUserHasCommentPermissions, + createTaskCommentDBFilter, +} from './assertTaskCommentPermissions'; + +export class GETTaskComments extends GETHandler { + permissionsFilteredInternally = true; + + async getPermissionsFilter(criteria, options) { + return createTaskCommentDBFilter(this.accessPolicy, this.models, criteria, options); + } + + async findSingleRecord(commentId, options) { + const taskCommentPermissionChecker = accessPolicy => + assertUserHasCommentPermissions(accessPolicy, this.models, commentId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, taskCommentPermissionChecker]), + ); + + return super.findSingleRecord(commentId, options); + } +} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js new file mode 100644 index 0000000000..5b0b2df67f --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -0,0 +1,64 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { hasBESAdminAccess } from '../../permissions'; +import { + assertUserHasPermissionToCreateTask, + assertUserHasTaskPermissions, + createTaskDBFilter, +} from '../tasks/assertTaskPermissions'; +import { mergeFilter } from '../utilities'; + +export const assertUserHasCommentPermissions = async (accessPolicy, models, commentId) => { + const comment = await models.taskComment.findById(commentId); + if (!comment) { + throw new Error(`No comment found with id ${commentId}`); + } + return assertUserHasTaskPermissions(accessPolicy, models, comment.task_id); +}; + +export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { + if (hasBESAdminAccess(accessPolicy)) { + return { dbConditions: criteria, dbOptions: options }; + } + const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); + + const taskIds = await models.task.find( + { + ...dbConditions, + id: criteria.task_id ?? undefined, + }, + { columns: ['task.id'] }, + ); + + if (!taskIds.length) { + // if the user doesn't have access to any tasks, return a condition that will return no results + return { dbConditions: { id: -1 }, dbOptions: options }; + } + + return { + dbConditions: { + ...criteria, + task_id: { + comparator: 'IN', + comparisonValue: taskIds.map(task => task.id), // this will include any task_id filters because the list of tasks was already filtered by the dbConditions + }, + }, + dbOptions: options, + }; +}; + +export const assertUserHasPermissionToCreateTaskComment = async ( + accessPolicy, + models, + newRecordData, +) => { + const { task_id: taskId } = newRecordData; + const task = await models.task.findById(taskId); + if (!task) { + throw new Error(`No task found with id ${taskId}`); + } + return assertUserHasPermissionToCreateTask(accessPolicy, models, task); +}; diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js new file mode 100644 index 0000000000..e6c87c1220 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { GETTaskComments } from './GETTaskComments'; +export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 9b3ceaf282..d4544219a0 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -490,6 +490,15 @@ export const constructForSingle = (models, recordType) => { }, ], }; + + case RECORDS.TASK_COMMENT: + return { + user_id: [constructRecordExistsWithId(models.user)], + user_name: [hasContent], + task_id: [constructRecordExistsWithId(models.task)], + message: [hasContent, isAString], + type: [constructIsOneOf(['user', 'system'])], + }; default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js new file mode 100644 index 0000000000..f873e6687d --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -0,0 +1,153 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurveys, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for CreateTaskComment', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + TO: ['Donor'], + }; + + const PUBLIC_POLICY = { + DL: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + let tasks; + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + + const BASE_COMMENT = { + comment: 'This is a test comment', + created_at: new Date('2021-12-31'), + }; + + before(async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + + const facilities = [ + { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }, + { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }, + ]; + + await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + + const surveys = await buildAndInsertSurveys(models, [ + { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: BESAdminPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + { + code: 'TEST_SURVEY_2', + name: 'Test Survey 2', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + ]); + + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + tasks = [ + { + id: generateId(), + survey_id: surveys[0].survey.id, + entity_id: facilities[0].id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }, + { + id: generateId(), + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + due_date: null, + repeat_schedule: '{}', + status: null, + }, + ]; + + await Promise.all( + tasks.map(task => + findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ), + ), + ); + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('POST /taskComments', async () => { + it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post('taskComments', { + body: { + ...BASE_TASK, + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + + expect(result.message).to.equal('Successfully created tasks'); + }); + }); +}); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js new file mode 100644 index 0000000000..498bab9036 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -0,0 +1,224 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurveys, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for GETTaskComments', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + TO: ['Donor'], + }; + + const PUBLIC_POLICY = { + DL: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + let tasks; + let comments; + + before(async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + + const facilities = [ + { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }, + { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }, + ]; + + await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + + const surveys = await buildAndInsertSurveys(models, [ + { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: BESAdminPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + { + code: 'TEST_SURVEY_2', + name: 'Test Survey 2', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + ]); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + tasks = [ + { + id: generateId(), + survey_id: surveys[0].survey.id, + entity_id: facilities[0].id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }, + { + id: generateId(), + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + due_date: null, + repeat_schedule: '{}', + status: null, + }, + ]; + + comments = [ + { + id: generateId(), + task_id: tasks[0].id, + user_id: user.id, + user_name: 'Minnie Mouse', + type: 'user', + comment: 'Comment 1', + created_at: new Date('2021-01-01'), + }, + { + id: generateId(), + task_id: tasks[1].id, + user_id: user.id, + user_name: 'Minnie Mouse', + type: 'user', + comment: 'Comment 2', + created_at: new Date('2021-01-02'), + }, + ]; + + await Promise.all( + tasks.map(task => + findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ), + ), + ); + + await Promise.all( + comments.map(comment => + findOrCreateDummyRecord( + models.taskComment, + { + 'task_comment.id': comment.id, + }, + comment, + ), + ), + ); + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('GET /taskComments/:id', async () => { + it('Sufficient permissions: returns a requested task comment when user has BES admin permissions', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.get(`taskComments/${comments[0].id}`); + expect(result.id).to.equal(comments[0].id); + }); + + it('Sufficient permissions: returns a requested task comment when user has permissions', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.get(`taskComments/${comments[1].id}`); + + expect(result.id).to.equal(comments[1].id); + }); + + it('Insufficient permissions: throws an error if requesting comment when user does not have permissions', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.get(`taskComments/${comments[0].id}`); + + expect(result).to.have.keys('error'); + }); + }); + + describe('GET /taskComments', async () => { + it('Sufficient permissions: returns all tasks if the user has BES admin access', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: results } = await app.get('taskComments'); + expect(results.length).to.equal(comments.length); + + results.forEach((result, index) => { + const comment = comments[index]; + expect(result.id).to.equal(comment.id); + }); + }); + + it('Sufficient permissions: returns comments for tasks the user has access to', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: results } = await app.get('taskComments'); + + expect(results.length).to.equal(1); + expect(results[0].id).to.equal(comments[1].id); + }); + + it('Sufficient permissions: handles filtering by task_id', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: results } = await app.get(`taskComments?task_id=${tasks[0].id},${tasks[1].id}`); + + expect(results.length).to.equal(1); + expect(results[0].id).to.equal(comments[1].id); + }); + + it('Insufficient permissions: returns an empty array if users do not have access to any tasks', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: results } = await app.get('taskComments'); + + expect(results).to.be.empty; + }); + }); +}); diff --git a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js index 3741538f5d..8102b4ad2a 100644 --- a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js +++ b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js @@ -50,7 +50,7 @@ exports.up = async function (db) { foreignKey: createForeignKey('user_id', 'user_account', false), }, user_name: { type: 'text', notNull: true }, - comment: { type: 'text', notNull: true }, + message: { type: 'text', notNull: true }, type: { type: 'TASK_COMMENT_TYPE', notNull: true }, created_at: { type: 'timestamp', notNull: true }, }, @@ -64,7 +64,7 @@ exports.up = async function (db) { }; exports.down = async function (db) { - await db.dropTable('task'); + await db.dropTable('task_comment'); return db.runSql('DROP TYPE TASK_COMMENT_TYPE;'); }; diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index ac7da6f0ca..6fcae78cab 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -32,14 +32,14 @@ export class TaskRecord extends DatabaseRecord { }, { joinWith: RECORDS.TASK_COMMENT, - joinAs: 'comments', + joinAs: 'comment', joinType: JOIN_TYPES.LEFT, - joinCondition: ['id', `${RECORDS.TASK_COMMENT}.task_id`], + joinCondition: [`${RECORDS.TASK}.id`, 'comment.task_id'], fields: { id: 'comment_id', user_id: 'comment_user_id', user_name: 'comment_user_name', - comment: 'comment', + message: 'comment_message', type: 'comment_type', created_at: 'comment_created_at', }, From 580a6d4491d7334a6f4d6441ea2c528ec230befa Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:58:24 +1200 Subject: [PATCH 04/38] WIP --- packages/central-server/src/apiV2/index.js | 4 +- .../apiV2/taskComments/CreateTaskComment.js | 17 +- .../src/apiV2/taskComments/GETTaskComments.js | 27 ++- .../assertTaskCommentPermissions.js | 10 - .../taskComments/CreateTaskComment.test.js | 11 +- .../taskComments/GETTaskComments.test.js | 201 +++++------------- ...50-AddTaskCommentsTable-modifies-schema.js | 10 +- packages/database/src/modelClasses/Task.js | 14 -- 8 files changed, 99 insertions(+), 195 deletions(-) diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 5cc85ec3a7..7666fb1d20 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -270,7 +270,7 @@ apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); -apiV2.get('/taskComments/:recordId?', useRouteHandler(GETTaskComments)); +apiV2.get('/tasks/:parentRecordId/taskComments', useRouteHandler(GETTaskComments)); /** * POST routes */ @@ -318,7 +318,7 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); -apiV2.post('/taskComments', useRouteHandler(CreateTaskComment)); +apiV2.post('/tasks/:taskId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js index cb3ab94268..ddef032ba8 100644 --- a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -22,14 +22,15 @@ export class CreateTaskComment extends CreateHandler { async createRecord() { const { id: userId } = this.req.user; - const { task_id: taskId, ...restOfTaskComment } = this.newRecordData; - await this.models.wrapInTransaction(async transactingModels => { - const user = await transactingModels.user.findById(userId); // Check if user exists - if (!user) { - throw new Error(`User with id ${userId} not found`); - } + const user = await this.models.user.findById(userId); // Check if user exists + if (!user) { + throw new Error(`User with id ${userId} not found`); + } - const { user_full_name: userFullName } = user; - }); + const { user_full_name: userFullName } = user; + this.newRecordData.user_id = userId; + this.newRecordData.user_name = userFullName; + + return this.insertRecord(); } } diff --git a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js index 092cfecbdc..e0e4838fd4 100644 --- a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js +++ b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js @@ -5,10 +5,13 @@ import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; import { GETHandler } from '../GETHandler'; -import { - assertUserHasCommentPermissions, - createTaskCommentDBFilter, -} from './assertTaskCommentPermissions'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; +import { createTaskCommentDBFilter } from './assertTaskCommentPermissions'; + +/** + * Handles endpoints: + * - /tasks/:taskId/comments + */ export class GETTaskComments extends GETHandler { permissionsFilteredInternally = true; @@ -17,13 +20,19 @@ export class GETTaskComments extends GETHandler { return createTaskCommentDBFilter(this.accessPolicy, this.models, criteria, options); } - async findSingleRecord(commentId, options) { - const taskCommentPermissionChecker = accessPolicy => - assertUserHasCommentPermissions(accessPolicy, this.models, commentId); + async getPermissionsViaParentFilter(criteria, options) { + const taskPermissionsChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, taskCommentPermissionChecker]), + assertAnyPermissions([assertBESAdminAccess, taskPermissionsChecker]), ); + // Filter by parent + const dbConditions = { 'task_comment.task_id': this.parentRecordId, ...criteria }; - return super.findSingleRecord(commentId, options); + // Apply regular permissions + return { + dbConditions, + dbOptions: options, + }; } } diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js index 5b0b2df67f..e7c2763c04 100644 --- a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -6,18 +6,8 @@ import { hasBESAdminAccess } from '../../permissions'; import { assertUserHasPermissionToCreateTask, - assertUserHasTaskPermissions, createTaskDBFilter, } from '../tasks/assertTaskPermissions'; -import { mergeFilter } from '../utilities'; - -export const assertUserHasCommentPermissions = async (accessPolicy, models, commentId) => { - const comment = await models.taskComment.findById(commentId); - if (!comment) { - throw new Error(`No comment found with id ${commentId}`); - } - return assertUserHasTaskPermissions(accessPolicy, models, comment.task_id); -}; export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { if (hasBESAdminAccess(accessPolicy)) { diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js index f873e6687d..93ac9821be 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -38,8 +38,8 @@ describe('Permissions checker for CreateTaskComment', async () => { }; const BASE_COMMENT = { - comment: 'This is a test comment', - created_at: new Date('2021-12-31'), + message: 'This is a test comment', + type: 'user', }; before(async () => { @@ -141,13 +141,12 @@ describe('Permissions checker for CreateTaskComment', async () => { await app.grantAccess(BES_ADMIN_POLICY); const { body: result } = await app.post('taskComments', { body: { - ...BASE_TASK, - entity_id: facilities[0].id, - survey_id: surveys[0].survey.id, + ...BASE_COMMENT, + task_id: tasks[0].id, }, }); - expect(result.message).to.equal('Successfully created tasks'); + expect(result.message).to.equal('Successfully created task comments'); }); }); }); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js index 498bab9036..f704f97a0c 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { - buildAndInsertSurveys, + buildAndInsertSurvey, findOrCreateDummyCountryEntity, findOrCreateDummyRecord, generateId, @@ -15,22 +15,21 @@ import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; describe('Permissions checker for GETTaskComments', async () => { const BES_ADMIN_POLICY = { - DL: [BES_ADMIN_PERMISSION_GROUP], + TO: [BES_ADMIN_PERMISSION_GROUP], }; const DEFAULT_POLICY = { - DL: ['Donor'], TO: ['Donor'], }; const PUBLIC_POLICY = { - DL: ['Public'], + TO: ['Public'], }; const app = new TestableApp(); const { models } = app; - let tasks; - let comments; + let task; + let comment; before(async () => { const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { @@ -38,49 +37,25 @@ describe('Permissions checker for GETTaskComments', async () => { name: 'Tonga', }); - const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'DL', - name: 'Demo Land', - }); - const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { name: 'Donor', }); - const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Admin', - }); - const facilities = [ - { - id: generateId(), - code: 'TEST_FACILITY_1', - name: 'Test Facility 1', - country_code: tongaCountry.code, - }, - { - id: generateId(), - code: 'TEST_FACILITY_2', - name: 'Test Facility 2', - country_code: dlCountry.code, - }, - ]; + const facility = { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }; - await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + await findOrCreateDummyRecord(models.entity, facility); - const surveys = await buildAndInsertSurveys(models, [ - { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: BESAdminPermission.id, - country_ids: [tongaCountry.id, dlCountry.id], - }, - { - code: 'TEST_SURVEY_2', - name: 'Test Survey 2', - permission_group_id: donorPermission.id, - country_ids: [tongaCountry.id, dlCountry.id], - }, - ]); + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id], + }); const user = { id: generateId(), @@ -91,68 +66,39 @@ describe('Permissions checker for GETTaskComments', async () => { const dueDate = new Date('2021-12-31'); - tasks = [ - { - id: generateId(), - survey_id: surveys[0].survey.id, - entity_id: facilities[0].id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }, - { - id: generateId(), - survey_id: surveys[1].survey.id, - entity_id: facilities[1].id, - due_date: null, - repeat_schedule: '{}', - status: null, - }, - ]; + task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility[0].id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; - comments = [ - { - id: generateId(), - task_id: tasks[0].id, - user_id: user.id, - user_name: 'Minnie Mouse', - type: 'user', - comment: 'Comment 1', - created_at: new Date('2021-01-01'), - }, + comment = { + id: generateId(), + task_id: task.id, + user_id: user.id, + user_name: 'Minnie Mouse', + type: 'user', + message: 'Comment 1', + created_at: new Date('2021-01-01'), + }; + + await findOrCreateDummyRecord( + models.task, { - id: generateId(), - task_id: tasks[1].id, - user_id: user.id, - user_name: 'Minnie Mouse', - type: 'user', - comment: 'Comment 2', - created_at: new Date('2021-01-02'), + 'task.id': task.id, }, - ]; - - await Promise.all( - tasks.map(task => - findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ), - ), + task, ); - await Promise.all( - comments.map(comment => - findOrCreateDummyRecord( - models.taskComment, - { - 'task_comment.id': comment.id, - }, - comment, - ), - ), + await findOrCreateDummyRecord( + models.taskComment, + { + 'task_comment.id': comment.id, + }, + comment, ); }); @@ -164,61 +110,28 @@ describe('Permissions checker for GETTaskComments', async () => { await resetTestData(); }); - describe('GET /taskComments/:id', async () => { - it('Sufficient permissions: returns a requested task comment when user has BES admin permissions', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - const { body: result } = await app.get(`taskComments/${comments[0].id}`); - expect(result.id).to.equal(comments[0].id); - }); - - it('Sufficient permissions: returns a requested task comment when user has permissions', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.get(`taskComments/${comments[1].id}`); - - expect(result.id).to.equal(comments[1].id); - }); - - it('Insufficient permissions: throws an error if requesting comment when user does not have permissions', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.get(`taskComments/${comments[0].id}`); - - expect(result).to.have.keys('error'); - }); - }); - - describe('GET /taskComments', async () => { - it('Sufficient permissions: returns all tasks if the user has BES admin access', async () => { + describe('GET /tasks/:parentRecordId/taskComments', async () => { + it('Sufficient permissions: returns comments if the user has BES admin access', async () => { await app.grantAccess(BES_ADMIN_POLICY); - const { body: results } = await app.get('taskComments'); - expect(results.length).to.equal(comments.length); - - results.forEach((result, index) => { - const comment = comments[index]; - expect(result.id).to.equal(comment.id); - }); - }); - - it('Sufficient permissions: returns comments for tasks the user has access to', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: results } = await app.get('taskComments'); - + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); expect(results.length).to.equal(1); - expect(results[0].id).to.equal(comments[1].id); + + expect(results[0].id).to.equal(comment.id); }); - it('Sufficient permissions: handles filtering by task_id', async () => { + it('Sufficient permissions: returns comments for the task if user has access to it', async () => { await app.grantAccess(DEFAULT_POLICY); - const { body: results } = await app.get(`taskComments?task_id=${tasks[0].id},${tasks[1].id}`); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); expect(results.length).to.equal(1); - expect(results[0].id).to.equal(comments[1].id); + expect(results[0].id).to.equal(comment.id); }); - it('Insufficient permissions: returns an empty array if users do not have access to any tasks', async () => { + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { await app.grantAccess(PUBLIC_POLICY); - const { body: results } = await app.get('taskComments'); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); - expect(results).to.be.empty; + expect(results).to.have.keys('error'); }); }); }); diff --git a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js index 8102b4ad2a..9f51d1c45c 100644 --- a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js +++ b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js @@ -51,13 +51,19 @@ exports.up = async function (db) { }, user_name: { type: 'text', notNull: true }, message: { type: 'text', notNull: true }, - type: { type: 'TASK_COMMENT_TYPE', notNull: true }, - created_at: { type: 'timestamp', notNull: true }, + type: { type: 'TASK_COMMENT_TYPE', notNull: true, defaultValue: 'user' }, + created_at: { + type: 'timestamp', + notNull: true, + }, }, ifNotExists: true, }); return db.runSql(` + ALTER TABLE task_comment + ALTER COLUMN created_at SET DEFAULT now(); + CREATE INDEX task_comment_task_id_idx ON task_comment USING btree (task_id); CREATE INDEX task_comment_user_id_idx ON task_comment USING btree (user_id); `); diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 6fcae78cab..332f2df968 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -30,20 +30,6 @@ export class TaskRecord extends DatabaseRecord { joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`], fields: { name: 'survey_name', code: 'survey_code' }, }, - { - joinWith: RECORDS.TASK_COMMENT, - joinAs: 'comment', - joinType: JOIN_TYPES.LEFT, - joinCondition: [`${RECORDS.TASK}.id`, 'comment.task_id'], - fields: { - id: 'comment_id', - user_id: 'comment_user_id', - user_name: 'comment_user_name', - message: 'comment_message', - type: 'comment_type', - created_at: 'comment_created_at', - }, - }, ]; async entity() { From 54f00c1f8e5e17354ce7f199466df67cff1de85b Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 08:46:53 +1200 Subject: [PATCH 05/38] Working endpoints --- packages/central-server/src/apiV2/index.js | 2 +- .../apiV2/taskComments/CreateTaskComment.js | 13 +- .../assertTaskCommentPermissions.js | 18 +- .../constructNewRecordValidationRules.js | 15 +- .../taskComments/CreateTaskComment.test.js | 167 +++++++++--------- .../taskComments/GETTaskComments.test.js | 22 ++- 6 files changed, 121 insertions(+), 116 deletions(-) diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 7666fb1d20..1535c0158a 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -318,7 +318,7 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); -apiV2.post('/tasks/:taskId/taskComments', useRouteHandler(CreateTaskComment)); +apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js index ddef032ba8..d131fec8ee 100644 --- a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -2,18 +2,22 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import { RECORDS } from '@tupaia/database'; import { CreateHandler } from '../CreateHandler'; import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; -import { assertUserHasPermissionToCreateTaskComment } from './assertTaskCommentPermissions'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; + /** * Handles POST endpoints: - * - /taskComments + * - /tasks/:parentRecordId/taskComments */ export class CreateTaskComment extends CreateHandler { + parentRecordType = RECORDS.TASK; + async assertUserHasAccess() { const createPermissionChecker = accessPolicy => - assertUserHasPermissionToCreateTaskComment(accessPolicy, this.models, this.newRecordData); + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); await this.assertPermissions( assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), @@ -27,9 +31,10 @@ export class CreateTaskComment extends CreateHandler { throw new Error(`User with id ${userId} not found`); } - const { user_full_name: userFullName } = user; + const { full_name: userFullName } = user; this.newRecordData.user_id = userId; this.newRecordData.user_name = userFullName; + this.newRecordData.task_id = this.parentRecordId; return this.insertRecord(); } diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js index e7c2763c04..3136b4f049 100644 --- a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -4,10 +4,7 @@ */ import { hasBESAdminAccess } from '../../permissions'; -import { - assertUserHasPermissionToCreateTask, - createTaskDBFilter, -} from '../tasks/assertTaskPermissions'; +import { createTaskDBFilter } from '../tasks/assertTaskPermissions'; export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { if (hasBESAdminAccess(accessPolicy)) { @@ -39,16 +36,3 @@ export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, dbOptions: options, }; }; - -export const assertUserHasPermissionToCreateTaskComment = async ( - accessPolicy, - models, - newRecordData, -) => { - const { task_id: taskId } = newRecordData; - const task = await models.task.findById(taskId); - if (!task) { - throw new Error(`No task found with id ${taskId}`); - } - return assertUserHasPermissionToCreateTask(accessPolicy, models, task); -}; diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index d4544219a0..62527c0119 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -30,7 +30,7 @@ import { DATA_SOURCE_SERVICE_TYPES } from '../../database/models/DataElement'; export const constructForParent = (models, recordType, parentRecordType) => { const combinedRecordType = `${parentRecordType}/${recordType}`; - const { SURVEY_RESPONSE, COMMENT } = RECORDS; + const { SURVEY_RESPONSE, COMMENT, TASK, TASK_COMMENT } = RECORDS; switch (combinedRecordType) { case `${SURVEY_RESPONSE}/${COMMENT}`: @@ -39,6 +39,11 @@ export const constructForParent = (models, recordType, parentRecordType) => { user_id: [constructRecordExistsWithId(models.user)], text: [hasContent], }; + case `${TASK}/${TASK_COMMENT}`: + return { + message: [hasContent, isAString], + type: [constructIsOneOf(['user', 'system'])], + }; default: throw new ValidationError( `${parentRecordType}/[${parentRecordType}Id]/${recordType} is not a valid POST endpoint`, @@ -491,14 +496,6 @@ export const constructForSingle = (models, recordType) => { ], }; - case RECORDS.TASK_COMMENT: - return { - user_id: [constructRecordExistsWithId(models.user)], - user_name: [hasContent], - task_id: [constructRecordExistsWithId(models.task)], - message: [hasContent, isAString], - type: [constructIsOneOf(['user', 'system'])], - }; default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js index 93ac9821be..3d8f83b49d 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { - buildAndInsertSurveys, + buildAndInsertSurvey, findOrCreateDummyCountryEntity, findOrCreateDummyRecord, generateId, @@ -20,7 +20,6 @@ describe('Permissions checker for CreateTaskComment', async () => { const DEFAULT_POLICY = { DL: ['Donor'], - TO: ['Donor'], }; const PUBLIC_POLICY = { @@ -29,25 +28,13 @@ describe('Permissions checker for CreateTaskComment', async () => { const app = new TestableApp(); const { models } = app; - let tasks; - - const user = { - id: generateId(), - first_name: 'Minnie', - last_name: 'Mouse', - }; const BASE_COMMENT = { message: 'This is a test comment', type: 'user', }; - before(async () => { - const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'TO', - name: 'Tonga', - }); - + const generateData = async () => { const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { code: 'DL', name: 'Demo Land', @@ -56,76 +43,61 @@ describe('Permissions checker for CreateTaskComment', async () => { const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { name: 'Donor', }); - const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Admin', - }); - const facilities = [ - { - id: generateId(), - code: 'TEST_FACILITY_1', - name: 'Test Facility 1', - country_code: tongaCountry.code, - }, - { - id: generateId(), - code: 'TEST_FACILITY_2', - name: 'Test Facility 2', - country_code: dlCountry.code, - }, - ]; + const facility = { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }; - await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + await findOrCreateDummyRecord(models.entity, facility); - const surveys = await buildAndInsertSurveys(models, [ - { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: BESAdminPermission.id, - country_ids: [tongaCountry.id, dlCountry.id], - }, - { - code: 'TEST_SURVEY_2', - name: 'Test Survey 2', - permission_group_id: donorPermission.id, - country_ids: [tongaCountry.id, dlCountry.id], - }, - ]); + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [dlCountry.id], + }); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; await findOrCreateDummyRecord(models.user, user); const dueDate = new Date('2021-12-31'); - tasks = [ - { - id: generateId(), - survey_id: surveys[0].survey.id, - entity_id: facilities[0].id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }, + const task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility.id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; + + await findOrCreateDummyRecord( + models.task, { - id: generateId(), - survey_id: surveys[1].survey.id, - entity_id: facilities[1].id, - due_date: null, - repeat_schedule: '{}', - status: null, + 'task.id': task.id, }, - ]; - - await Promise.all( - tasks.map(task => - findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ), - ), + task, ); + + return { + task, + user, + }; + }; + + let task; + + before(async () => { + const data = await generateData(); + task = data.task; }); afterEach(() => { @@ -139,14 +111,49 @@ describe('Permissions checker for CreateTaskComment', async () => { describe('POST /taskComments', async () => { it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { await app.grantAccess(BES_ADMIN_POLICY); - const { body: result } = await app.post('taskComments', { - body: { - ...BASE_COMMENT, - task_id: tasks[0].id, - }, + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, }); - expect(result.message).to.equal('Successfully created task comments'); + expect(result).to.have.keys('error'); }); }); }); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js index f704f97a0c..cf746c1d16 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -28,10 +28,8 @@ describe('Permissions checker for GETTaskComments', async () => { const app = new TestableApp(); const { models } = app; - let task; - let comment; - before(async () => { + const generateData = async () => { const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { code: 'TO', name: 'Tonga', @@ -66,7 +64,7 @@ describe('Permissions checker for GETTaskComments', async () => { const dueDate = new Date('2021-12-31'); - task = { + const task = { id: generateId(), survey_id: survey.id, entity_id: facility[0].id, @@ -75,7 +73,7 @@ describe('Permissions checker for GETTaskComments', async () => { repeat_schedule: null, }; - comment = { + const comment = { id: generateId(), task_id: task.id, user_id: user.id, @@ -100,6 +98,20 @@ describe('Permissions checker for GETTaskComments', async () => { }, comment, ); + return { + task, + user, + comment, + }; + }; + + let task; + let comment; + + before(async () => { + const { task: createdTask, comment: createdComment } = await generateData(); + task = createdTask; + comment = createdComment; }); afterEach(() => { From 07e162d29ac89e4f9d8023fa4ffee82dab536ef1 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 08:52:15 +1200 Subject: [PATCH 06/38] Generate types --- packages/types/src/schemas/schemas.ts | 24 +++++++++++------------- packages/types/src/types/models.ts | 10 +++++----- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 167eac7390..613d46e9bd 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85825,9 +85825,6 @@ export const TaskUpdateSchema = { export const TaskCommentSchema = { "type": "object", "properties": { - "comment": { - "type": "string" - }, "created_at": { "type": "string", "format": "date-time" @@ -85835,6 +85832,9 @@ export const TaskCommentSchema = { "id": { "type": "string" }, + "message": { + "type": "string" + }, "task_id": { "type": "string" }, @@ -85854,9 +85854,9 @@ export const TaskCommentSchema = { }, "additionalProperties": false, "required": [ - "comment", "created_at", "id", + "message", "task_id", "type", "user_name" @@ -85866,13 +85866,13 @@ export const TaskCommentSchema = { export const TaskCommentCreateSchema = { "type": "object", "properties": { - "comment": { - "type": "string" - }, "created_at": { "type": "string", "format": "date-time" }, + "message": { + "type": "string" + }, "task_id": { "type": "string" }, @@ -85892,10 +85892,8 @@ export const TaskCommentCreateSchema = { }, "additionalProperties": false, "required": [ - "comment", - "created_at", + "message", "task_id", - "type", "user_name" ] } @@ -85903,9 +85901,6 @@ export const TaskCommentCreateSchema = { export const TaskCommentUpdateSchema = { "type": "object", "properties": { - "comment": { - "type": "string" - }, "created_at": { "type": "string", "format": "date-time" @@ -85913,6 +85908,9 @@ export const TaskCommentUpdateSchema = { "id": { "type": "string" }, + "message": { + "type": "string" + }, "task_id": { "type": "string" }, diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 47812a4b86..29b7084573 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1563,26 +1563,26 @@ export interface TaskUpdate { 'survey_id'?: string; } export interface TaskComment { - 'comment': string; 'created_at': Date; 'id': string; + 'message': string; 'task_id': string; 'type': TaskCommentType; 'user_id'?: string | null; 'user_name': string; } export interface TaskCommentCreate { - 'comment': string; - 'created_at': Date; + 'created_at'?: Date; + 'message': string; 'task_id': string; - 'type': TaskCommentType; + 'type'?: TaskCommentType; 'user_id'?: string | null; 'user_name': string; } export interface TaskCommentUpdate { - 'comment'?: string; 'created_at'?: Date; 'id'?: string; + 'message'?: string; 'task_id'?: string; 'type'?: TaskCommentType; 'user_id'?: string | null; From fb63c4f21e8c54a0ea9cd2671d07a22c364b057f Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:13:39 +1200 Subject: [PATCH 07/38] Fix test --- .../src/tests/apiV2/taskComments/GETTaskComments.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js index cf746c1d16..7b3be0dfc8 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -67,7 +67,7 @@ describe('Permissions checker for GETTaskComments', async () => { const task = { id: generateId(), survey_id: survey.id, - entity_id: facility[0].id, + entity_id: facility.id, due_date: dueDate, status: 'to_do', repeat_schedule: null, From 307570fac90cdfe3b06dfd28220705c1bb30fe8d Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:20:12 +1200 Subject: [PATCH 08/38] Create task with comments --- .../src/apiV2/tasks/CreateTask.js | 6 +++++- .../src/routes/CreateTaskRoute.ts | 21 ++++++++++++++++--- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 4 ++-- .../datatrak-web-server/TaskChangeRequest.ts | 5 ++++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index d1eda45751..c018d50508 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,6 +21,10 @@ export class CreateTask extends CreateHandler { } async createRecord() { - await this.insertRecord(); + const { models } = this; + const task = await models.task.create(this.newRecordData); + return { + id: task.id, + }; } } diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts index 63a9dce5a0..940c1c49ef 100644 --- a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebTaskChangeRequest, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTaskChangeRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; import { formatTaskChanges } from '../utils'; export type CreateTaskRequest = Request< @@ -19,7 +19,7 @@ export class CreateTaskRoute extends Route { public async buildResponse() { const { models, body, ctx } = this.req; - const { surveyCode, entityId } = body; + const { surveyCode, entityId, comment } = body; const survey = await models.survey.findOne({ code: surveyCode }); if (!survey) { @@ -36,6 +36,21 @@ export class CreateTaskRoute extends Route { taskDetails.status = TaskStatus.to_do; } - return ctx.services.central.createResource('tasks', {}, taskDetails); + const task = await ctx.services.central.createResource('tasks', {}, taskDetails); + + if (comment) { + await ctx.services.central.createResource( + `tasks/${task.id}/taskComments`, + {}, + { + message: comment, + type: TaskCommentType.user, + }, + ); + } + + return { + message: 'Task created successfully', + }; } } diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 1ae661cb7b..44316faf56 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -119,6 +119,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { control, setValue, watch, + register, formState: { isValid, dirtyFields }, } = formContext; @@ -287,8 +288,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { /> - {/** This is a placeholder for when we add in comments functionality */} - + diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts index d5d440241b..d6551b6348 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts @@ -6,7 +6,9 @@ import { Entity, Survey, UserAccount } from '../../models'; export type Params = Record; -export type ResBody = Record; +export type ResBody = { + message: string; +}; export type ReqQuery = Record; export type ReqBody = { assigneeId?: UserAccount['id']; @@ -14,4 +16,5 @@ export type ReqBody = { entityId: Entity['id']; repeatSchedule?: string; surveyCode: Survey['code']; + comment?: string; }; From 8daeb386157bc19bcc790691b9f2f22c835634a2 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:37:47 +1200 Subject: [PATCH 09/38] Update 20240719015050-AddTaskCommentsTable-modifies-schema.js --- .../20240719015050-AddTaskCommentsTable-modifies-schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js index 9f51d1c45c..75395074c5 100644 --- a/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js +++ b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js @@ -53,7 +53,7 @@ exports.up = async function (db) { message: { type: 'text', notNull: true }, type: { type: 'TASK_COMMENT_TYPE', notNull: true, defaultValue: 'user' }, created_at: { - type: 'timestamp', + type: 'timestamp with time zone', notNull: true, }, }, From 912095b7e24d9c59f7463d9eea3774ec1b482859 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 10:22:31 +1200 Subject: [PATCH 10/38] View comments on task details --- .../src/routes/TaskRoute.ts | 7 ++- .../src/utils/formatTaskResponse.ts | 5 +- .../Tasks/TaskDetails/TaskComments.tsx | 59 +++++++++++++++++++ .../Tasks/TaskDetails/TaskDetails.tsx | 21 ++----- packages/datatrak-web/src/utils/date.ts | 12 ++++ .../datatrak-web-server/TaskRequest.ts | 10 +++- 6 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx diff --git a/packages/datatrak-web-server/src/routes/TaskRoute.ts b/packages/datatrak-web-server/src/routes/TaskRoute.ts index 3c4ee61a53..9bcf9f2437 100644 --- a/packages/datatrak-web-server/src/routes/TaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/TaskRoute.ts @@ -42,6 +42,11 @@ export class TaskRoute extends Route { throw new Error(`Task with id ${taskId} not found`); } - return formatTaskResponse(task); + const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`); + + return formatTaskResponse({ + ...task, + comments, + }); } } diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 71397a850c..84b597559c 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -12,6 +12,7 @@ export type TaskT = Omit & { 'survey.code': Survey['code']; 'survey.name': Survey['name']; task_status: DatatrakWebTaskRequest.ResBody['taskStatus']; + comments: DatatrakWebTaskRequest.ResBody['comments']; }; export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody => { @@ -41,5 +42,7 @@ export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody }, }; - return camelcaseKeys(formattedTask) as DatatrakWebTaskRequest.ResBody; + return camelcaseKeys(formattedTask, { + deep: true, + }) as DatatrakWebTaskRequest.ResBody; }; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx new file mode 100644 index 0000000000..81d6bcd9d7 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -0,0 +1,59 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; +import { Task } from '../../../types'; +import { displayDateTime } from '../../../utils'; + +const Wrapper = styled.div` + width: 100%; + border: 1px solid ${({ theme }) => theme.palette.divider}; + background-color: ${({ theme }) => theme.palette.background.default}; + margin-block-end: 1.2rem; + padding: 1rem; + border-radius: 4px; + overflow-y: auto; + height: 19rem; +`; + +const CommentContainer = styled.div` + padding-block: 0.4rem; + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } +`; + +const Message = styled(Typography).attrs({ + variant: 'body2', + color: 'textPrimary', +})` + margin-block-start: 0.2rem; +`; + +type Comments = Task['comments']; + +const SingleComment = ({ comment }: { comment: Comments[0] }) => { + const { createdAt, userName, message } = comment; + return ( + + + {displayDateTime(createdAt)} - {userName} + + {message} + + ); +}; + +export const TaskComments = ({ comments }: { comments: Comments }) => { + return ( + + {comments.map((comment, index) => ( + + ))} + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx index d4433feb11..7729fd9359 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -20,6 +20,7 @@ import { TaskForm } from '../TaskForm'; import { ROUTES } from '../../../constants'; import { Task } from '../../../types'; import { TaskMetadata } from './TaskMetadata'; +import { TaskComments } from './TaskComments'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -37,6 +38,7 @@ const Container = styled(Paper).attrs({ const MainColumn = styled.div` display: flex; flex-direction: column; + justify-content: space-between; flex: 1; margin-block: 1.2rem; ${({ theme }) => theme.breakpoints.up('md')} { @@ -48,6 +50,7 @@ const MainColumn = styled.div` const SideColumn = styled.div` display: flex; flex-direction: column; + justify-content: space-between; ${({ theme }) => theme.breakpoints.up('md')} { width: 28%; } @@ -59,18 +62,6 @@ const ItemWrapper = styled.div` } `; -const CommentsPlaceholder = styled(ItemWrapper)` - height: 10rem; - width: 100%; - border: 1px solid ${({ theme }) => theme.palette.divider}; - background-color: ${({ theme }) => theme.palette.background.default}; - border-radius: 4px; - ${({ theme }) => theme.breakpoints.up('md')} { - flex: 1; - height: 100%; - } -`; - const CommentsInput = styled(TextField).attrs({ multiline: true, variant: 'outlined', @@ -119,6 +110,7 @@ export const TaskDetails = ({ task }: { task: Task }) => { control, handleSubmit, watch, + register, formState: { isValid, dirtyFields }, reset, } = formContext; @@ -204,9 +196,8 @@ export const TaskDetails = ({ task }: { task: Task }) => { - - {/** This is a placeholder for when we add in comments functionality */} - + + diff --git a/packages/datatrak-web/src/utils/date.ts b/packages/datatrak-web/src/utils/date.ts index d431e8c1e2..3b682c77ca 100644 --- a/packages/datatrak-web/src/utils/date.ts +++ b/packages/datatrak-web/src/utils/date.ts @@ -3,9 +3,21 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ +import { format } from 'date-fns'; + export const displayDate = (date?: Date | null) => { if (!date) { return ''; } return new Date(date).toLocaleDateString(); }; + +export const displayDateTime = (date?: Date | null) => { + if (!date) { + return ''; + } + + const dateDisplay = displayDate(date); + const timeDisplay = format(new Date(date), 'p'); + return `${dateDisplay} ${timeDisplay}`; +}; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts index f6edb85d68..27fefdc854 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts @@ -3,12 +3,20 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import { KeysToCamelCase } from '../../../utils'; +import { TaskComment } from '../../models'; import { TaskResponse } from './TasksRequest'; export type Params = { taskId: string; }; -export type ResBody = TaskResponse; +type Comment = Omit, 'createdAt'> & { + // handle the fact that KeysToCamelCase changes Date keys to to camelCase as well + createdAt: Date; +}; +export type ResBody = TaskResponse & { + comments: Comment[]; +}; export type ReqBody = Record; export type ReqQuery = Record; From 45cd9dc4a18c6b6ef4e13e1c3388af01a318e926 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 10:22:44 +1200 Subject: [PATCH 11/38] Allow create comment on edit task --- .../src/routes/EditTaskRoute.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index 1336129309..21939be094 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -10,11 +10,11 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { formatTaskChanges } from '../utils'; -import { DatatrakWebTaskChangeRequest } from '@tupaia/types'; +import { DatatrakWebTaskChangeRequest, TaskCommentType } from '@tupaia/types'; export type EditTaskRequest = Request< { taskId: string }, - Record, + { message: string }, Partial, Record >; @@ -24,9 +24,25 @@ export class EditTaskRoute extends Route { const { body, ctx, params } = this.req; const { taskId } = params; + const { comment, ...rest } = body; - const taskDetails = formatTaskChanges(body); + const taskDetails = formatTaskChanges(rest); - return ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); + await ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); + + if (comment) { + await ctx.services.central.createResource( + `tasks/${taskId}/taskComments`, + {}, + { + message: comment, + type: TaskCommentType.user, + }, + ); + } + + return { + message: 'Task updated successfully', + }; } } From bc2afa9da0e84201090d216b932409d2e9e2ebcd Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:11:18 +1200 Subject: [PATCH 12/38] Display comments count on table --- .../src/routes/TasksRoute.ts | 26 ++++++---- packages/datatrak-web-server/src/types.ts | 2 + .../src/utils/formatTaskResponse.ts | 2 +- .../src/components/Icons/CommentIcon.tsx | 26 ++++++++++ .../src/components/Icons/index.ts | 1 + .../features/Tasks/TasksTable/TasksTable.tsx | 50 ++++++++++++++++++- .../src/models/TaskComment.ts | 14 ++++++ .../server-boilerplate/src/models/index.ts | 1 + .../datatrak-web-server/TasksRequest.ts | 5 +- 9 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 packages/datatrak-web/src/components/Icons/CommentIcon.tsx create mode 100644 packages/server-boilerplate/src/models/TaskComment.ts diff --git a/packages/datatrak-web-server/src/routes/TasksRoute.ts b/packages/datatrak-web-server/src/routes/TasksRoute.ts index bacddce998..aa7ca0cce8 100644 --- a/packages/datatrak-web-server/src/routes/TasksRoute.ts +++ b/packages/datatrak-web-server/src/routes/TasksRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { parse } from 'cookie'; -import { DatatrakWebTasksRequest, Task, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTasksRequest, Task, TaskCommentType, TaskStatus } from '@tupaia/types'; import { RECORDS } from '@tupaia/database'; import { TaskT, formatTaskResponse } from '../utils'; @@ -33,13 +33,6 @@ const FIELDS = [ const DEFAULT_PAGE_SIZE = 20; -type SingleTask = Task & { - 'survey.name': string; - 'survey.code': string; - 'entity.name': string; - 'entity.country_code': string; -}; - type FormattedFilters = Record; const EQUALITY_FILTERS = ['due_date', 'survey.project_id', 'task_status']; @@ -142,7 +135,7 @@ export class TasksRoute extends Route { } public async buildResponse() { - const { ctx, query = {} } = this.req; + const { ctx, query = {}, models } = this.req; const { pageSize = DEFAULT_PAGE_SIZE, sort, page = 0 } = query; this.formatFilters(); @@ -161,7 +154,20 @@ export class TasksRoute extends Route { page, }); - const formattedTasks = tasks.map((task: TaskT) => formatTaskResponse(task)); + const formattedTasks = (await Promise.all( + tasks.map(async (task: TaskT) => { + const formattedTask = formatTaskResponse(task); + // Get comment count for each task + const commentsCount = await models.taskComment.count({ + task_id: task.id, + type: TaskCommentType.user, + }); + return { + ...formattedTask, + commentsCount, + }; + }), + )) as DatatrakWebTasksRequest.ResBody['tasks']; // Get all task ids for pagination const count = await this.queryForCount(); diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index 0081748abb..1c9e07d491 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -12,6 +12,7 @@ import { PermissionGroupModel, SurveyModel, SurveyResponseModel, + TaskCommentModel, TaskModel, UserEntityPermissionModel, UserModel, @@ -29,4 +30,5 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly task: TaskModel; readonly permissionGroup: PermissionGroupModel; readonly userEntityPermission: UserEntityPermissionModel; + readonly taskComment: TaskCommentModel; } diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 84b597559c..19bed811ce 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -12,7 +12,7 @@ export type TaskT = Omit & { 'survey.code': Survey['code']; 'survey.name': Survey['name']; task_status: DatatrakWebTaskRequest.ResBody['taskStatus']; - comments: DatatrakWebTaskRequest.ResBody['comments']; + comments?: DatatrakWebTaskRequest.ResBody['comments']; }; export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody => { diff --git a/packages/datatrak-web/src/components/Icons/CommentIcon.tsx b/packages/datatrak-web/src/components/Icons/CommentIcon.tsx new file mode 100644 index 0000000000..c0e6d6c914 --- /dev/null +++ b/packages/datatrak-web/src/components/Icons/CommentIcon.tsx @@ -0,0 +1,26 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const CommentIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/datatrak-web/src/components/Icons/index.ts b/packages/datatrak-web/src/components/Icons/index.ts index 00a378286f..a6db7c9f18 100644 --- a/packages/datatrak-web/src/components/Icons/index.ts +++ b/packages/datatrak-web/src/components/Icons/index.ts @@ -13,3 +13,4 @@ export { PinIcon } from './PinIcon'; export { ReportsIcon } from './ReportsIcon'; export { CopyIcon } from './CopyIcon'; export { TaskIcon } from './TaskIcon'; +export { CommentIcon } from './CommentIcon'; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index f8b0b13746..1ea5663750 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -5,8 +5,11 @@ import React from 'react'; import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; import { useLocation, useSearchParams } from 'react-router-dom'; +import { DatatrakWebTasksRequest } from '@tupaia/types'; import { FilterableTable } from '@tupaia/ui-components'; +import { CommentIcon } from '../../../components'; import { TaskStatusType } from '../../../types'; import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; @@ -37,6 +40,36 @@ const ActionCellContent = styled.div` align-items: center; `; +const CommentsCountWrapper = styled.div` + color: ${({ theme }) => theme.palette.text.secondary}; + display: flex; + align-items: center; + position: absolute; + right: 0; + .MuiSvgIcon-root { + font-size: 1rem; + } +`; + +const CommentCountText = styled(Typography)` + font-size: 0.75rem; + margin-inline-start: 0.25rem; +`; + +const StatusCellContent = styled.div` + display: flex; +`; + +const CommentsCount = ({ commentsCount }: { commentsCount: number }) => { + if (!commentsCount) return null; + return ( + + + {commentsCount} + + ); +}; + const useTasksTable = () => { const { projectId } = useCurrentUserContext(); const [searchParams, setSearchParams] = useSearchParams(); @@ -130,7 +163,22 @@ const useTasksTable = () => { filterable: true, accessor: 'taskStatus', id: 'task_status', - Cell: ({ value }: { value: TaskStatusType }) => , + Cell: ({ + value, + row, + }: { + value: TaskStatusType; + row: { + original: DatatrakWebTasksRequest.ResBody['tasks'][0]; + }; + }) => { + return ( + + + + + ); + }, Filter: StatusFilter, disableResizing: true, width: 180, diff --git a/packages/server-boilerplate/src/models/TaskComment.ts b/packages/server-boilerplate/src/models/TaskComment.ts new file mode 100644 index 0000000000..b559c34ec1 --- /dev/null +++ b/packages/server-boilerplate/src/models/TaskComment.ts @@ -0,0 +1,14 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { + TaskCommentRecord as BaseTaskCommentRecord, + TaskCommentModel as BaseTaskCommentModel, +} from '@tupaia/database'; +import { Task, TaskComment } from '@tupaia/types'; +import { Model } from './types'; + +export interface TaskCommentRecord extends TaskComment, BaseTaskCommentRecord {} + +export interface TaskCommentModel extends Model {} diff --git a/packages/server-boilerplate/src/models/index.ts b/packages/server-boilerplate/src/models/index.ts index 440a3ed94d..d87aba7a75 100644 --- a/packages/server-boilerplate/src/models/index.ts +++ b/packages/server-boilerplate/src/models/index.ts @@ -62,3 +62,4 @@ export { SurveyModel, SurveyRecord } from './Survey'; export { UserEntityPermissionModel, UserEntityPermissionRecord } from './UserEntityPermission'; export { UserModel, UserRecord } from './User'; export { TaskModel, TaskRecord } from './Task'; +export { TaskCommentModel, TaskCommentRecord } from './TaskComment'; diff --git a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts index 22dc17f08d..1f9d2f5ac2 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts @@ -30,7 +30,10 @@ export type TaskResponse = KeysToCamelCase< }; export type ResBody = { - tasks: TaskResponse[]; + tasks: TaskResponse & + { + commentsCount: number; + }[]; count: number; numberOfPages: number; }; From 6243d92d37dd1fbe5130e968665c4d4d59e58375 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:18:23 +1200 Subject: [PATCH 13/38] Show comment count on tasks tile on landing page --- .../src/features/Tasks/CommentsCount.tsx | 34 +++++++++++++++++++ .../src/features/Tasks/TaskTile.tsx | 25 ++------------ .../features/Tasks/TasksTable/TasksTable.tsx | 32 ++++------------- 3 files changed, 42 insertions(+), 49 deletions(-) create mode 100644 packages/datatrak-web/src/features/Tasks/CommentsCount.tsx diff --git a/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx b/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx new file mode 100644 index 0000000000..cc21a34c85 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx @@ -0,0 +1,34 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; +import { CommentIcon } from '../../components'; + +const CommentsCountWrapper = styled.div` + color: ${({ theme }) => theme.palette.text.secondary}; + display: flex; + align-items: center; + right: 0; + .MuiSvgIcon-root { + font-size: 1rem; + } +`; + +const CommentCountText = styled(Typography)` + font-size: 0.75rem; + margin-inline-start: 0.25rem; +`; + +export const CommentsCount = ({ commentsCount }: { commentsCount: number }) => { + if (!commentsCount) return null; + return ( + + + {commentsCount} + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx index f5210b17e0..30dd138aec 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx @@ -11,6 +11,7 @@ import { ROUTES } from '../../constants'; import { StatusPill } from './StatusPill'; import { displayDate } from '../../utils'; import { ButtonLink } from '../../components'; +import { CommentsCount } from './CommentsCount'; const TileContainer = styled.div` display: flex; @@ -63,28 +64,6 @@ const TileRight = styled.div` justify-content: center; `; -const CommentsContainer = styled.div` - display: flex; - align-items: center; - - .MuiSvgIcon-root { - font-size: 1rem; - margin-inline-end: 0.2rem; - } -`; - -const Comments = ({ commentsCount = 0 }) => { - if (!commentsCount) { - return null; - } - return ( - - - {commentsCount} - - ); -}; - export const TaskTile = ({ task }) => { const { survey, entity, taskStatus, dueDate } = task; const surveyLink = generatePath(ROUTES.SURVEY, { @@ -98,7 +77,7 @@ export const TaskTile = ({ task }) => { {displayDate(dueDate)} - + diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index 1ea5663750..b4256ec6ec 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -19,6 +19,7 @@ import { TaskActionsMenu } from '../TaskActionsMenu'; import { StatusFilter } from './StatusFilter'; import { ActionButton } from './ActionButton'; import { FilterToolbar } from './FilterToolbar'; +import { CommentsCount } from '../CommentsCount'; const Container = styled.div` display: flex; @@ -40,36 +41,15 @@ const ActionCellContent = styled.div` align-items: center; `; -const CommentsCountWrapper = styled.div` - color: ${({ theme }) => theme.palette.text.secondary}; - display: flex; - align-items: center; - position: absolute; - right: 0; - .MuiSvgIcon-root { - font-size: 1rem; - } -`; - -const CommentCountText = styled(Typography)` - font-size: 0.75rem; - margin-inline-start: 0.25rem; -`; - const StatusCellContent = styled.div` display: flex; + justify-content: space-between; + a:has(&) { + // This is a workaround to make the comments count display at the edge of the cell + padding-inline-end: 0; + } `; -const CommentsCount = ({ commentsCount }: { commentsCount: number }) => { - if (!commentsCount) return null; - return ( - - - {commentsCount} - - ); -}; - const useTasksTable = () => { const { projectId } = useCurrentUserContext(); const [searchParams, setSearchParams] = useSearchParams(); From 5e1dc87318ec18fe6c53585a1af5b61b3ffba3bb Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:00:35 +1200 Subject: [PATCH 14/38] Fix up types --- .../src/routes/TaskRoute.ts | 9 +++++---- .../src/routes/TasksRoute.ts | 2 +- .../src/utils/formatTaskResponse.ts | 20 +++++++++++++------ .../datatrak-web/src/api/queries/useTask.ts | 4 ++-- .../src/features/Tasks/TaskActionsMenu.tsx | 4 ++-- .../Tasks/TaskDetails/TaskComments.tsx | 4 ++-- .../Tasks/TaskDetails/TaskDetails.tsx | 4 ++-- .../Tasks/TaskDetails/TaskMetadata.tsx | 4 ++-- .../src/features/Tasks/TaskTile.tsx | 3 +-- .../Tasks/TasksTable/ActionButton.tsx | 6 +++--- .../Tasks/TasksTable/AssignTaskModal.tsx | 6 +++--- .../features/Tasks/TasksTable/TasksTable.tsx | 4 +--- packages/datatrak-web/src/types/task.ts | 6 ++++-- .../datatrak-web-server/TasksRequest.ts | 9 ++++----- 14 files changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/datatrak-web-server/src/routes/TaskRoute.ts b/packages/datatrak-web-server/src/routes/TaskRoute.ts index 9bcf9f2437..011d1d6ef2 100644 --- a/packages/datatrak-web-server/src/routes/TaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/TaskRoute.ts @@ -7,6 +7,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebTaskRequest } from '@tupaia/types'; import { TaskT, formatTaskResponse } from '../utils'; +import camelcaseKeys from 'camelcase-keys'; export type TaskRequest = Request< DatatrakWebTaskRequest.Params, @@ -44,9 +45,9 @@ export class TaskRoute extends Route { const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`); - return formatTaskResponse({ - ...task, - comments, - }); + return { + ...formatTaskResponse(task), + comments: camelcaseKeys(comments, { deep: true }), + }; } } diff --git a/packages/datatrak-web-server/src/routes/TasksRoute.ts b/packages/datatrak-web-server/src/routes/TasksRoute.ts index aa7ca0cce8..e8f63e882e 100644 --- a/packages/datatrak-web-server/src/routes/TasksRoute.ts +++ b/packages/datatrak-web-server/src/routes/TasksRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { parse } from 'cookie'; -import { DatatrakWebTasksRequest, Task, TaskCommentType, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTasksRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; import { RECORDS } from '@tupaia/database'; import { TaskT, formatTaskResponse } from '../utils'; diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 19bed811ce..114a50bf2a 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -3,7 +3,14 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { DatatrakWebTaskRequest, Entity, Survey, Task } from '@tupaia/types'; +import { + DatatrakWebTasksRequest, + Entity, + KeysToCamelCase, + Survey, + Task, + TaskStatus, +} from '@tupaia/types'; import camelcaseKeys from 'camelcase-keys'; export type TaskT = Omit & { @@ -11,11 +18,12 @@ export type TaskT = Omit & { 'entity.country_code': string; 'survey.code': Survey['code']; 'survey.name': Survey['name']; - task_status: DatatrakWebTaskRequest.ResBody['taskStatus']; - comments?: DatatrakWebTaskRequest.ResBody['comments']; + task_status: TaskStatus | 'overdue' | 'repeating'; }; -export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody => { +type FormattedTask = DatatrakWebTasksRequest.TaskResponse; + +export const formatTaskResponse = (task: TaskT): FormattedTask => { const { entity_id: entityId, 'entity.name': entityName, @@ -29,7 +37,6 @@ export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody const formattedTask = { ...rest, - taskStatus, entity: { id: entityId, name: entityName, @@ -40,9 +47,10 @@ export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody name: surveyName, code: surveyCode, }, + taskStatus, }; return camelcaseKeys(formattedTask, { deep: true, - }) as DatatrakWebTaskRequest.ResBody; + }); }; diff --git a/packages/datatrak-web/src/api/queries/useTask.ts b/packages/datatrak-web/src/api/queries/useTask.ts index cb5e2134cc..19be0ef6f7 100644 --- a/packages/datatrak-web/src/api/queries/useTask.ts +++ b/packages/datatrak-web/src/api/queries/useTask.ts @@ -4,13 +4,13 @@ */ import { useQuery } from 'react-query'; -import { DatatrakWebTasksRequest } from '@tupaia/types'; +import { DatatrakWebTaskRequest } from '@tupaia/types'; import { get } from '../api'; export const useTask = (taskId?: string) => { return useQuery( ['tasks', taskId], - (): Promise => get(`tasks/${taskId}`), + (): Promise => get(`tasks/${taskId}`), { enabled: !!taskId, }, diff --git a/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx index bed8d49bf2..95d894c8fd 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx @@ -10,7 +10,7 @@ import { ActionsMenu } from '@tupaia/ui-components'; import styled from 'styled-components'; import { useEditTask } from '../../api'; import { SmallModal } from '../../components'; -import { Task } from '../../types'; +import { SingleTaskResponse, TasksResponse } from '../../types'; const MenuButton = styled(IconButton)` &.MuiIconButton-root { @@ -47,7 +47,7 @@ const CancelTaskModal = ({ isOpen, onClose, onCancelTask, isLoading }: ModalProp ); -export const TaskActionsMenu = ({ id, taskStatus }: Task) => { +export const TaskActionsMenu = ({ id, taskStatus }: TasksResponse | SingleTaskResponse) => { const [isOpen, setIsOpen] = useState(false); const onOpen = () => setIsOpen(true); const onClose = () => setIsOpen(false); diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx index 81d6bcd9d7..21a1ad98b1 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -6,8 +6,8 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; -import { Task } from '../../../types'; import { displayDateTime } from '../../../utils'; +import { SingleTaskResponse } from '../../../types'; const Wrapper = styled.div` width: 100%; @@ -34,7 +34,7 @@ const Message = styled(Typography).attrs({ margin-block-start: 0.2rem; `; -type Comments = Task['comments']; +type Comments = SingleTaskResponse['comments']; const SingleComment = ({ comment }: { comment: Comments[0] }) => { const { createdAt, userName, message } = comment; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx index 7729fd9359..dccdc3c8e5 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -13,12 +13,12 @@ import { LoadingContainer, TextField } from '@tupaia/ui-components'; import { useEditTask } from '../../../api'; import { Button } from '../../../components'; import { useFromLocation } from '../../../utils'; +import { SingleTaskResponse } from '../../../types'; import { RepeatScheduleInput } from '../RepeatScheduleInput'; import { DueDatePicker } from '../DueDatePicker'; import { AssigneeInput } from '../AssigneeInput'; import { TaskForm } from '../TaskForm'; import { ROUTES } from '../../../constants'; -import { Task } from '../../../types'; import { TaskMetadata } from './TaskMetadata'; import { TaskComments } from './TaskComments'; @@ -93,7 +93,7 @@ const Form = styled(TaskForm)` } `; -export const TaskDetails = ({ task }: { task: Task }) => { +export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { const navigate = useNavigate(); const backLink = useFromLocation(); diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx index 881730e931..0e0362af49 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx @@ -6,9 +6,9 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; -import { Task } from '../../../types'; import { StatusPill } from '../StatusPill'; import { useEntityByCode } from '../../../api'; +import { SingleTaskResponse } from '../../../types'; const Container = styled.div` border: 1px solid ${props => props.theme.palette.divider}; @@ -60,7 +60,7 @@ const CountryWrapper = styled.div` justify-content: flex-end; `; -export const TaskMetadata = ({ task }: { task?: Task }) => { +export const TaskMetadata = ({ task }: { task?: SingleTaskResponse }) => { const { data: country } = useEntityByCode(task?.entity?.countryCode, { enabled: !!task?.entity?.countryCode, }); diff --git a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx index 30dd138aec..caf098d49a 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx @@ -6,11 +6,10 @@ import React from 'react'; import styled from 'styled-components'; import { generatePath, Link } from 'react-router-dom'; -import ChatIcon from '@material-ui/icons/ChatBubbleOutline'; import { ROUTES } from '../../constants'; -import { StatusPill } from './StatusPill'; import { displayDate } from '../../utils'; import { ButtonLink } from '../../components'; +import { StatusPill } from './StatusPill'; import { CommentsCount } from './CommentsCount'; const TileContainer = styled.div` diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx index 0a9ff7eb28..df5adb2ea7 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx @@ -9,8 +9,8 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { Button } from '@tupaia/ui-components'; import { ROUTES } from '../../../constants'; -import { Task } from '../../../types'; -import { AssignTaskModal } from './AssignTaskModal.tsx'; +import { TasksResponse } from '../../../types'; +import { AssignTaskModal } from './AssignTaskModal'; const ActionButtonComponent = styled(Button).attrs({ color: 'primary', @@ -30,7 +30,7 @@ const ActionButtonComponent = styled(Button).attrs({ `; interface ActionButtonProps { - task: Task; + task: TasksResponse; } export const ActionButton = ({ task }: ActionButtonProps) => { diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx index 3ef8441417..8a92d750f0 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx @@ -8,7 +8,7 @@ import { useForm, Controller } from 'react-hook-form'; import { Typography } from '@material-ui/core'; import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { useEditTask } from '../../../api'; -import { Task } from '../../../types'; +import { TasksResponse } from '../../../types'; import { AssigneeInput } from '../AssigneeInput'; import { TaskForm } from '../TaskForm'; import { StatusPill } from '../StatusPill'; @@ -75,11 +75,11 @@ const Row = styled.div` `; interface AssignTaskModalProps { - task: Task; + task: TasksResponse; Button: React.ComponentType<{ onClick: () => void }>; } -const useDisplayRepeatSchedule = (task: Task) => { +const useDisplayRepeatSchedule = (task: TasksResponse) => { // TODO: When repeating tasks are implemented, make sure the repeat schedule is displayed correctly once a due date is returned with the task const repeatScheduleOptions = getRepeatScheduleOptions(task.dueDate); const { label } = repeatScheduleOptions[0]; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index b4256ec6ec..68cbc46a0a 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -5,21 +5,19 @@ import React from 'react'; import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; import { useLocation, useSearchParams } from 'react-router-dom'; import { DatatrakWebTasksRequest } from '@tupaia/types'; import { FilterableTable } from '@tupaia/ui-components'; -import { CommentIcon } from '../../../components'; import { TaskStatusType } from '../../../types'; import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; import { TaskActionsMenu } from '../TaskActionsMenu'; +import { CommentsCount } from '../CommentsCount'; import { StatusFilter } from './StatusFilter'; import { ActionButton } from './ActionButton'; import { FilterToolbar } from './FilterToolbar'; -import { CommentsCount } from '../CommentsCount'; const Container = styled.div` display: flex; diff --git a/packages/datatrak-web/src/types/task.ts b/packages/datatrak-web/src/types/task.ts index 7f922f40b8..c07640480e 100644 --- a/packages/datatrak-web/src/types/task.ts +++ b/packages/datatrak-web/src/types/task.ts @@ -3,11 +3,13 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { DatatrakWebTaskRequest, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTaskRequest, DatatrakWebTasksRequest, TaskStatus } from '@tupaia/types'; export type TaskStatusType = TaskStatus | 'overdue' | 'repeating'; -export type Task = DatatrakWebTaskRequest.ResBody; +export type SingleTaskResponse = DatatrakWebTaskRequest.ResBody; + +export type TasksResponse = DatatrakWebTasksRequest.ResBody['tasks'][0]; export type TaskFilterType = | 'all_assignees_tasks' diff --git a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts index 1f9d2f5ac2..0c52ead2f5 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts @@ -9,7 +9,7 @@ import { Entity, Survey, Task, TaskStatus } from '../../models'; export type Params = Record; export type TaskResponse = KeysToCamelCase< - Omit + Partial> > & { assigneeName?: string; taskStatus: TaskStatus | 'overdue' | 'repeating'; @@ -30,10 +30,9 @@ export type TaskResponse = KeysToCamelCase< }; export type ResBody = { - tasks: TaskResponse & - { - commentsCount: number; - }[]; + tasks: (TaskResponse & { + commentsCount: number; + })[]; count: number; numberOfPages: number; }; From 4271c1dd08c4a359a82d803574fdad85a4cd0f87 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:05:40 +1200 Subject: [PATCH 15/38] Order comments descending --- packages/datatrak-web-server/src/routes/TaskRoute.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web-server/src/routes/TaskRoute.ts b/packages/datatrak-web-server/src/routes/TaskRoute.ts index 011d1d6ef2..4a12081fea 100644 --- a/packages/datatrak-web-server/src/routes/TaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/TaskRoute.ts @@ -43,7 +43,9 @@ export class TaskRoute extends Route { throw new Error(`Task with id ${taskId} not found`); } - const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`); + const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`, { + sort: ['created_at DESC'], + }); return { ...formatTaskResponse(task), From 0288187090ddd4ad9cb5f4cef9d439e087a834a1 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:06:21 +1200 Subject: [PATCH 16/38] Update schemas.ts --- packages/types/src/schemas/schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 613d46e9bd..c66553fd10 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -87853,7 +87853,6 @@ export const TaskResponseSchema = { }, "required": [ "entity", - "id", "survey", "taskStatus" ] From ca957957feaf4536ff8c2d093d05be554707a82a Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:11:25 +1200 Subject: [PATCH 17/38] addTaskComment on model --- packages/database/src/modelClasses/Task.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 332f2df968..c3b498fe7c 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -55,6 +55,13 @@ export class TaskRecord extends DatabaseRecord { async systemComments() { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } + + async addTaskComment(commentData) { + return this.otherModels.taskComment.create({ + ...commentData, + task_id: this.id, + }); + } } export class TaskModel extends DatabaseModel { From b822a6d9ed6f20a78210abf5a4e1e778b85517ca Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:22:52 +1200 Subject: [PATCH 18/38] Generate comment on edit task --- .../src/apiV2/tasks/EditTask.js | 7 +- packages/database/src/modelClasses/Task.js | 86 +++++++++++++++++-- .../src/routes/CreateTaskRoute.ts | 3 +- .../src/utils/formatTaskChanges.ts | 28 +++--- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 30 +++---- .../Tasks/TaskDetails/TaskDetails.tsx | 31 ++++--- .../datatrak-web-server/TaskChangeRequest.ts | 10 +-- 7 files changed, 141 insertions(+), 54 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js index 543a051620..330f241072 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -15,6 +15,11 @@ export class EditTask extends EditHandler { } async editRecord() { - await this.updateRecord(); + const originalRecord = await this.models.task.findById(this.recordId); + return this.models.wrapInTransaction(async transactingModels => { + const newRecord = await transactingModels.task.updateById(this.recordId, this.updatedFields); + await originalRecord.addSystemCommentsForUpdatedTask(this.updatedFields, this.req.user.id); + return newRecord; + }); } } diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index c3b498fe7c..82ee33b146 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -9,6 +9,49 @@ import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; import { JOIN_TYPES } from '../TupaiaDatabase'; +const getFriendlyFieldName = field => { + if (field === 'assignee_id') { + return 'assignee'; + } + if (field === 'repeat_schedule') { + return 'recurring task'; + } + + // Default to replacing underscores with spaces + return field.replace(/_/g, ' '); +}; + +const formatValue = async (field, value, models) => { + switch (field) { + case 'assignee_id': { + if (!value) { + return 'Unassigned'; + } + const assignee = await models.user.findById(value); + return assignee.full_name; + } + case 'repeat_schedule': { + if (!value || !value?.frequency) { + return "Doesn't repeat"; + } + + // TODO: Update this when we add in rrule in RN-1341, to handle date of week/month/year etc + return `${value.frequency.charAt(0).toUpperCase()}${value.frequency.slice(1)}`; + } + case 'due_date': { + if (!value) { + return 'No due date'; + } + return format(new Date(value), 'd MMMM yy'); + } + default: { + // Default to capitalizing the value and replacing underscores with spaces + const words = value.replace(/_/g, ' '); + return `${words.charAt(0).toUpperCase()}${words.slice(1)}`; + } + } +}; + export class TaskRecord extends DatabaseRecord { static databaseRecord = RECORDS.TASK; @@ -56,11 +99,44 @@ export class TaskRecord extends DatabaseRecord { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } - async addTaskComment(commentData) { - return this.otherModels.taskComment.create({ - ...commentData, - task_id: this.id, - }); + async addSystemCommentsForUpdatedTask(updatedFields, userId) { + const fieldsToCreateCommentsFor = ['due_date', 'repeat_schedule', 'status', 'assignee_id']; + const comments = []; + + const user = await this.otherModels.user.findById(userId); + + for (const [field, newValue] of Object.entries(updatedFields)) { + if (!fieldsToCreateCommentsFor.includes(field)) continue; + const originalValue = this[field]; + if (originalValue === newValue) continue; + + // if the due date is changed and the task is repeating, don't add a comment, because this just means that the repeat schedule is being updated, not that the due date is being changed + if (field === 'due_date' && updatedFields.repeat_schedule) { + continue; + } + + const friendlyFieldName = getFriendlyFieldName(field); + const formattedOriginalValue = await formatValue(field, originalValue, this.otherModels); + const formattedNewValue = await formatValue(field, newValue, this.otherModels); + + comments.push( + `Changed ${friendlyFieldName} from ${formattedOriginalValue} to ${formattedNewValue}`, + ); + } + + if (!comments.length) return; + + await Promise.all( + comments.map(message => + this.otherModels.taskComment.create({ + task_id: this.id, + message, + type: 'system', + user_id: userId, + user_name: user.full_name, + }), + ), + ); } } diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts index 940c1c49ef..ebbac6f84f 100644 --- a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -19,7 +19,7 @@ export class CreateTaskRoute extends Route { public async buildResponse() { const { models, body, ctx } = this.req; - const { surveyCode, entityId, comment } = body; + const { survey_code: surveyCode, comment } = body; const survey = await models.survey.findOne({ code: surveyCode }); if (!survey) { @@ -29,7 +29,6 @@ export class CreateTaskRoute extends Route { const taskDetails = formatTaskChanges({ ...body, survey_id: survey.id, - entity_id: entityId, }); if (taskDetails.due_date) { diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index 2380d12f97..db7499acf1 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -14,19 +14,21 @@ type Output = Partial> & { }; export const formatTaskChanges = (task: Input) => { - const { dueDate, repeatSchedule, assigneeId, ...restOfTask } = task; - - const taskDetails: Output & { - due_date?: string | null; - } = { assignee_id: assigneeId, ...restOfTask }; - - if (repeatSchedule) { - // if task is repeating, clear due date - taskDetails.repeat_schedule = JSON.stringify({ - // TODO: format this correctly when recurring tasks are implemented - frequency: repeatSchedule, - }); - taskDetails.due_date = null; + const { due_date: dueDate, repeat_schedule: repeatSchedule, ...restOfTask } = task; + + const taskDetails: Output = restOfTask; + + if (repeatSchedule !== undefined) { + if (repeatSchedule === null) { + taskDetails.repeat_schedule = null; + } else { + // if task is repeating, clear due date + taskDetails.repeat_schedule = { + // TODO: format this correctly when recurring tasks are implemented + frequency: repeatSchedule, + }; + taskDetails.due_date = null; + } } else if (dueDate) { // apply status and due date only if not a repeating task // set due date to end of day diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 44316faf56..435f70c225 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -104,11 +104,11 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { const defaultDueDate = generateDefaultDueDate(); const defaultValues = { - surveyCode: null, - entityId: null, - dueDate: defaultDueDate, - repeatSchedule: null, - assigneeId: null, + survey_code: null, + entity_id: null, + due_date: defaultDueDate, + repeat_schedule: null, + assignee_id: null, }; const formContext = useForm({ mode: 'onChange', @@ -164,18 +164,18 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { useEffect(() => { if (!selectedCountry?.code) return; - const { surveyCode, entityId } = dirtyFields; + const { survey_code: surveyCode, entity_id: entityId } = dirtyFields; // reset surveyCode and entityId when country changes, if they are dirty if (surveyCode) { - setValue('surveyCode', null, { shouldValidate: true }); + setValue('survey_code', null, { shouldValidate: true }); } if (entityId) { - setValue('entityId', null, { shouldValidate: true }); + setValue('entity_id', null, { shouldValidate: true }); } }, [selectedCountry?.code]); - const surveyCode = watch('surveyCode'); - const dueDate = watch('dueDate'); + const surveyCode = watch('survey_code'); + const dueDate = watch('due_date'); return ( { /> ( @@ -220,7 +220,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { )} /> { @@ -241,7 +241,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { /> { }} /> ( @@ -273,7 +273,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { ( { const backLink = useFromLocation(); const defaultValues = { - dueDate: task.dueDate ?? null, - repeatSchedule: task.repeatSchedule?.frequency ?? null, - assigneeId: task.assigneeId ?? null, + due_date: task.dueDate ?? null, + repeat_schedule: task.repeatSchedule?.frequency ?? null, + assignee_id: task.assigneeId ?? null, }; const formContext = useForm({ mode: 'onChange', @@ -130,10 +130,19 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { const canEditFields = task.taskStatus !== TaskStatus.completed && task.taskStatus !== TaskStatus.cancelled; - const dueDate = watch('dueDate'); + const dueDate = watch('due_date'); + + const onSubmit = data => { + const updatedFields = Object.keys(dirtyFields).reduce((acc, key) => { + acc[key] = data[key]; + return acc; + }, {}); + + editTask(updatedFields); + }; return ( -
+ @@ -142,10 +151,10 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { ( { ( { ( ; export type ResBody = { message: string; }; export type ReqQuery = Record; -export type ReqBody = { - assigneeId?: UserAccount['id']; - dueDate?: string; - entityId: Entity['id']; - repeatSchedule?: string; - surveyCode: Survey['code']; +export type ReqBody = Partial> & { + survey_code: Survey['code']; comment?: string; }; From 8dc501af16b6ff3f72fc7330570aac3f0dda558d Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:33:29 +1200 Subject: [PATCH 19/38] Generate comments on create of task --- .../src/apiV2/tasks/CreateTask.js | 12 +++++---- .../src/apiV2/tasks/EditTask.js | 2 +- packages/database/src/modelClasses/Task.js | 27 +++++++++---------- .../src/utils/formatTaskChanges.ts | 5 +++- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index c018d50508..5eeea4bbac 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,10 +21,12 @@ export class CreateTask extends CreateHandler { } async createRecord() { - const { models } = this; - const task = await models.task.create(this.newRecordData); - return { - id: task.id, - }; + await this.models.wrapInTransaction(async transactingModels => { + const task = await transactingModels.task.create(this.newRecordData); + await task.addComment('Created this task', this.req.user.id, 'system'); + return { + id: task.id, + }; + }); } } diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js index 330f241072..5c3904dea7 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -18,7 +18,7 @@ export class EditTask extends EditHandler { const originalRecord = await this.models.task.findById(this.recordId); return this.models.wrapInTransaction(async transactingModels => { const newRecord = await transactingModels.task.updateById(this.recordId, this.updatedFields); - await originalRecord.addSystemCommentsForUpdatedTask(this.updatedFields, this.req.user.id); + await originalRecord.addSystemCommentsOnUpdate(this.updatedFields, this.req.user.id); return newRecord; }); } diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 82ee33b146..48ce315f3d 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -99,12 +99,21 @@ export class TaskRecord extends DatabaseRecord { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } - async addSystemCommentsForUpdatedTask(updatedFields, userId) { + async addComment(message, userId, type) { + const user = await this.otherModels.user.findById(userId); + return this.otherModels.taskComment.create({ + task_id: this.id, + message, + type, + user_id: userId, + user_name: user.full_name, + }); + } + + async addSystemCommentsOnUpdate(updatedFields, userId) { const fieldsToCreateCommentsFor = ['due_date', 'repeat_schedule', 'status', 'assignee_id']; const comments = []; - const user = await this.otherModels.user.findById(userId); - for (const [field, newValue] of Object.entries(updatedFields)) { if (!fieldsToCreateCommentsFor.includes(field)) continue; const originalValue = this[field]; @@ -126,17 +135,7 @@ export class TaskRecord extends DatabaseRecord { if (!comments.length) return; - await Promise.all( - comments.map(message => - this.otherModels.taskComment.create({ - task_id: this.id, - message, - type: 'system', - user_id: userId, - user_name: user.full_name, - }), - ), - ); + await Promise.all(comments.map(message => this.addComment(message, userId, 'system'))); } } diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index db7499acf1..af4380ee1e 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -29,7 +29,8 @@ export const formatTaskChanges = (task: Input) => { }; taskDetails.due_date = null; } - } else if (dueDate) { + } + if (dueDate) { // apply status and due date only if not a repeating task // set due date to end of day const endOfDay = new Date(new Date(dueDate).setHours(23, 59, 59, 999)); @@ -38,6 +39,8 @@ export const formatTaskChanges = (task: Input) => { const withoutTimezone = stripTimezoneFromDate(endOfDay); taskDetails.due_date = withoutTimezone; + + taskDetails.repeat_schedule = null; } return taskDetails; From eca48fdf3d49042845861f99c5b32b6ba96b3179 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:54:58 +1200 Subject: [PATCH 20/38] Generate system message on task completion --- packages/database/src/changeHandlers/TaskCompletionHandler.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js index 391909c392..a356b0f784 100644 --- a/packages/database/src/changeHandlers/TaskCompletionHandler.js +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -79,10 +79,12 @@ export class TaskCompletionHandler extends ChangeHandler { if (!matchingSurveyResponse) continue; - await this.models.task.updateById(id, { + await transactingModels.task.updateById(id, { status: 'completed', survey_response_id: matchingSurveyResponse.id, }); + + await task.addComment('Completed this task', matchingSurveyResponse.user_id, 'system'); } } } From 5bce6526397a234ea6a270419624c2cd03090c8e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:57:06 +1200 Subject: [PATCH 21/38] Syetm task styling --- .../src/features/Tasks/TaskDetails/TaskComments.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx index 21a1ad98b1..5b880b8f4b 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components'; import { Typography } from '@material-ui/core'; import { displayDateTime } from '../../../utils'; import { SingleTaskResponse } from '../../../types'; +import { TaskCommentType } from '@tupaia/types'; const Wrapper = styled.div` width: 100%; @@ -29,7 +30,6 @@ const CommentContainer = styled.div` const Message = styled(Typography).attrs({ variant: 'body2', - color: 'textPrimary', })` margin-block-start: 0.2rem; `; @@ -37,13 +37,15 @@ const Message = styled(Typography).attrs({ type Comments = SingleTaskResponse['comments']; const SingleComment = ({ comment }: { comment: Comments[0] }) => { - const { createdAt, userName, message } = comment; + const { createdAt, userName, message, type } = comment; return ( {displayDateTime(createdAt)} - {userName} - {message} + + {message} + ); }; From 3302f5544a3a282159cc3bc148980b6660e2df8c Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:01:34 +1200 Subject: [PATCH 22/38] Handle editing a task with just a comment --- packages/datatrak-web-server/src/routes/EditTaskRoute.ts | 5 ++++- .../src/features/Tasks/TaskDetails/TaskDetails.tsx | 5 ++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index 21939be094..e2260fff01 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -28,7 +28,10 @@ export class EditTaskRoute extends Route { const taskDetails = formatTaskChanges(rest); - await ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); + // Sometimes an 'edit' will just be a comment, so we don't want to update the task details as an error will be thrown about an empty object + if (Object.keys(taskDetails).length > 0) { + await ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); + } if (comment) { await ctx.services.central.createResource( diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx index f51939ea56..8e25a9d745 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -111,7 +111,7 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { handleSubmit, watch, register, - formState: { isValid, dirtyFields }, + formState: { dirtyFields }, reset, } = formContext; @@ -153,7 +153,6 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { ( { Clear changes - From 28f02c068c838f72fc84ca0081d4047380f8fc43 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:13:18 +1200 Subject: [PATCH 23/38] Move comment handling to central server --- .../src/apiV2/tasks/CreateTask.js | 8 ++++++- .../src/apiV2/tasks/EditTask.js | 13 +++++++++-- .../src/routes/CreateTaskRoute.ts | 17 +++----------- .../src/routes/EditTaskRoute.ts | 23 ++----------------- .../src/utils/formatTaskChanges.ts | 22 ++++++++---------- 5 files changed, 32 insertions(+), 51 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index 5eeea4bbac..7422e48f3c 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,9 +21,15 @@ export class CreateTask extends CreateHandler { } async createRecord() { + const { comment, ...newRecordData } = this.newRecordData; await this.models.wrapInTransaction(async transactingModels => { - const task = await transactingModels.task.create(this.newRecordData); + const task = await transactingModels.task.create(newRecordData); + // Add the user comment first, since the transaction will mean that all comments have the same created_at time, but we want the user comment to be the 'most recent' + if (comment) { + await task.addComment(comment, this.req.user.id, 'user'); + } await task.addComment('Created this task', this.req.user.id, 'system'); + return { id: task.id, }; diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js index 5c3904dea7..c31ff1d8aa 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -15,11 +15,20 @@ export class EditTask extends EditHandler { } async editRecord() { + const { comment, ...updatedFields } = this.updatedFields; const originalRecord = await this.models.task.findById(this.recordId); + return this.models.wrapInTransaction(async transactingModels => { - const newRecord = await transactingModels.task.updateById(this.recordId, this.updatedFields); + const taskRecord = await transactingModels.task.findById(this.recordId); + if (comment) { + await taskRecord.addComment(comment, this.req.user.id, 'user'); + } await originalRecord.addSystemCommentsOnUpdate(this.updatedFields, this.req.user.id); - return newRecord; + // an edit can be just a comment, so we need to check if there are any other fields to update before calling updateById so we don't get an error + if (Object.keys(updatedFields).length > 0) { + return transactingModels.task.updateById(this.recordId, updatedFields); + } + return taskRecord; }); } } diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts index ebbac6f84f..38b847f4bc 100644 --- a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebTaskChangeRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTaskChangeRequest, TaskStatus } from '@tupaia/types'; import { formatTaskChanges } from '../utils'; export type CreateTaskRequest = Request< @@ -19,7 +19,7 @@ export class CreateTaskRoute extends Route { public async buildResponse() { const { models, body, ctx } = this.req; - const { survey_code: surveyCode, comment } = body; + const { survey_code: surveyCode } = body; const survey = await models.survey.findOne({ code: surveyCode }); if (!survey) { @@ -35,18 +35,7 @@ export class CreateTaskRoute extends Route { taskDetails.status = TaskStatus.to_do; } - const task = await ctx.services.central.createResource('tasks', {}, taskDetails); - - if (comment) { - await ctx.services.central.createResource( - `tasks/${task.id}/taskComments`, - {}, - { - message: comment, - type: TaskCommentType.user, - }, - ); - } + await ctx.services.central.createResource('tasks', {}, taskDetails); return { message: 'Task created successfully', diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index e2260fff01..eebaabf582 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -24,28 +24,9 @@ export class EditTaskRoute extends Route { const { body, ctx, params } = this.req; const { taskId } = params; - const { comment, ...rest } = body; - const taskDetails = formatTaskChanges(rest); + const taskDetails = formatTaskChanges(body); - // Sometimes an 'edit' will just be a comment, so we don't want to update the task details as an error will be thrown about an empty object - if (Object.keys(taskDetails).length > 0) { - await ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); - } - - if (comment) { - await ctx.services.central.createResource( - `tasks/${taskId}/taskComments`, - {}, - { - message: comment, - type: TaskCommentType.user, - }, - ); - } - - return { - message: 'Task updated successfully', - }; + return ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); } } diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index af4380ee1e..3eff8ffd5e 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -11,6 +11,7 @@ type Input = Partial & type Output = Partial> & { due_date?: string | null; + comment?: string; }; export const formatTaskChanges = (task: Input) => { @@ -18,19 +19,14 @@ export const formatTaskChanges = (task: Input) => { const taskDetails: Output = restOfTask; - if (repeatSchedule !== undefined) { - if (repeatSchedule === null) { - taskDetails.repeat_schedule = null; - } else { - // if task is repeating, clear due date - taskDetails.repeat_schedule = { - // TODO: format this correctly when recurring tasks are implemented - frequency: repeatSchedule, - }; - taskDetails.due_date = null; - } - } - if (dueDate) { + if (repeatSchedule) { + // if task is repeating, clear due date + taskDetails.repeat_schedule = { + // TODO: format this correctly when recurring tasks are implemented + frequency: repeatSchedule, + }; + taskDetails.due_date = null; + } else if (dueDate) { // apply status and due date only if not a repeating task // set due date to end of day const endOfDay = new Date(new Date(dueDate).setHours(23, 59, 59, 999)); From ccd8d0719f963c984dd1d788642072e03984952f Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:47:28 +1200 Subject: [PATCH 24/38] Move comment adding to central server --- .../src/apiV2/tasks/CreateTask.js | 16 +++++-- .../src/apiV2/tasks/EditTask.js | 14 +++++- .../src/tests/apiV2/tasks/CreateTask.test.js | 19 ++++++++ .../src/tests/apiV2/tasks/EditTask.test.js | 47 +++++++++++++++++++ packages/database/src/modelClasses/Task.js | 11 +++++ .../src/routes/CreateTaskRoute.ts | 19 +------- .../src/routes/EditTaskRoute.ts | 20 +------- .../src/utils/formatTaskChanges.ts | 2 + 8 files changed, 107 insertions(+), 41 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index c018d50508..0d648537c6 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,10 +21,16 @@ export class CreateTask extends CreateHandler { } async createRecord() { - const { models } = this; - const task = await models.task.create(this.newRecordData); - return { - id: task.id, - }; + const { comment, ...data } = this.newRecordData; + + return this.models.wrapInTransaction(async transactingModels => { + const task = await transactingModels.task.create(data); + if (comment) { + await task.addComment(comment, this.req.user.id); + } + return { + id: task.id, + }; + }); } } diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js index 543a051620..0f06ee1ea7 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -15,6 +15,18 @@ export class EditTask extends EditHandler { } async editRecord() { - await this.updateRecord(); + const { comment, ...updatedFields } = this.updatedFields; + return this.models.wrapInTransaction(async transactingModels => { + const originalTask = await transactingModels.task.findById(this.recordId); + let task = originalTask; + // Sometimes an update can just be a comment, so we don't want to update the task if there are no fields to update, because we would get an error + if (Object.keys(updatedFields).length > 0) { + task = await transactingModels.task.updateById(this.recordId, updatedFields); + } + if (comment) { + await originalTask.addComment(comment, this.req.user.id); + } + return task; + }); } } diff --git a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js index 8add4378fe..4790269b1d 100644 --- a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js @@ -135,5 +135,24 @@ describe('Permissions checker for CreateTask', async () => { }); expect(result).to.have.keys('error'); }); + + it('Handles creating a task with a comment', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post('tasks', { + body: { + ...BASE_TASK, + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + comment: 'This is a comment', + }, + }); + + expect(result.message).to.equal('Successfully created tasks'); + const comment = await models.taskComment.findOne({ + task_id: result.id, + message: 'This is a comment', + }); + expect(comment).not.to.be.undefined; + }); }); }); diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js index 1bda0247a9..a0f51382b0 100644 --- a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -207,5 +207,52 @@ describe('Permissions checker for EditTask', async () => { expect(result).to.have.keys('error'); expect(result.error).to.include('Need to have access to the new entity of the task'); }); + + it('Handles adding a comment when editing a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.undefined; + }); + + it('Handles adding a comment when no other edits are made', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(tasks[1].entity_id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.undefined; + }); }); }); diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 332f2df968..90759ec43a 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -55,6 +55,17 @@ export class TaskRecord extends DatabaseRecord { async systemComments() { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } + + async addComment(message, userId, type = 'user') { + const user = await this.otherModels.user.findById(userId); + return this.otherModels.taskComment.create({ + message, + task_id: this.id, + user_id: userId, + user_name: user.full_name, + type, + }); + } } export class TaskModel extends DatabaseModel { diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts index 940c1c49ef..c03306c6a9 100644 --- a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebTaskChangeRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTaskChangeRequest, TaskStatus } from '@tupaia/types'; import { formatTaskChanges } from '../utils'; export type CreateTaskRequest = Request< @@ -36,21 +36,6 @@ export class CreateTaskRoute extends Route { taskDetails.status = TaskStatus.to_do; } - const task = await ctx.services.central.createResource('tasks', {}, taskDetails); - - if (comment) { - await ctx.services.central.createResource( - `tasks/${task.id}/taskComments`, - {}, - { - message: comment, - type: TaskCommentType.user, - }, - ); - } - - return { - message: 'Task created successfully', - }; + return ctx.services.central.createResource('tasks', {}, taskDetails); } } diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index 21939be094..eebaabf582 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -24,25 +24,9 @@ export class EditTaskRoute extends Route { const { body, ctx, params } = this.req; const { taskId } = params; - const { comment, ...rest } = body; - const taskDetails = formatTaskChanges(rest); + const taskDetails = formatTaskChanges(body); - await ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); - - if (comment) { - await ctx.services.central.createResource( - `tasks/${taskId}/taskComments`, - {}, - { - message: comment, - type: TaskCommentType.user, - }, - ); - } - - return { - message: 'Task updated successfully', - }; + return ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); } } diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index 2380d12f97..b03437c352 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -11,6 +11,7 @@ type Input = Partial & type Output = Partial> & { due_date?: string | null; + comment?: string; }; export const formatTaskChanges = (task: Input) => { @@ -36,6 +37,7 @@ export const formatTaskChanges = (task: Input) => { const withoutTimezone = stripTimezoneFromDate(endOfDay); taskDetails.due_date = withoutTimezone; + taskDetails.repeat_schedule = null; } return taskDetails; From ce615db0f1411b55faad1668fc16d2bfb3527a8c Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:48:19 +1200 Subject: [PATCH 25/38] Update schemas.ts --- packages/types/src/schemas/schemas.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index c66553fd10..68158cb07a 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85732,6 +85732,9 @@ export const TaskSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" } }, "additionalProperties": false, @@ -85774,6 +85777,9 @@ export const TaskCreateSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" } }, "additionalProperties": false, @@ -85817,6 +85823,9 @@ export const TaskUpdateSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" } }, "additionalProperties": false @@ -87784,6 +87793,9 @@ export const TaskResponseSchema = { ], "type": "string" }, + "surveyResponseId": { + "type": "string" + }, "assigneeName": { "type": "string" }, @@ -87837,22 +87849,18 @@ export const TaskResponseSchema = { "name" ] }, - "dueDate": { - "type": "string", - "format": "date-time" - }, "repeatSchedule": { "type": "object", - "properties": { - "frequency": { - "type": "string" - } - }, "additionalProperties": false + }, + "dueDate": { + "type": "string", + "format": "date-time" } }, "required": [ "entity", + "repeatSchedule", "survey", "taskStatus" ] From de21d533449a4414da25b4dc89419e344a5ab7d1 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:56:41 +1200 Subject: [PATCH 26/38] Fix types --- .../datatrak-web-server/src/utils/formatTaskResponse.ts | 6 +++++- .../src/types/requests/datatrak-web-server/TasksRequest.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 114a50bf2a..0ddd5a9005 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -4,6 +4,7 @@ */ import { + DatatrakWebTaskRequest, DatatrakWebTasksRequest, Entity, KeysToCamelCase, @@ -13,12 +14,13 @@ import { } from '@tupaia/types'; import camelcaseKeys from 'camelcase-keys'; -export type TaskT = Omit & { +export type TaskT = Omit & { 'entity.name': Entity['name']; 'entity.country_code': string; 'survey.code': Survey['code']; 'survey.name': Survey['name']; task_status: TaskStatus | 'overdue' | 'repeating'; + repeat_schedule?: Record | null; }; type FormattedTask = DatatrakWebTasksRequest.TaskResponse; @@ -32,6 +34,7 @@ export const formatTaskResponse = (task: TaskT): FormattedTask => { survey_id: surveyId, 'survey.name': surveyName, task_status: taskStatus, + repeat_schedule: repeatSchedule, ...rest } = task; @@ -48,6 +51,7 @@ export const formatTaskResponse = (task: TaskT): FormattedTask => { code: surveyCode, }, taskStatus, + repeatSchedule, }; return camelcaseKeys(formattedTask, { diff --git a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts index 044b68de3a..e19d777775 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts @@ -23,7 +23,7 @@ export type TaskResponse = KeysToCamelCase< id: Entity['id']; countryCode: string; // this is not undefined or null so use string explicitly here }; - repeatSchedule: Record | null; + repeatSchedule?: Record | null; dueDate?: Date | null; }; From 498b1be6bc0836b68e90307a2d87fbfd53c705ab Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:59:48 +1200 Subject: [PATCH 27/38] Remove task comments endpoints --- packages/central-server/src/apiV2/index.js | 3 - .../apiV2/taskComments/CreateTaskComment.js | 41 ----- .../src/apiV2/taskComments/GETTaskComments.js | 38 ----- .../assertTaskCommentPermissions.js | 38 ----- .../src/apiV2/taskComments/index.js | 7 - .../taskComments/CreateTaskComment.test.js | 159 ------------------ .../taskComments/GETTaskComments.test.js | 149 ---------------- 7 files changed, 435 deletions(-) delete mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js delete mode 100644 packages/central-server/src/apiV2/taskComments/GETTaskComments.js delete mode 100644 packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js delete mode 100644 packages/central-server/src/apiV2/taskComments/index.js delete mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js delete mode 100644 packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 1535c0158a..bf2a97dc7f 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,7 +145,6 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; -import { CreateTaskComment, GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -270,7 +269,6 @@ apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); -apiV2.get('/tasks/:parentRecordId/taskComments', useRouteHandler(GETTaskComments)); /** * POST routes */ @@ -318,7 +316,6 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); -apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js deleted file mode 100644 index d131fec8ee..0000000000 --- a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ -import { RECORDS } from '@tupaia/database'; -import { CreateHandler } from '../CreateHandler'; -import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; -import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; - -/** - * Handles POST endpoints: - * - /tasks/:parentRecordId/taskComments - */ - -export class CreateTaskComment extends CreateHandler { - parentRecordType = RECORDS.TASK; - - async assertUserHasAccess() { - const createPermissionChecker = accessPolicy => - assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); - - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), - ); - } - - async createRecord() { - const { id: userId } = this.req.user; - const user = await this.models.user.findById(userId); // Check if user exists - if (!user) { - throw new Error(`User with id ${userId} not found`); - } - - const { full_name: userFullName } = user; - this.newRecordData.user_id = userId; - this.newRecordData.user_name = userFullName; - this.newRecordData.task_id = this.parentRecordId; - - return this.insertRecord(); - } -} diff --git a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js deleted file mode 100644 index e0e4838fd4..0000000000 --- a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; -import { GETHandler } from '../GETHandler'; -import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; -import { createTaskCommentDBFilter } from './assertTaskCommentPermissions'; - -/** - * Handles endpoints: - * - /tasks/:taskId/comments - */ - -export class GETTaskComments extends GETHandler { - permissionsFilteredInternally = true; - - async getPermissionsFilter(criteria, options) { - return createTaskCommentDBFilter(this.accessPolicy, this.models, criteria, options); - } - - async getPermissionsViaParentFilter(criteria, options) { - const taskPermissionsChecker = accessPolicy => - assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, taskPermissionsChecker]), - ); - // Filter by parent - const dbConditions = { 'task_comment.task_id': this.parentRecordId, ...criteria }; - - // Apply regular permissions - return { - dbConditions, - dbOptions: options, - }; - } -} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js deleted file mode 100644 index 3136b4f049..0000000000 --- a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { hasBESAdminAccess } from '../../permissions'; -import { createTaskDBFilter } from '../tasks/assertTaskPermissions'; - -export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { - if (hasBESAdminAccess(accessPolicy)) { - return { dbConditions: criteria, dbOptions: options }; - } - const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); - - const taskIds = await models.task.find( - { - ...dbConditions, - id: criteria.task_id ?? undefined, - }, - { columns: ['task.id'] }, - ); - - if (!taskIds.length) { - // if the user doesn't have access to any tasks, return a condition that will return no results - return { dbConditions: { id: -1 }, dbOptions: options }; - } - - return { - dbConditions: { - ...criteria, - task_id: { - comparator: 'IN', - comparisonValue: taskIds.map(task => task.id), // this will include any task_id filters because the list of tasks was already filtered by the dbConditions - }, - }, - dbOptions: options, - }; -}; diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js deleted file mode 100644 index e6c87c1220..0000000000 --- a/packages/central-server/src/apiV2/taskComments/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -export { GETTaskComments } from './GETTaskComments'; -export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js deleted file mode 100644 index 3d8f83b49d..0000000000 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import { - buildAndInsertSurvey, - findOrCreateDummyCountryEntity, - findOrCreateDummyRecord, - generateId, -} from '@tupaia/database'; -import { TestableApp, resetTestData } from '../../testUtilities'; -import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; - -describe('Permissions checker for CreateTaskComment', async () => { - const BES_ADMIN_POLICY = { - DL: [BES_ADMIN_PERMISSION_GROUP], - }; - - const DEFAULT_POLICY = { - DL: ['Donor'], - }; - - const PUBLIC_POLICY = { - DL: ['Public'], - }; - - const app = new TestableApp(); - const { models } = app; - - const BASE_COMMENT = { - message: 'This is a test comment', - type: 'user', - }; - - const generateData = async () => { - const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'DL', - name: 'Demo Land', - }); - - const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Donor', - }); - - const facility = { - id: generateId(), - code: 'TEST_FACILITY_2', - name: 'Test Facility 2', - country_code: dlCountry.code, - }; - - await findOrCreateDummyRecord(models.entity, facility); - - const { survey } = await buildAndInsertSurvey(models, { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: donorPermission.id, - country_ids: [dlCountry.id], - }); - - const user = { - id: generateId(), - first_name: 'Minnie', - last_name: 'Mouse', - }; - - await findOrCreateDummyRecord(models.user, user); - - const dueDate = new Date('2021-12-31'); - - const task = { - id: generateId(), - survey_id: survey.id, - entity_id: facility.id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }; - - await findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ); - - return { - task, - user, - }; - }; - - let task; - - before(async () => { - const data = await generateData(); - task = data.task; - }); - - afterEach(() => { - app.revokeAccess(); - }); - - after(async () => { - await resetTestData(); - }); - - describe('POST /taskComments', async () => { - it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { - await app.grantAccess(PUBLIC_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result).to.have.keys('error'); - }); - }); -}); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js deleted file mode 100644 index 7b3be0dfc8..0000000000 --- a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import { - buildAndInsertSurvey, - findOrCreateDummyCountryEntity, - findOrCreateDummyRecord, - generateId, -} from '@tupaia/database'; -import { TestableApp, resetTestData } from '../../testUtilities'; -import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; - -describe('Permissions checker for GETTaskComments', async () => { - const BES_ADMIN_POLICY = { - TO: [BES_ADMIN_PERMISSION_GROUP], - }; - - const DEFAULT_POLICY = { - TO: ['Donor'], - }; - - const PUBLIC_POLICY = { - TO: ['Public'], - }; - - const app = new TestableApp(); - const { models } = app; - - const generateData = async () => { - const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'TO', - name: 'Tonga', - }); - - const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Donor', - }); - - const facility = { - id: generateId(), - code: 'TEST_FACILITY_1', - name: 'Test Facility 1', - country_code: tongaCountry.code, - }; - - await findOrCreateDummyRecord(models.entity, facility); - - const { survey } = await buildAndInsertSurvey(models, { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: donorPermission.id, - country_ids: [tongaCountry.id], - }); - - const user = { - id: generateId(), - first_name: 'Minnie', - last_name: 'Mouse', - }; - await findOrCreateDummyRecord(models.user, user); - - const dueDate = new Date('2021-12-31'); - - const task = { - id: generateId(), - survey_id: survey.id, - entity_id: facility.id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }; - - const comment = { - id: generateId(), - task_id: task.id, - user_id: user.id, - user_name: 'Minnie Mouse', - type: 'user', - message: 'Comment 1', - created_at: new Date('2021-01-01'), - }; - - await findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ); - - await findOrCreateDummyRecord( - models.taskComment, - { - 'task_comment.id': comment.id, - }, - comment, - ); - return { - task, - user, - comment, - }; - }; - - let task; - let comment; - - before(async () => { - const { task: createdTask, comment: createdComment } = await generateData(); - task = createdTask; - comment = createdComment; - }); - - afterEach(() => { - app.revokeAccess(); - }); - - after(async () => { - await resetTestData(); - }); - - describe('GET /tasks/:parentRecordId/taskComments', async () => { - it('Sufficient permissions: returns comments if the user has BES admin access', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - const { body: results } = await app.get(`tasks/${task.id}/taskComments`); - expect(results.length).to.equal(1); - - expect(results[0].id).to.equal(comment.id); - }); - - it('Sufficient permissions: returns comments for the task if user has access to it', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: results } = await app.get(`tasks/${task.id}/taskComments`); - - expect(results.length).to.equal(1); - expect(results[0].id).to.equal(comment.id); - }); - - it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { - await app.grantAccess(PUBLIC_POLICY); - const { body: results } = await app.get(`tasks/${task.id}/taskComments`); - - expect(results).to.have.keys('error'); - }); - }); -}); From 73e9bbf18dfcfccdc8f73ebaa6cca95ec397f993 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:03:06 +1200 Subject: [PATCH 28/38] build fixes --- .../datatrak-web/src/views/Tasks/TaskDetailsPage.tsx | 10 ++++++++-- packages/types/src/schemas/schemas.ts | 1 - 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx b/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx index 302359a6cd..f7c0163461 100644 --- a/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx +++ b/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx @@ -14,7 +14,7 @@ import { TaskDetails, TaskPageHeader, TaskActionsMenu } from '../../features'; import { useTask } from '../../api'; import { ROUTES } from '../../constants'; import { useFromLocation } from '../../utils'; -import { Task } from '../../types'; +import { SingleTaskResponse } from '../../types'; const ButtonWrapper = styled.div` display: flex; @@ -46,7 +46,13 @@ const ErrorModal = ({ isOpen, onClose }) => { ); }; -const ButtonComponent = ({ task, openErrorModal }: { task?: Task; openErrorModal: () => void }) => { +const ButtonComponent = ({ + task, + openErrorModal, +}: { + task?: SingleTaskResponse; + openErrorModal: () => void; +}) => { const from = useFromLocation(); if (!task) return null; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 68158cb07a..46ee95825e 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -87860,7 +87860,6 @@ export const TaskResponseSchema = { }, "required": [ "entity", - "repeatSchedule", "survey", "taskStatus" ] From b7e6a904f4d332b7202a44355ce5763ac32f3545 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:31:52 +1200 Subject: [PATCH 29/38] Add comments --- packages/database/src/modelClasses/Task.js | 55 ++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 48ce315f3d..a3a00cf4f4 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -9,6 +9,13 @@ import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; import { JOIN_TYPES } from '../TupaiaDatabase'; +/** + * + * @description Get a human-friendly name for a field. In most cases, this just replaces underscores with spaces, but there are some special cases like 'assignee_id' and 'repeat_schedule' that need to be handled differently + * + * @param {string} field + * @returns {string} + */ const getFriendlyFieldName = field => { if (field === 'assignee_id') { return 'assignee'; @@ -21,6 +28,14 @@ const getFriendlyFieldName = field => { return field.replace(/_/g, ' '); }; +/** + * @description Format the value of a field for display in a comment. This is used to make the comment more human-readable, and handles special cases like formatting dates and assignee names + * + * @param {string} field + * @param {*} value + * @param {*} models + * @returns {Promise} + */ const formatValue = async (field, value, models) => { switch (field) { case 'assignee_id': { @@ -35,17 +50,18 @@ const formatValue = async (field, value, models) => { return "Doesn't repeat"; } - // TODO: Update this when we add in rrule in RN-1341, to handle date of week/month/year etc return `${value.frequency.charAt(0).toUpperCase()}${value.frequency.slice(1)}`; } case 'due_date': { + // TODO: Currently repeating tasks don't have a due date, so we need to handle null values. In RN-1341 we will add a due date to repeating tasks overnight, so this will need to be updated then if (!value) { return 'No due date'; } + // Format the date as 'd MMMM yy' (e.g. 1 January 21). This is so that there is no ambiguity between US and other date formats return format(new Date(value), 'd MMMM yy'); } default: { - // Default to capitalizing the value and replacing underscores with spaces + // Default to capitalizing the value's first character, and replacing underscores with spaces const words = value.replace(/_/g, ' '); return `${words.charAt(0).toUpperCase()}${words.slice(1)}`; } @@ -87,17 +103,39 @@ export class TaskRecord extends DatabaseRecord { return this.otherModels.survey.findById(this.survey_id); } + /** + * @description Get all comments for the task + * @returns {Promise} + */ + async comments() { return this.otherModels.taskComment.find({ task_id: this.id }); } + /** + * @description Get all user comments for the task + * @returns {Promise} + */ async userComments() { return this.otherModels.taskComment.find({ task_id: this.id, type: 'user' }); } + /** + * @description Get all system comments for the task + * @returns {Promise} + */ + async systemComments() { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } + /** + * @description Add a comment to the task. Handles linking the comment to the task and user, and setting the comment type + * + * @param {string} message + * @param {string} userId + * @param {string} type + * + */ async addComment(message, userId, type) { const user = await this.otherModels.user.findById(userId); @@ -110,16 +148,27 @@ export class TaskRecord extends DatabaseRecord { }); } + /** + * @description Add system comments when a task is updated. This is used to automatically add comments when certain fields are updated, e.g. due date, assignee, etc. + * + * @param {object} updatedFields + * @param {string} userId + */ + async addSystemCommentsOnUpdate(updatedFields, userId) { const fieldsToCreateCommentsFor = ['due_date', 'repeat_schedule', 'status', 'assignee_id']; const comments = []; + // Loop through the updated fields and add a comment for each one that has changed for (const [field, newValue] of Object.entries(updatedFields)) { + // Only create comments for certain fields if (!fieldsToCreateCommentsFor.includes(field)) continue; const originalValue = this[field]; + // If the field hasn't actually changed, don't add a comment if (originalValue === newValue) continue; - // if the due date is changed and the task is repeating, don't add a comment, because this just means that the repeat schedule is being updated, not that the due date is being changed + // If the due date is changed and the task is repeating, don't add a comment, because this just means that the repeat schedule is being updated, not that the due date is being changed. This will likely change as part of RN-1341 + // TODO: re-evaulate this when RN-1341 is implemented if (field === 'due_date' && updatedFields.repeat_schedule) { continue; } From 4b71fd5b403e919afee15b24ad32381637216116 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:34:54 +1200 Subject: [PATCH 30/38] Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. --- packages/central-server/src/apiV2/index.js | 3 + .../apiV2/taskComments/CreateTaskComment.js | 41 +++++ .../src/apiV2/taskComments/GETTaskComments.js | 38 +++++ .../assertTaskCommentPermissions.js | 38 +++++ .../src/apiV2/taskComments/index.js | 7 + .../taskComments/CreateTaskComment.test.js | 159 ++++++++++++++++++ .../taskComments/GETTaskComments.test.js | 149 ++++++++++++++++ 7 files changed, 435 insertions(+) create mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js create mode 100644 packages/central-server/src/apiV2/taskComments/GETTaskComments.js create mode 100644 packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js create mode 100644 packages/central-server/src/apiV2/taskComments/index.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..1535c0158a 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,6 +145,7 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; +import { CreateTaskComment, GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -269,6 +270,7 @@ apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); +apiV2.get('/tasks/:parentRecordId/taskComments', useRouteHandler(GETTaskComments)); /** * POST routes */ @@ -316,6 +318,7 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); +apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js new file mode 100644 index 0000000000..d131fec8ee --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -0,0 +1,41 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { RECORDS } from '@tupaia/database'; +import { CreateHandler } from '../CreateHandler'; +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; + +/** + * Handles POST endpoints: + * - /tasks/:parentRecordId/taskComments + */ + +export class CreateTaskComment extends CreateHandler { + parentRecordType = RECORDS.TASK; + + async assertUserHasAccess() { + const createPermissionChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); + + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), + ); + } + + async createRecord() { + const { id: userId } = this.req.user; + const user = await this.models.user.findById(userId); // Check if user exists + if (!user) { + throw new Error(`User with id ${userId} not found`); + } + + const { full_name: userFullName } = user; + this.newRecordData.user_id = userId; + this.newRecordData.user_name = userFullName; + this.newRecordData.task_id = this.parentRecordId; + + return this.insertRecord(); + } +} diff --git a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js new file mode 100644 index 0000000000..e0e4838fd4 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js @@ -0,0 +1,38 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { GETHandler } from '../GETHandler'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; +import { createTaskCommentDBFilter } from './assertTaskCommentPermissions'; + +/** + * Handles endpoints: + * - /tasks/:taskId/comments + */ + +export class GETTaskComments extends GETHandler { + permissionsFilteredInternally = true; + + async getPermissionsFilter(criteria, options) { + return createTaskCommentDBFilter(this.accessPolicy, this.models, criteria, options); + } + + async getPermissionsViaParentFilter(criteria, options) { + const taskPermissionsChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, taskPermissionsChecker]), + ); + // Filter by parent + const dbConditions = { 'task_comment.task_id': this.parentRecordId, ...criteria }; + + // Apply regular permissions + return { + dbConditions, + dbOptions: options, + }; + } +} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js new file mode 100644 index 0000000000..3136b4f049 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -0,0 +1,38 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { hasBESAdminAccess } from '../../permissions'; +import { createTaskDBFilter } from '../tasks/assertTaskPermissions'; + +export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { + if (hasBESAdminAccess(accessPolicy)) { + return { dbConditions: criteria, dbOptions: options }; + } + const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); + + const taskIds = await models.task.find( + { + ...dbConditions, + id: criteria.task_id ?? undefined, + }, + { columns: ['task.id'] }, + ); + + if (!taskIds.length) { + // if the user doesn't have access to any tasks, return a condition that will return no results + return { dbConditions: { id: -1 }, dbOptions: options }; + } + + return { + dbConditions: { + ...criteria, + task_id: { + comparator: 'IN', + comparisonValue: taskIds.map(task => task.id), // this will include any task_id filters because the list of tasks was already filtered by the dbConditions + }, + }, + dbOptions: options, + }; +}; diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js new file mode 100644 index 0000000000..e6c87c1220 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { GETTaskComments } from './GETTaskComments'; +export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js new file mode 100644 index 0000000000..3d8f83b49d --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -0,0 +1,159 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for CreateTaskComment', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + }; + + const PUBLIC_POLICY = { + DL: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + + const BASE_COMMENT = { + message: 'This is a test comment', + type: 'user', + }; + + const generateData = async () => { + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + + const facility = { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }; + + await findOrCreateDummyRecord(models.entity, facility); + + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [dlCountry.id], + }); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + const task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility.id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; + + await findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ); + + return { + task, + user, + }; + }; + + let task; + + before(async () => { + const data = await generateData(); + task = data.task; + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('POST /taskComments', async () => { + it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result).to.have.keys('error'); + }); + }); +}); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js new file mode 100644 index 0000000000..7b3be0dfc8 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -0,0 +1,149 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for GETTaskComments', async () => { + const BES_ADMIN_POLICY = { + TO: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + TO: ['Donor'], + }; + + const PUBLIC_POLICY = { + TO: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + + const generateData = async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + + const facility = { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }; + + await findOrCreateDummyRecord(models.entity, facility); + + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id], + }); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + const task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility.id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; + + const comment = { + id: generateId(), + task_id: task.id, + user_id: user.id, + user_name: 'Minnie Mouse', + type: 'user', + message: 'Comment 1', + created_at: new Date('2021-01-01'), + }; + + await findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ); + + await findOrCreateDummyRecord( + models.taskComment, + { + 'task_comment.id': comment.id, + }, + comment, + ); + return { + task, + user, + comment, + }; + }; + + let task; + let comment; + + before(async () => { + const { task: createdTask, comment: createdComment } = await generateData(); + task = createdTask; + comment = createdComment; + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('GET /tasks/:parentRecordId/taskComments', async () => { + it('Sufficient permissions: returns comments if the user has BES admin access', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + expect(results.length).to.equal(1); + + expect(results[0].id).to.equal(comment.id); + }); + + it('Sufficient permissions: returns comments for the task if user has access to it', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + + expect(results.length).to.equal(1); + expect(results[0].id).to.equal(comment.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + + expect(results).to.have.keys('error'); + }); + }); +}); From 3d4651127d1dcf852a8067a8c442072c6ffca8a6 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:40:26 +1200 Subject: [PATCH 31/38] Remove task comment create endpoint as no longer needed --- packages/central-server/src/apiV2/index.js | 3 +- .../apiV2/taskComments/CreateTaskComment.js | 41 ----- .../assertTaskCommentPermissions.js | 2 +- .../src/apiV2/taskComments/index.js | 1 - .../taskComments/CreateTaskComment.test.js | 159 ------------------ 5 files changed, 2 insertions(+), 204 deletions(-) delete mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js delete mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 1535c0158a..9ec8dacfb8 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,7 +145,7 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; -import { CreateTaskComment, GETTaskComments } from './taskComments'; +import { GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -318,7 +318,6 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); -apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js deleted file mode 100644 index d131fec8ee..0000000000 --- a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ -import { RECORDS } from '@tupaia/database'; -import { CreateHandler } from '../CreateHandler'; -import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; -import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; - -/** - * Handles POST endpoints: - * - /tasks/:parentRecordId/taskComments - */ - -export class CreateTaskComment extends CreateHandler { - parentRecordType = RECORDS.TASK; - - async assertUserHasAccess() { - const createPermissionChecker = accessPolicy => - assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); - - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), - ); - } - - async createRecord() { - const { id: userId } = this.req.user; - const user = await this.models.user.findById(userId); // Check if user exists - if (!user) { - throw new Error(`User with id ${userId} not found`); - } - - const { full_name: userFullName } = user; - this.newRecordData.user_id = userId; - this.newRecordData.user_name = userFullName; - this.newRecordData.task_id = this.parentRecordId; - - return this.insertRecord(); - } -} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js index 3136b4f049..c2e4f87325 100644 --- a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -10,7 +10,7 @@ export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, if (hasBESAdminAccess(accessPolicy)) { return { dbConditions: criteria, dbOptions: options }; } - const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); + const { dbConditions } = await createTaskDBFilter(accessPolicy, models); const taskIds = await models.task.find( { diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js index e6c87c1220..d5760663d3 100644 --- a/packages/central-server/src/apiV2/taskComments/index.js +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -4,4 +4,3 @@ */ export { GETTaskComments } from './GETTaskComments'; -export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js deleted file mode 100644 index 3d8f83b49d..0000000000 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import { - buildAndInsertSurvey, - findOrCreateDummyCountryEntity, - findOrCreateDummyRecord, - generateId, -} from '@tupaia/database'; -import { TestableApp, resetTestData } from '../../testUtilities'; -import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; - -describe('Permissions checker for CreateTaskComment', async () => { - const BES_ADMIN_POLICY = { - DL: [BES_ADMIN_PERMISSION_GROUP], - }; - - const DEFAULT_POLICY = { - DL: ['Donor'], - }; - - const PUBLIC_POLICY = { - DL: ['Public'], - }; - - const app = new TestableApp(); - const { models } = app; - - const BASE_COMMENT = { - message: 'This is a test comment', - type: 'user', - }; - - const generateData = async () => { - const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'DL', - name: 'Demo Land', - }); - - const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Donor', - }); - - const facility = { - id: generateId(), - code: 'TEST_FACILITY_2', - name: 'Test Facility 2', - country_code: dlCountry.code, - }; - - await findOrCreateDummyRecord(models.entity, facility); - - const { survey } = await buildAndInsertSurvey(models, { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: donorPermission.id, - country_ids: [dlCountry.id], - }); - - const user = { - id: generateId(), - first_name: 'Minnie', - last_name: 'Mouse', - }; - - await findOrCreateDummyRecord(models.user, user); - - const dueDate = new Date('2021-12-31'); - - const task = { - id: generateId(), - survey_id: survey.id, - entity_id: facility.id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }; - - await findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ); - - return { - task, - user, - }; - }; - - let task; - - before(async () => { - const data = await generateData(); - task = data.task; - }); - - afterEach(() => { - app.revokeAccess(); - }); - - after(async () => { - await resetTestData(); - }); - - describe('POST /taskComments', async () => { - it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { - await app.grantAccess(PUBLIC_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result).to.have.keys('error'); - }); - }); -}); From baf0fa87bc6143d7e280603e8f3d7156621d81c9 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:41:15 +1200 Subject: [PATCH 32/38] Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. --- packages/central-server/src/apiV2/index.js | 3 + .../apiV2/taskComments/CreateTaskComment.js | 41 +++++ .../src/apiV2/taskComments/GETTaskComments.js | 38 +++++ .../assertTaskCommentPermissions.js | 38 +++++ .../src/apiV2/taskComments/index.js | 7 + .../taskComments/CreateTaskComment.test.js | 159 ++++++++++++++++++ .../taskComments/GETTaskComments.test.js | 149 ++++++++++++++++ 7 files changed, 435 insertions(+) create mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js create mode 100644 packages/central-server/src/apiV2/taskComments/GETTaskComments.js create mode 100644 packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js create mode 100644 packages/central-server/src/apiV2/taskComments/index.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js create mode 100644 packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..1535c0158a 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,6 +145,7 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; +import { CreateTaskComment, GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -269,6 +270,7 @@ apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); +apiV2.get('/tasks/:parentRecordId/taskComments', useRouteHandler(GETTaskComments)); /** * POST routes */ @@ -316,6 +318,7 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); +apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js new file mode 100644 index 0000000000..d131fec8ee --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -0,0 +1,41 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { RECORDS } from '@tupaia/database'; +import { CreateHandler } from '../CreateHandler'; +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; + +/** + * Handles POST endpoints: + * - /tasks/:parentRecordId/taskComments + */ + +export class CreateTaskComment extends CreateHandler { + parentRecordType = RECORDS.TASK; + + async assertUserHasAccess() { + const createPermissionChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); + + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), + ); + } + + async createRecord() { + const { id: userId } = this.req.user; + const user = await this.models.user.findById(userId); // Check if user exists + if (!user) { + throw new Error(`User with id ${userId} not found`); + } + + const { full_name: userFullName } = user; + this.newRecordData.user_id = userId; + this.newRecordData.user_name = userFullName; + this.newRecordData.task_id = this.parentRecordId; + + return this.insertRecord(); + } +} diff --git a/packages/central-server/src/apiV2/taskComments/GETTaskComments.js b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js new file mode 100644 index 0000000000..e0e4838fd4 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/GETTaskComments.js @@ -0,0 +1,38 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { GETHandler } from '../GETHandler'; +import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; +import { createTaskCommentDBFilter } from './assertTaskCommentPermissions'; + +/** + * Handles endpoints: + * - /tasks/:taskId/comments + */ + +export class GETTaskComments extends GETHandler { + permissionsFilteredInternally = true; + + async getPermissionsFilter(criteria, options) { + return createTaskCommentDBFilter(this.accessPolicy, this.models, criteria, options); + } + + async getPermissionsViaParentFilter(criteria, options) { + const taskPermissionsChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, taskPermissionsChecker]), + ); + // Filter by parent + const dbConditions = { 'task_comment.task_id': this.parentRecordId, ...criteria }; + + // Apply regular permissions + return { + dbConditions, + dbOptions: options, + }; + } +} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js new file mode 100644 index 0000000000..3136b4f049 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -0,0 +1,38 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { hasBESAdminAccess } from '../../permissions'; +import { createTaskDBFilter } from '../tasks/assertTaskPermissions'; + +export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, options) => { + if (hasBESAdminAccess(accessPolicy)) { + return { dbConditions: criteria, dbOptions: options }; + } + const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); + + const taskIds = await models.task.find( + { + ...dbConditions, + id: criteria.task_id ?? undefined, + }, + { columns: ['task.id'] }, + ); + + if (!taskIds.length) { + // if the user doesn't have access to any tasks, return a condition that will return no results + return { dbConditions: { id: -1 }, dbOptions: options }; + } + + return { + dbConditions: { + ...criteria, + task_id: { + comparator: 'IN', + comparisonValue: taskIds.map(task => task.id), // this will include any task_id filters because the list of tasks was already filtered by the dbConditions + }, + }, + dbOptions: options, + }; +}; diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js new file mode 100644 index 0000000000..e6c87c1220 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { GETTaskComments } from './GETTaskComments'; +export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js new file mode 100644 index 0000000000..3d8f83b49d --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -0,0 +1,159 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for CreateTaskComment', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + }; + + const PUBLIC_POLICY = { + DL: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + + const BASE_COMMENT = { + message: 'This is a test comment', + type: 'user', + }; + + const generateData = async () => { + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + + const facility = { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }; + + await findOrCreateDummyRecord(models.entity, facility); + + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [dlCountry.id], + }); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + const task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility.id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; + + await findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ); + + return { + task, + user, + }; + }; + + let task; + + before(async () => { + const data = await generateData(); + task = data.task; + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('POST /taskComments', async () => { + it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result.message).to.equal('Successfully created taskComments'); + const taskComment = await models.taskComment.findOne({ + task_id: task.id, + message: BASE_COMMENT.message, + }); + + expect(taskComment).to.not.be.undefined; + + expect(taskComment.type).to.equal(BASE_COMMENT.type); + expect(taskComment.user_name).to.equal('Test User'); + expect(taskComment.task_id).to.equal(task.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { + body: BASE_COMMENT, + }); + + expect(result).to.have.keys('error'); + }); + }); +}); diff --git a/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js new file mode 100644 index 0000000000..7b3be0dfc8 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/GETTaskComments.test.js @@ -0,0 +1,149 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for GETTaskComments', async () => { + const BES_ADMIN_POLICY = { + TO: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + TO: ['Donor'], + }; + + const PUBLIC_POLICY = { + TO: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + + const generateData = async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + + const facility = { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }; + + await findOrCreateDummyRecord(models.entity, facility); + + const { survey } = await buildAndInsertSurvey(models, { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id], + }); + + const user = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + await findOrCreateDummyRecord(models.user, user); + + const dueDate = new Date('2021-12-31'); + + const task = { + id: generateId(), + survey_id: survey.id, + entity_id: facility.id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }; + + const comment = { + id: generateId(), + task_id: task.id, + user_id: user.id, + user_name: 'Minnie Mouse', + type: 'user', + message: 'Comment 1', + created_at: new Date('2021-01-01'), + }; + + await findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ); + + await findOrCreateDummyRecord( + models.taskComment, + { + 'task_comment.id': comment.id, + }, + comment, + ); + return { + task, + user, + comment, + }; + }; + + let task; + let comment; + + before(async () => { + const { task: createdTask, comment: createdComment } = await generateData(); + task = createdTask; + comment = createdComment; + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('GET /tasks/:parentRecordId/taskComments', async () => { + it('Sufficient permissions: returns comments if the user has BES admin access', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + expect(results.length).to.equal(1); + + expect(results[0].id).to.equal(comment.id); + }); + + it('Sufficient permissions: returns comments for the task if user has access to it', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + + expect(results.length).to.equal(1); + expect(results[0].id).to.equal(comment.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: results } = await app.get(`tasks/${task.id}/taskComments`); + + expect(results).to.have.keys('error'); + }); + }); +}); From d0c82a2ccaef476473bd78fe5ae541810bbd0f48 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:42:55 +1200 Subject: [PATCH 33/38] Fix revert --- packages/central-server/src/apiV2/index.js | 3 +- .../apiV2/taskComments/CreateTaskComment.js | 41 ----- .../assertTaskCommentPermissions.js | 2 +- .../src/apiV2/taskComments/index.js | 1 - .../taskComments/CreateTaskComment.test.js | 159 ------------------ 5 files changed, 2 insertions(+), 204 deletions(-) delete mode 100644 packages/central-server/src/apiV2/taskComments/CreateTaskComment.js delete mode 100644 packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 1535c0158a..9ec8dacfb8 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -145,7 +145,7 @@ import { } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; import { CreateTask, EditTask, GETTasks } from './tasks'; -import { CreateTaskComment, GETTaskComments } from './taskComments'; +import { GETTaskComments } from './taskComments'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -318,7 +318,6 @@ apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/tasks', useRouteHandler(CreateTask)); -apiV2.post('/tasks/:parentRecordId/taskComments', useRouteHandler(CreateTaskComment)); /** * PUT routes */ diff --git a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js deleted file mode 100644 index d131fec8ee..0000000000 --- a/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ -import { RECORDS } from '@tupaia/database'; -import { CreateHandler } from '../CreateHandler'; -import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; -import { assertUserHasTaskPermissions } from '../tasks/assertTaskPermissions'; - -/** - * Handles POST endpoints: - * - /tasks/:parentRecordId/taskComments - */ - -export class CreateTaskComment extends CreateHandler { - parentRecordType = RECORDS.TASK; - - async assertUserHasAccess() { - const createPermissionChecker = accessPolicy => - assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); - - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), - ); - } - - async createRecord() { - const { id: userId } = this.req.user; - const user = await this.models.user.findById(userId); // Check if user exists - if (!user) { - throw new Error(`User with id ${userId} not found`); - } - - const { full_name: userFullName } = user; - this.newRecordData.user_id = userId; - this.newRecordData.user_name = userFullName; - this.newRecordData.task_id = this.parentRecordId; - - return this.insertRecord(); - } -} diff --git a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js index 3136b4f049..c2e4f87325 100644 --- a/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js +++ b/packages/central-server/src/apiV2/taskComments/assertTaskCommentPermissions.js @@ -10,7 +10,7 @@ export const createTaskCommentDBFilter = async (accessPolicy, models, criteria, if (hasBESAdminAccess(accessPolicy)) { return { dbConditions: criteria, dbOptions: options }; } - const { dbConditions } = await createTaskDBFilter(accessPolicy, models, criteria, options); + const { dbConditions } = await createTaskDBFilter(accessPolicy, models); const taskIds = await models.task.find( { diff --git a/packages/central-server/src/apiV2/taskComments/index.js b/packages/central-server/src/apiV2/taskComments/index.js index e6c87c1220..d5760663d3 100644 --- a/packages/central-server/src/apiV2/taskComments/index.js +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -4,4 +4,3 @@ */ export { GETTaskComments } from './GETTaskComments'; -export { CreateTaskComment } from './CreateTaskComment'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js deleted file mode 100644 index 3d8f83b49d..0000000000 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import { - buildAndInsertSurvey, - findOrCreateDummyCountryEntity, - findOrCreateDummyRecord, - generateId, -} from '@tupaia/database'; -import { TestableApp, resetTestData } from '../../testUtilities'; -import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; - -describe('Permissions checker for CreateTaskComment', async () => { - const BES_ADMIN_POLICY = { - DL: [BES_ADMIN_PERMISSION_GROUP], - }; - - const DEFAULT_POLICY = { - DL: ['Donor'], - }; - - const PUBLIC_POLICY = { - DL: ['Public'], - }; - - const app = new TestableApp(); - const { models } = app; - - const BASE_COMMENT = { - message: 'This is a test comment', - type: 'user', - }; - - const generateData = async () => { - const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'DL', - name: 'Demo Land', - }); - - const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Donor', - }); - - const facility = { - id: generateId(), - code: 'TEST_FACILITY_2', - name: 'Test Facility 2', - country_code: dlCountry.code, - }; - - await findOrCreateDummyRecord(models.entity, facility); - - const { survey } = await buildAndInsertSurvey(models, { - code: 'TEST_SURVEY_1', - name: 'Test Survey 1', - permission_group_id: donorPermission.id, - country_ids: [dlCountry.id], - }); - - const user = { - id: generateId(), - first_name: 'Minnie', - last_name: 'Mouse', - }; - - await findOrCreateDummyRecord(models.user, user); - - const dueDate = new Date('2021-12-31'); - - const task = { - id: generateId(), - survey_id: survey.id, - entity_id: facility.id, - due_date: dueDate, - status: 'to_do', - repeat_schedule: null, - }; - - await findOrCreateDummyRecord( - models.task, - { - 'task.id': task.id, - }, - task, - ); - - return { - task, - user, - }; - }; - - let task; - - before(async () => { - const data = await generateData(); - task = data.task; - }); - - afterEach(() => { - app.revokeAccess(); - }); - - after(async () => { - await resetTestData(); - }); - - describe('POST /taskComments', async () => { - it('Sufficient permissions: allows a user to create a task comment if they have BES Admin permission', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Sufficient permissions: allows a user to create a task comment if they have access to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result.message).to.equal('Successfully created taskComments'); - const taskComment = await models.taskComment.findOne({ - task_id: task.id, - message: BASE_COMMENT.message, - }); - - expect(taskComment).to.not.be.undefined; - - expect(taskComment.type).to.equal(BASE_COMMENT.type); - expect(taskComment.user_name).to.equal('Test User'); - expect(taskComment.task_id).to.equal(task.id); - }); - - it('Insufficient permissions: throws an error if the user does not have access to the task', async () => { - await app.grantAccess(PUBLIC_POLICY); - const { body: result } = await app.post(`tasks/${task.id}/taskComments`, { - body: BASE_COMMENT, - }); - - expect(result).to.have.keys('error'); - }); - }); -}); From 3739d5bbb24c00846e9f1dc9f0c926fd21b4ea02 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:29:58 +1200 Subject: [PATCH 34/38] Handle recurring task comments --- .../TaskCompletionHandler.test.js | 18 +++++++++++++++ .../changeHandlers/TaskCompletionHandler.js | 23 ++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js index e6a36ec052..cb8f36a4cf 100644 --- a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js +++ b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js @@ -95,6 +95,12 @@ describe('TaskCompletionHandler', () => { { entity_id: tonga.id, date: '2024-07-21' }, ]); await assertTaskStatus(task.id, 'completed', responseIds[0]); + const comments = await models.taskComment.find({ task_id: task.id }); + expect(comments[0]).toMatchObject({ + message: 'Completed this task', + user_id: userId, + type: 'system', + }); }); it('created response marks associated tasks as completed if created_time === data_time', async () => { @@ -106,6 +112,18 @@ describe('TaskCompletionHandler', () => { await createResponses([{ entity_id: tonga.id, date: '2021-07-08' }]); await assertTaskStatus(task.id, 'to_do', null); }); + + it('created response does not mark associated tasks as completed if status is null, but should still create a comment', async () => { + await models.task.update({ id: task.id }, { status: null }); + await createResponses([{ entity_id: tonga.id, date: '2021-07-08' }]); + await assertTaskStatus(task.id, null, null); + const comments = await models.taskComment.find({ task_id: task.id }); + expect(comments[0]).toMatchObject({ + message: 'Completed this task', + user_id: userId, + type: 'system', + }); + }); }); describe('updating a survey response', () => { diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js index a356b0f784..255eb8490c 100644 --- a/packages/database/src/changeHandlers/TaskCompletionHandler.js +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -42,9 +42,15 @@ export class TaskCompletionHandler extends ChangeHandler { })), ); - const tasks = await this.models.task.find({ - // only fetch tasks that have a status of 'to_do' + return this.models.task.find({ + // only fetch tasks that have a status of 'to_do' or null (repeating tasks have a status of null) status: 'to_do', + [QUERY_CONJUNCTIONS.OR]: { + status: { + comparator: 'IS', + comparisonValue: null, + }, + }, [QUERY_CONJUNCTIONS.RAW]: { sql: `${surveyIdAndEntityIdPairs .map(() => `(survey_id = ? AND entity_id = ? AND created_at <= ?)`) @@ -56,8 +62,6 @@ export class TaskCompletionHandler extends ChangeHandler { ]), }, }); - - return tasks; } async handleChanges(transactingModels, changedResponses) { @@ -79,10 +83,13 @@ export class TaskCompletionHandler extends ChangeHandler { if (!matchingSurveyResponse) continue; - await transactingModels.task.updateById(id, { - status: 'completed', - survey_response_id: matchingSurveyResponse.id, - }); + // only update the task status if it is not repeating + if (task.status !== null) { + await transactingModels.task.updateById(id, { + status: 'completed', + survey_response_id: matchingSurveyResponse.id, + }); + } await task.addComment('Completed this task', matchingSurveyResponse.user_id, 'system'); } From b92b94f819229473daefeb238739d7b07da83257 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:19:25 +1200 Subject: [PATCH 35/38] feat(datatrakWeb): RN-1331: Task comments UI (#5801) * Create task with comments * View comments on task details * Allow create comment on edit task * Display comments count on table * Show comment count on tasks tile on landing page * Fix up types * Order comments descending * Update schemas.ts * Move comment adding to central server * Update schemas.ts * Fix types * build fixes * Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. * Remove task comment create endpoint as no longer needed --- .../src/apiV2/tasks/CreateTask.js | 12 +++- .../src/apiV2/tasks/EditTask.js | 14 ++++- .../src/tests/apiV2/tasks/CreateTask.test.js | 19 ++++++ .../src/tests/apiV2/tasks/EditTask.test.js | 47 +++++++++++++++ packages/database/src/modelClasses/Task.js | 11 ++++ .../src/routes/CreateTaskRoute.ts | 2 +- .../src/routes/EditTaskRoute.ts | 4 +- .../src/routes/TaskRoute.ts | 10 +++- .../src/routes/TasksRoute.ts | 26 ++++---- packages/datatrak-web-server/src/types.ts | 2 + .../src/utils/formatTaskChanges.ts | 2 + .../src/utils/formatTaskResponse.ts | 27 +++++++-- .../datatrak-web/src/api/queries/useTask.ts | 4 +- .../src/components/Icons/CommentIcon.tsx | 26 ++++++++ .../src/components/Icons/index.ts | 1 + .../src/features/Tasks/CommentsCount.tsx | 34 +++++++++++ .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 4 +- .../src/features/Tasks/TaskActionsMenu.tsx | 4 +- .../Tasks/TaskDetails/TaskComments.tsx | 59 +++++++++++++++++++ .../Tasks/TaskDetails/TaskDetails.tsx | 25 +++----- .../Tasks/TaskDetails/TaskMetadata.tsx | 4 +- .../src/features/Tasks/TaskTile.tsx | 28 +-------- .../Tasks/TasksTable/ActionButton.tsx | 6 +- .../Tasks/TasksTable/AssignTaskModal.tsx | 6 +- .../features/Tasks/TasksTable/TasksTable.tsx | 28 ++++++++- packages/datatrak-web/src/types/task.ts | 6 +- packages/datatrak-web/src/utils/date.ts | 12 ++++ .../src/views/Tasks/TaskDetailsPage.tsx | 10 +++- .../src/models/TaskComment.ts | 14 +++++ .../server-boilerplate/src/models/index.ts | 1 + packages/types/src/schemas/schemas.ts | 2 - .../datatrak-web-server/TaskChangeRequest.ts | 5 +- .../datatrak-web-server/TaskRequest.ts | 10 +++- .../datatrak-web-server/TasksRequest.ts | 8 ++- 34 files changed, 383 insertions(+), 90 deletions(-) create mode 100644 packages/datatrak-web/src/components/Icons/CommentIcon.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CommentsCount.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx create mode 100644 packages/server-boilerplate/src/models/TaskComment.ts diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index d1eda45751..0d648537c6 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,6 +21,16 @@ export class CreateTask extends CreateHandler { } async createRecord() { - await this.insertRecord(); + const { comment, ...data } = this.newRecordData; + + return this.models.wrapInTransaction(async transactingModels => { + const task = await transactingModels.task.create(data); + if (comment) { + await task.addComment(comment, this.req.user.id); + } + return { + id: task.id, + }; + }); } } diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js index 543a051620..0f06ee1ea7 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -15,6 +15,18 @@ export class EditTask extends EditHandler { } async editRecord() { - await this.updateRecord(); + const { comment, ...updatedFields } = this.updatedFields; + return this.models.wrapInTransaction(async transactingModels => { + const originalTask = await transactingModels.task.findById(this.recordId); + let task = originalTask; + // Sometimes an update can just be a comment, so we don't want to update the task if there are no fields to update, because we would get an error + if (Object.keys(updatedFields).length > 0) { + task = await transactingModels.task.updateById(this.recordId, updatedFields); + } + if (comment) { + await originalTask.addComment(comment, this.req.user.id); + } + return task; + }); } } diff --git a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js index 8add4378fe..4790269b1d 100644 --- a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js @@ -135,5 +135,24 @@ describe('Permissions checker for CreateTask', async () => { }); expect(result).to.have.keys('error'); }); + + it('Handles creating a task with a comment', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post('tasks', { + body: { + ...BASE_TASK, + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + comment: 'This is a comment', + }, + }); + + expect(result.message).to.equal('Successfully created tasks'); + const comment = await models.taskComment.findOne({ + task_id: result.id, + message: 'This is a comment', + }); + expect(comment).not.to.be.undefined; + }); }); }); diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js index 1bda0247a9..a0f51382b0 100644 --- a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -207,5 +207,52 @@ describe('Permissions checker for EditTask', async () => { expect(result).to.have.keys('error'); expect(result.error).to.include('Need to have access to the new entity of the task'); }); + + it('Handles adding a comment when editing a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.undefined; + }); + + it('Handles adding a comment when no other edits are made', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(tasks[1].entity_id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.undefined; + }); }); }); diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 332f2df968..90759ec43a 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -55,6 +55,17 @@ export class TaskRecord extends DatabaseRecord { async systemComments() { return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); } + + async addComment(message, userId, type = 'user') { + const user = await this.otherModels.user.findById(userId); + return this.otherModels.taskComment.create({ + message, + task_id: this.id, + user_id: userId, + user_name: user.full_name, + type, + }); + } } export class TaskModel extends DatabaseModel { diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts index 63a9dce5a0..c03306c6a9 100644 --- a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -19,7 +19,7 @@ export class CreateTaskRoute extends Route { public async buildResponse() { const { models, body, ctx } = this.req; - const { surveyCode, entityId } = body; + const { surveyCode, entityId, comment } = body; const survey = await models.survey.findOne({ code: surveyCode }); if (!survey) { diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index 1336129309..eebaabf582 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -10,11 +10,11 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { formatTaskChanges } from '../utils'; -import { DatatrakWebTaskChangeRequest } from '@tupaia/types'; +import { DatatrakWebTaskChangeRequest, TaskCommentType } from '@tupaia/types'; export type EditTaskRequest = Request< { taskId: string }, - Record, + { message: string }, Partial, Record >; diff --git a/packages/datatrak-web-server/src/routes/TaskRoute.ts b/packages/datatrak-web-server/src/routes/TaskRoute.ts index af54de4782..3afabb757e 100644 --- a/packages/datatrak-web-server/src/routes/TaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/TaskRoute.ts @@ -7,6 +7,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebTaskRequest } from '@tupaia/types'; import { TaskT, formatTaskResponse } from '../utils'; +import camelcaseKeys from 'camelcase-keys'; export type TaskRequest = Request< DatatrakWebTaskRequest.Params, @@ -43,6 +44,13 @@ export class TaskRoute extends Route { throw new Error(`Task with id ${taskId} not found`); } - return formatTaskResponse(task); + const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`, { + sort: ['created_at DESC'], + }); + + return { + ...formatTaskResponse(task), + comments: camelcaseKeys(comments, { deep: true }), + }; } } diff --git a/packages/datatrak-web-server/src/routes/TasksRoute.ts b/packages/datatrak-web-server/src/routes/TasksRoute.ts index bacddce998..e8f63e882e 100644 --- a/packages/datatrak-web-server/src/routes/TasksRoute.ts +++ b/packages/datatrak-web-server/src/routes/TasksRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { parse } from 'cookie'; -import { DatatrakWebTasksRequest, Task, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTasksRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; import { RECORDS } from '@tupaia/database'; import { TaskT, formatTaskResponse } from '../utils'; @@ -33,13 +33,6 @@ const FIELDS = [ const DEFAULT_PAGE_SIZE = 20; -type SingleTask = Task & { - 'survey.name': string; - 'survey.code': string; - 'entity.name': string; - 'entity.country_code': string; -}; - type FormattedFilters = Record; const EQUALITY_FILTERS = ['due_date', 'survey.project_id', 'task_status']; @@ -142,7 +135,7 @@ export class TasksRoute extends Route { } public async buildResponse() { - const { ctx, query = {} } = this.req; + const { ctx, query = {}, models } = this.req; const { pageSize = DEFAULT_PAGE_SIZE, sort, page = 0 } = query; this.formatFilters(); @@ -161,7 +154,20 @@ export class TasksRoute extends Route { page, }); - const formattedTasks = tasks.map((task: TaskT) => formatTaskResponse(task)); + const formattedTasks = (await Promise.all( + tasks.map(async (task: TaskT) => { + const formattedTask = formatTaskResponse(task); + // Get comment count for each task + const commentsCount = await models.taskComment.count({ + task_id: task.id, + type: TaskCommentType.user, + }); + return { + ...formattedTask, + commentsCount, + }; + }), + )) as DatatrakWebTasksRequest.ResBody['tasks']; // Get all task ids for pagination const count = await this.queryForCount(); diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index 0081748abb..1c9e07d491 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -12,6 +12,7 @@ import { PermissionGroupModel, SurveyModel, SurveyResponseModel, + TaskCommentModel, TaskModel, UserEntityPermissionModel, UserModel, @@ -29,4 +30,5 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly task: TaskModel; readonly permissionGroup: PermissionGroupModel; readonly userEntityPermission: UserEntityPermissionModel; + readonly taskComment: TaskCommentModel; } diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index 2380d12f97..b03437c352 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -11,6 +11,7 @@ type Input = Partial & type Output = Partial> & { due_date?: string | null; + comment?: string; }; export const formatTaskChanges = (task: Input) => { @@ -36,6 +37,7 @@ export const formatTaskChanges = (task: Input) => { const withoutTimezone = stripTimezoneFromDate(endOfDay); taskDetails.due_date = withoutTimezone; + taskDetails.repeat_schedule = null; } return taskDetails; diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 71397a850c..0ddd5a9005 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -3,18 +3,29 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { DatatrakWebTaskRequest, Entity, Survey, Task } from '@tupaia/types'; +import { + DatatrakWebTaskRequest, + DatatrakWebTasksRequest, + Entity, + KeysToCamelCase, + Survey, + Task, + TaskStatus, +} from '@tupaia/types'; import camelcaseKeys from 'camelcase-keys'; -export type TaskT = Omit & { +export type TaskT = Omit & { 'entity.name': Entity['name']; 'entity.country_code': string; 'survey.code': Survey['code']; 'survey.name': Survey['name']; - task_status: DatatrakWebTaskRequest.ResBody['taskStatus']; + task_status: TaskStatus | 'overdue' | 'repeating'; + repeat_schedule?: Record | null; }; -export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody => { +type FormattedTask = DatatrakWebTasksRequest.TaskResponse; + +export const formatTaskResponse = (task: TaskT): FormattedTask => { const { entity_id: entityId, 'entity.name': entityName, @@ -23,12 +34,12 @@ export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody survey_id: surveyId, 'survey.name': surveyName, task_status: taskStatus, + repeat_schedule: repeatSchedule, ...rest } = task; const formattedTask = { ...rest, - taskStatus, entity: { id: entityId, name: entityName, @@ -39,7 +50,11 @@ export const formatTaskResponse = (task: TaskT): DatatrakWebTaskRequest.ResBody name: surveyName, code: surveyCode, }, + taskStatus, + repeatSchedule, }; - return camelcaseKeys(formattedTask) as DatatrakWebTaskRequest.ResBody; + return camelcaseKeys(formattedTask, { + deep: true, + }); }; diff --git a/packages/datatrak-web/src/api/queries/useTask.ts b/packages/datatrak-web/src/api/queries/useTask.ts index cb5e2134cc..19be0ef6f7 100644 --- a/packages/datatrak-web/src/api/queries/useTask.ts +++ b/packages/datatrak-web/src/api/queries/useTask.ts @@ -4,13 +4,13 @@ */ import { useQuery } from 'react-query'; -import { DatatrakWebTasksRequest } from '@tupaia/types'; +import { DatatrakWebTaskRequest } from '@tupaia/types'; import { get } from '../api'; export const useTask = (taskId?: string) => { return useQuery( ['tasks', taskId], - (): Promise => get(`tasks/${taskId}`), + (): Promise => get(`tasks/${taskId}`), { enabled: !!taskId, }, diff --git a/packages/datatrak-web/src/components/Icons/CommentIcon.tsx b/packages/datatrak-web/src/components/Icons/CommentIcon.tsx new file mode 100644 index 0000000000..c0e6d6c914 --- /dev/null +++ b/packages/datatrak-web/src/components/Icons/CommentIcon.tsx @@ -0,0 +1,26 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const CommentIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/datatrak-web/src/components/Icons/index.ts b/packages/datatrak-web/src/components/Icons/index.ts index 00a378286f..a6db7c9f18 100644 --- a/packages/datatrak-web/src/components/Icons/index.ts +++ b/packages/datatrak-web/src/components/Icons/index.ts @@ -13,3 +13,4 @@ export { PinIcon } from './PinIcon'; export { ReportsIcon } from './ReportsIcon'; export { CopyIcon } from './CopyIcon'; export { TaskIcon } from './TaskIcon'; +export { CommentIcon } from './CommentIcon'; diff --git a/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx b/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx new file mode 100644 index 0000000000..cc21a34c85 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx @@ -0,0 +1,34 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; +import { CommentIcon } from '../../components'; + +const CommentsCountWrapper = styled.div` + color: ${({ theme }) => theme.palette.text.secondary}; + display: flex; + align-items: center; + right: 0; + .MuiSvgIcon-root { + font-size: 1rem; + } +`; + +const CommentCountText = styled(Typography)` + font-size: 0.75rem; + margin-inline-start: 0.25rem; +`; + +export const CommentsCount = ({ commentsCount }: { commentsCount: number }) => { + if (!commentsCount) return null; + return ( + + + {commentsCount} + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 1ae661cb7b..44316faf56 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -119,6 +119,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { control, setValue, watch, + register, formState: { isValid, dirtyFields }, } = formContext; @@ -287,8 +288,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { /> - {/** This is a placeholder for when we add in comments functionality */} - + diff --git a/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx index bed8d49bf2..95d894c8fd 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx @@ -10,7 +10,7 @@ import { ActionsMenu } from '@tupaia/ui-components'; import styled from 'styled-components'; import { useEditTask } from '../../api'; import { SmallModal } from '../../components'; -import { Task } from '../../types'; +import { SingleTaskResponse, TasksResponse } from '../../types'; const MenuButton = styled(IconButton)` &.MuiIconButton-root { @@ -47,7 +47,7 @@ const CancelTaskModal = ({ isOpen, onClose, onCancelTask, isLoading }: ModalProp ); -export const TaskActionsMenu = ({ id, taskStatus }: Task) => { +export const TaskActionsMenu = ({ id, taskStatus }: TasksResponse | SingleTaskResponse) => { const [isOpen, setIsOpen] = useState(false); const onOpen = () => setIsOpen(true); const onClose = () => setIsOpen(false); diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx new file mode 100644 index 0000000000..21a1ad98b1 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -0,0 +1,59 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; +import { displayDateTime } from '../../../utils'; +import { SingleTaskResponse } from '../../../types'; + +const Wrapper = styled.div` + width: 100%; + border: 1px solid ${({ theme }) => theme.palette.divider}; + background-color: ${({ theme }) => theme.palette.background.default}; + margin-block-end: 1.2rem; + padding: 1rem; + border-radius: 4px; + overflow-y: auto; + height: 19rem; +`; + +const CommentContainer = styled.div` + padding-block: 0.4rem; + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } +`; + +const Message = styled(Typography).attrs({ + variant: 'body2', + color: 'textPrimary', +})` + margin-block-start: 0.2rem; +`; + +type Comments = SingleTaskResponse['comments']; + +const SingleComment = ({ comment }: { comment: Comments[0] }) => { + const { createdAt, userName, message } = comment; + return ( + + + {displayDateTime(createdAt)} - {userName} + + {message} + + ); +}; + +export const TaskComments = ({ comments }: { comments: Comments }) => { + return ( + + {comments.map((comment, index) => ( + + ))} + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx index d4433feb11..dccdc3c8e5 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -13,13 +13,14 @@ import { LoadingContainer, TextField } from '@tupaia/ui-components'; import { useEditTask } from '../../../api'; import { Button } from '../../../components'; import { useFromLocation } from '../../../utils'; +import { SingleTaskResponse } from '../../../types'; import { RepeatScheduleInput } from '../RepeatScheduleInput'; import { DueDatePicker } from '../DueDatePicker'; import { AssigneeInput } from '../AssigneeInput'; import { TaskForm } from '../TaskForm'; import { ROUTES } from '../../../constants'; -import { Task } from '../../../types'; import { TaskMetadata } from './TaskMetadata'; +import { TaskComments } from './TaskComments'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -37,6 +38,7 @@ const Container = styled(Paper).attrs({ const MainColumn = styled.div` display: flex; flex-direction: column; + justify-content: space-between; flex: 1; margin-block: 1.2rem; ${({ theme }) => theme.breakpoints.up('md')} { @@ -48,6 +50,7 @@ const MainColumn = styled.div` const SideColumn = styled.div` display: flex; flex-direction: column; + justify-content: space-between; ${({ theme }) => theme.breakpoints.up('md')} { width: 28%; } @@ -59,18 +62,6 @@ const ItemWrapper = styled.div` } `; -const CommentsPlaceholder = styled(ItemWrapper)` - height: 10rem; - width: 100%; - border: 1px solid ${({ theme }) => theme.palette.divider}; - background-color: ${({ theme }) => theme.palette.background.default}; - border-radius: 4px; - ${({ theme }) => theme.breakpoints.up('md')} { - flex: 1; - height: 100%; - } -`; - const CommentsInput = styled(TextField).attrs({ multiline: true, variant: 'outlined', @@ -102,7 +93,7 @@ const Form = styled(TaskForm)` } `; -export const TaskDetails = ({ task }: { task: Task }) => { +export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { const navigate = useNavigate(); const backLink = useFromLocation(); @@ -119,6 +110,7 @@ export const TaskDetails = ({ task }: { task: Task }) => { control, handleSubmit, watch, + register, formState: { isValid, dirtyFields }, reset, } = formContext; @@ -204,9 +196,8 @@ export const TaskDetails = ({ task }: { task: Task }) => { - - {/** This is a placeholder for when we add in comments functionality */} - + + diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx index 881730e931..0e0362af49 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx @@ -6,9 +6,9 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; -import { Task } from '../../../types'; import { StatusPill } from '../StatusPill'; import { useEntityByCode } from '../../../api'; +import { SingleTaskResponse } from '../../../types'; const Container = styled.div` border: 1px solid ${props => props.theme.palette.divider}; @@ -60,7 +60,7 @@ const CountryWrapper = styled.div` justify-content: flex-end; `; -export const TaskMetadata = ({ task }: { task?: Task }) => { +export const TaskMetadata = ({ task }: { task?: SingleTaskResponse }) => { const { data: country } = useEntityByCode(task?.entity?.countryCode, { enabled: !!task?.entity?.countryCode, }); diff --git a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx index f5210b17e0..caf098d49a 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx @@ -6,11 +6,11 @@ import React from 'react'; import styled from 'styled-components'; import { generatePath, Link } from 'react-router-dom'; -import ChatIcon from '@material-ui/icons/ChatBubbleOutline'; import { ROUTES } from '../../constants'; -import { StatusPill } from './StatusPill'; import { displayDate } from '../../utils'; import { ButtonLink } from '../../components'; +import { StatusPill } from './StatusPill'; +import { CommentsCount } from './CommentsCount'; const TileContainer = styled.div` display: flex; @@ -63,28 +63,6 @@ const TileRight = styled.div` justify-content: center; `; -const CommentsContainer = styled.div` - display: flex; - align-items: center; - - .MuiSvgIcon-root { - font-size: 1rem; - margin-inline-end: 0.2rem; - } -`; - -const Comments = ({ commentsCount = 0 }) => { - if (!commentsCount) { - return null; - } - return ( - - - {commentsCount} - - ); -}; - export const TaskTile = ({ task }) => { const { survey, entity, taskStatus, dueDate } = task; const surveyLink = generatePath(ROUTES.SURVEY, { @@ -98,7 +76,7 @@ export const TaskTile = ({ task }) => { {displayDate(dueDate)} - + diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx index 0a9ff7eb28..df5adb2ea7 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx @@ -9,8 +9,8 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { Button } from '@tupaia/ui-components'; import { ROUTES } from '../../../constants'; -import { Task } from '../../../types'; -import { AssignTaskModal } from './AssignTaskModal.tsx'; +import { TasksResponse } from '../../../types'; +import { AssignTaskModal } from './AssignTaskModal'; const ActionButtonComponent = styled(Button).attrs({ color: 'primary', @@ -30,7 +30,7 @@ const ActionButtonComponent = styled(Button).attrs({ `; interface ActionButtonProps { - task: Task; + task: TasksResponse; } export const ActionButton = ({ task }: ActionButtonProps) => { diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx index 3ef8441417..8a92d750f0 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx @@ -8,7 +8,7 @@ import { useForm, Controller } from 'react-hook-form'; import { Typography } from '@material-ui/core'; import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { useEditTask } from '../../../api'; -import { Task } from '../../../types'; +import { TasksResponse } from '../../../types'; import { AssigneeInput } from '../AssigneeInput'; import { TaskForm } from '../TaskForm'; import { StatusPill } from '../StatusPill'; @@ -75,11 +75,11 @@ const Row = styled.div` `; interface AssignTaskModalProps { - task: Task; + task: TasksResponse; Button: React.ComponentType<{ onClick: () => void }>; } -const useDisplayRepeatSchedule = (task: Task) => { +const useDisplayRepeatSchedule = (task: TasksResponse) => { // TODO: When repeating tasks are implemented, make sure the repeat schedule is displayed correctly once a due date is returned with the task const repeatScheduleOptions = getRepeatScheduleOptions(task.dueDate); const { label } = repeatScheduleOptions[0]; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index f8b0b13746..68cbc46a0a 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -6,6 +6,7 @@ import React from 'react'; import styled from 'styled-components'; import { useLocation, useSearchParams } from 'react-router-dom'; +import { DatatrakWebTasksRequest } from '@tupaia/types'; import { FilterableTable } from '@tupaia/ui-components'; import { TaskStatusType } from '../../../types'; import { useCurrentUserContext, useTasks } from '../../../api'; @@ -13,6 +14,7 @@ import { displayDate } from '../../../utils'; import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; import { TaskActionsMenu } from '../TaskActionsMenu'; +import { CommentsCount } from '../CommentsCount'; import { StatusFilter } from './StatusFilter'; import { ActionButton } from './ActionButton'; import { FilterToolbar } from './FilterToolbar'; @@ -37,6 +39,15 @@ const ActionCellContent = styled.div` align-items: center; `; +const StatusCellContent = styled.div` + display: flex; + justify-content: space-between; + a:has(&) { + // This is a workaround to make the comments count display at the edge of the cell + padding-inline-end: 0; + } +`; + const useTasksTable = () => { const { projectId } = useCurrentUserContext(); const [searchParams, setSearchParams] = useSearchParams(); @@ -130,7 +141,22 @@ const useTasksTable = () => { filterable: true, accessor: 'taskStatus', id: 'task_status', - Cell: ({ value }: { value: TaskStatusType }) => , + Cell: ({ + value, + row, + }: { + value: TaskStatusType; + row: { + original: DatatrakWebTasksRequest.ResBody['tasks'][0]; + }; + }) => { + return ( + + + + + ); + }, Filter: StatusFilter, disableResizing: true, width: 180, diff --git a/packages/datatrak-web/src/types/task.ts b/packages/datatrak-web/src/types/task.ts index 7f922f40b8..c07640480e 100644 --- a/packages/datatrak-web/src/types/task.ts +++ b/packages/datatrak-web/src/types/task.ts @@ -3,11 +3,13 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { DatatrakWebTaskRequest, TaskStatus } from '@tupaia/types'; +import { DatatrakWebTaskRequest, DatatrakWebTasksRequest, TaskStatus } from '@tupaia/types'; export type TaskStatusType = TaskStatus | 'overdue' | 'repeating'; -export type Task = DatatrakWebTaskRequest.ResBody; +export type SingleTaskResponse = DatatrakWebTaskRequest.ResBody; + +export type TasksResponse = DatatrakWebTasksRequest.ResBody['tasks'][0]; export type TaskFilterType = | 'all_assignees_tasks' diff --git a/packages/datatrak-web/src/utils/date.ts b/packages/datatrak-web/src/utils/date.ts index d431e8c1e2..3b682c77ca 100644 --- a/packages/datatrak-web/src/utils/date.ts +++ b/packages/datatrak-web/src/utils/date.ts @@ -3,9 +3,21 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ +import { format } from 'date-fns'; + export const displayDate = (date?: Date | null) => { if (!date) { return ''; } return new Date(date).toLocaleDateString(); }; + +export const displayDateTime = (date?: Date | null) => { + if (!date) { + return ''; + } + + const dateDisplay = displayDate(date); + const timeDisplay = format(new Date(date), 'p'); + return `${dateDisplay} ${timeDisplay}`; +}; diff --git a/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx b/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx index 302359a6cd..f7c0163461 100644 --- a/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx +++ b/packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx @@ -14,7 +14,7 @@ import { TaskDetails, TaskPageHeader, TaskActionsMenu } from '../../features'; import { useTask } from '../../api'; import { ROUTES } from '../../constants'; import { useFromLocation } from '../../utils'; -import { Task } from '../../types'; +import { SingleTaskResponse } from '../../types'; const ButtonWrapper = styled.div` display: flex; @@ -46,7 +46,13 @@ const ErrorModal = ({ isOpen, onClose }) => { ); }; -const ButtonComponent = ({ task, openErrorModal }: { task?: Task; openErrorModal: () => void }) => { +const ButtonComponent = ({ + task, + openErrorModal, +}: { + task?: SingleTaskResponse; + openErrorModal: () => void; +}) => { const from = useFromLocation(); if (!task) return null; diff --git a/packages/server-boilerplate/src/models/TaskComment.ts b/packages/server-boilerplate/src/models/TaskComment.ts new file mode 100644 index 0000000000..b559c34ec1 --- /dev/null +++ b/packages/server-boilerplate/src/models/TaskComment.ts @@ -0,0 +1,14 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { + TaskCommentRecord as BaseTaskCommentRecord, + TaskCommentModel as BaseTaskCommentModel, +} from '@tupaia/database'; +import { Task, TaskComment } from '@tupaia/types'; +import { Model } from './types'; + +export interface TaskCommentRecord extends TaskComment, BaseTaskCommentRecord {} + +export interface TaskCommentModel extends Model {} diff --git a/packages/server-boilerplate/src/models/index.ts b/packages/server-boilerplate/src/models/index.ts index 440a3ed94d..d87aba7a75 100644 --- a/packages/server-boilerplate/src/models/index.ts +++ b/packages/server-boilerplate/src/models/index.ts @@ -62,3 +62,4 @@ export { SurveyModel, SurveyRecord } from './Survey'; export { UserEntityPermissionModel, UserEntityPermissionRecord } from './UserEntityPermission'; export { UserModel, UserRecord } from './User'; export { TaskModel, TaskRecord } from './Task'; +export { TaskCommentModel, TaskCommentRecord } from './TaskComment'; diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index eb263bd457..46ee95825e 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -87860,8 +87860,6 @@ export const TaskResponseSchema = { }, "required": [ "entity", - "id", - "repeatSchedule", "survey", "taskStatus" ] diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts index d5d440241b..d6551b6348 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts @@ -6,7 +6,9 @@ import { Entity, Survey, UserAccount } from '../../models'; export type Params = Record; -export type ResBody = Record; +export type ResBody = { + message: string; +}; export type ReqQuery = Record; export type ReqBody = { assigneeId?: UserAccount['id']; @@ -14,4 +16,5 @@ export type ReqBody = { entityId: Entity['id']; repeatSchedule?: string; surveyCode: Survey['code']; + comment?: string; }; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts index f6edb85d68..27fefdc854 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts @@ -3,12 +3,20 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import { KeysToCamelCase } from '../../../utils'; +import { TaskComment } from '../../models'; import { TaskResponse } from './TasksRequest'; export type Params = { taskId: string; }; -export type ResBody = TaskResponse; +type Comment = Omit, 'createdAt'> & { + // handle the fact that KeysToCamelCase changes Date keys to to camelCase as well + createdAt: Date; +}; +export type ResBody = TaskResponse & { + comments: Comment[]; +}; export type ReqBody = Record; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts index 950ff6eb71..e19d777775 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts @@ -9,7 +9,7 @@ import { Entity, Survey, Task, TaskStatus } from '../../models'; export type Params = Record; export type TaskResponse = KeysToCamelCase< - Omit + Partial> > & { assigneeName?: string; taskStatus: TaskStatus | 'overdue' | 'repeating'; @@ -23,12 +23,14 @@ export type TaskResponse = KeysToCamelCase< id: Entity['id']; countryCode: string; // this is not undefined or null so use string explicitly here }; - repeatSchedule: Record | null; + repeatSchedule?: Record | null; dueDate?: Date | null; }; export type ResBody = { - tasks: TaskResponse[]; + tasks: (TaskResponse & { + commentsCount: number; + })[]; count: number; numberOfPages: number; }; From 7421982633cb965f574d657754af204ad06d6ee3 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:23:41 +1200 Subject: [PATCH 36/38] Fix import order --- .../src/features/Tasks/TaskDetails/TaskComments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx index 5b880b8f4b..5dde3bb1c9 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -6,9 +6,9 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; +import { TaskCommentType } from '@tupaia/types'; import { displayDateTime } from '../../../utils'; import { SingleTaskResponse } from '../../../types'; -import { TaskCommentType } from '@tupaia/types'; const Wrapper = styled.div` width: 100%; From 86535537e22fe698d406b10fda49a23b29dcf080 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 26 Jul 2024 11:00:23 +1200 Subject: [PATCH 37/38] PR changes --- .../src/apiV2/tasks/CreateTask.js | 8 +- .../constructNewRecordValidationRules.js | 7 +- .../src/tests/apiV2/tasks/EditTask.test.js | 335 ++++++++++++------ .../changeHandlers/TaskCompletionHandler.js | 6 +- packages/database/src/modelClasses/Task.js | 16 +- .../database/src/modelClasses/TaskComment.js | 5 + 6 files changed, 266 insertions(+), 111 deletions(-) diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index 7422e48f3c..dc79ec9d4d 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -26,9 +26,13 @@ export class CreateTask extends CreateHandler { const task = await transactingModels.task.create(newRecordData); // Add the user comment first, since the transaction will mean that all comments have the same created_at time, but we want the user comment to be the 'most recent' if (comment) { - await task.addComment(comment, this.req.user.id, 'user'); + await task.addComment(comment, this.req.user.id, transactingModels.taskComment.types.User); } - await task.addComment('Created this task', this.req.user.id, 'system'); + await task.addComment( + 'Created this task', + this.req.user.id, + transactingModels.taskComment.types.System, + ); return { id: task.id, diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 62527c0119..d6f3509b2d 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -30,7 +30,7 @@ import { DATA_SOURCE_SERVICE_TYPES } from '../../database/models/DataElement'; export const constructForParent = (models, recordType, parentRecordType) => { const combinedRecordType = `${parentRecordType}/${recordType}`; - const { SURVEY_RESPONSE, COMMENT, TASK, TASK_COMMENT } = RECORDS; + const { SURVEY_RESPONSE, COMMENT } = RECORDS; switch (combinedRecordType) { case `${SURVEY_RESPONSE}/${COMMENT}`: @@ -39,11 +39,6 @@ export const constructForParent = (models, recordType, parentRecordType) => { user_id: [constructRecordExistsWithId(models.user)], text: [hasContent], }; - case `${TASK}/${TASK_COMMENT}`: - return { - message: [hasContent, isAString], - type: [constructIsOneOf(['user', 'system'])], - }; default: throw new ValidationError( `${parentRecordType}/[${parentRecordType}Id]/${recordType} is not a valid POST endpoint`, diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js index a0f51382b0..2a2c11a3fc 100644 --- a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -129,130 +129,267 @@ describe('Permissions checker for EditTask', async () => { }); describe('PUT /tasks/:id', async () => { - it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - await app.put(`tasks/${tasks[1].id}`, { - body: { - entity_id: facilities[0].id, - survey_id: surveys[0].survey.id, - }, + describe('Permissions', async () => { + it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[0].id); + expect(result[0].survey_id).to.equal(surveys[0].survey.id); }); - const result = await models.task.find({ - id: tasks[1].id, - }); - expect(result[0].entity_id).to.equal(facilities[0].id); - expect(result[0].survey_id).to.equal(surveys[0].survey.id); - }); - it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => { - await app.grantAccess(DEFAULT_POLICY); - await app.put(`tasks/${tasks[1].id}`, { - body: { - status: 'completed', - }, - }); - const result = await models.task.find({ - id: tasks[1].id, + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + status: 'completed', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].status).to.equal('completed'); }); - expect(result[0].status).to.equal('completed'); - }); - it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => { - await app.grantAccess({ - DL: ['Donor'], - TO: ['Donor'], + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); }); - await app.put(`tasks/${tasks[1].id}`, { - body: { - survey_id: surveys[1].survey.id, - entity_id: facilities[1].id, - }, + + it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[0].id}`, { + body: { + status: 'completed', + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the country of the task'); }); - const result = await models.task.find({ - id: tasks[1].id, + + it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[0].survey.id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new survey of the task'); }); - expect(result[0].entity_id).to.equal(facilities[1].id); - expect(result[0].survey_id).to.equal(surveys[1].survey.id); - }); - it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[0].id}`, { - body: { - status: 'completed', - }, + it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new entity of the task'); }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the country of the task'); }); - it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[1].id}`, { - body: { - survey_id: surveys[0].survey.id, - }, + describe('User generated comments', async () => { + it('Handles adding a comment when editing a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.null; }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the new survey of the task'); - }); - it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[1].id}`, { - body: { - entity_id: facilities[0].id, - }, + it('Handles adding a comment when no other edits are made', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + comment: 'This is a test comment', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(tasks[1].entity_id); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + message: 'This is a test comment', + }); + expect(comment).not.to.be.null; }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the new entity of the task'); }); - it('Handles adding a comment when editing a task', async () => { - await app.grantAccess({ - DL: ['Donor'], - TO: ['Donor'], - }); - await app.put(`tasks/${tasks[1].id}`, { - body: { - survey_id: surveys[1].survey.id, - entity_id: facilities[1].id, - comment: 'This is a test comment', - }, - }); - const result = await models.task.find({ - id: tasks[1].id, + describe('System generated comments', () => { + it('Adds a comment when the due date changes on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + due_date: new Date('2021-11-30').toISOString(), + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: 'Changed due date from 31 December 21 to 30 November 21', + }); + expect(comment).not.to.be.null; }); - expect(result[0].entity_id).to.equal(facilities[1].id); - expect(result[0].survey_id).to.equal(surveys[1].survey.id); - const comment = await models.taskComment.findOne({ - task_id: tasks[1].id, - message: 'This is a test comment', + it('Adds a comment when the repeat schedule changes from not repeating to repeating on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + // this is currently null when setting a task to repeat + due_date: null, + repeat_schedule: { + frequency: 'daily', + }, + }, + }); + + const repeatComment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: "Changed recurring task from Doesn't repeat to Daily", + }); + + const dueDateComment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: 'Changed due date from 31 December 21 to null', + }); + expect(repeatComment).not.to.be.null; + // should not add a comment about the due date changing in this case + expect(dueDateComment).to.be.null; }); - expect(comment).not.to.be.undefined; - }); - it('Handles adding a comment when no other edits are made', async () => { - await app.grantAccess({ - DL: ['Donor'], - TO: ['Donor'], + it('Adds a comment when the repeat schedule changes from repeating to not repeating on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + repeat_schedule: { + frequency: 'daily', + }, + }, + }); + + await app.put(`tasks/${tasks[1].id}`, { + body: { + repeat_schedule: null, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: "Changed recurring task from Daily to Doesn't repeat", + }); + expect(comment).not.to.be.null; }); - await app.put(`tasks/${tasks[1].id}`, { - body: { - comment: 'This is a test comment', - }, + + it('Adds a comment when the status changes on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + status: 'completed', + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: 'Changed status from To do to Completed', + }); + expect(comment).not.to.be.null; }); - const result = await models.task.find({ - id: tasks[1].id, + + it('Adds a comment when the assignee changes to Unassigned on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + assignee_id: null, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + message: 'Changed assignee from Peter Pan to Unassigned', + }); + expect(comment).not.to.be.null; }); - expect(result[0].entity_id).to.equal(tasks[1].entity_id); - const comment = await models.taskComment.findOne({ - task_id: tasks[1].id, - message: 'This is a test comment', + it('Adds a comment when the assignee changes from unassigned to assigned on a task', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.put(`tasks/${tasks[0].id}`, { + body: { + assignee_id: assignee.id, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[0].id, + type: models.taskComment.types.System, + message: 'Changed assignee from Unassigned to Peter Pan', + }); + expect(comment).not.to.be.null; }); - expect(comment).not.to.be.undefined; }); }); }); diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js index 255eb8490c..c9b1d1d2dc 100644 --- a/packages/database/src/changeHandlers/TaskCompletionHandler.js +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -91,7 +91,11 @@ export class TaskCompletionHandler extends ChangeHandler { }); } - await task.addComment('Completed this task', matchingSurveyResponse.user_id, 'system'); + await task.addComment( + 'Completed this task', + matchingSurveyResponse.user_id, + transactingModels.taskComment.types.System, + ); } } } diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index a3a00cf4f4..f1b80d89d7 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -117,7 +117,10 @@ export class TaskRecord extends DatabaseRecord { * @returns {Promise} */ async userComments() { - return this.otherModels.taskComment.find({ task_id: this.id, type: 'user' }); + return this.otherModels.taskComment.find({ + task_id: this.id, + type: this.otherModels.taskComment.types.User, + }); } /** @@ -126,7 +129,10 @@ export class TaskRecord extends DatabaseRecord { */ async systemComments() { - return this.otherModels.taskComment.find({ task_id: this.id, type: 'system' }); + return this.otherModels.taskComment.find({ + task_id: this.id, + type: this.otherModels.taskComment.types.System, + }); } /** * @description Add a comment to the task. Handles linking the comment to the task and user, and setting the comment type @@ -184,7 +190,11 @@ export class TaskRecord extends DatabaseRecord { if (!comments.length) return; - await Promise.all(comments.map(message => this.addComment(message, userId, 'system'))); + await Promise.all( + comments.map(message => + this.addComment(message, userId, this.otherModels.taskComment.types.System), + ), + ); } } diff --git a/packages/database/src/modelClasses/TaskComment.js b/packages/database/src/modelClasses/TaskComment.js index 2f471f41a8..3accc2dbd7 100644 --- a/packages/database/src/modelClasses/TaskComment.js +++ b/packages/database/src/modelClasses/TaskComment.js @@ -15,4 +15,9 @@ export class TaskCommentModel extends DatabaseModel { get DatabaseRecordClass() { return TaskCommentRecord; } + + types = { + System: 'system', + User: 'user', + }; } From befced23208f938a2b6d063994459465fdc1e29a Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:41:43 +1200 Subject: [PATCH 38/38] Fix change handler tests --- .../changeHandlers/TaskCompletionHandler.test.js | 2 +- .../database/src/changeHandlers/TaskCompletionHandler.js | 8 ++++---- packages/database/src/modelClasses/Task.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js index 95d35d30c3..2f760d85de 100644 --- a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js +++ b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js @@ -99,7 +99,7 @@ describe('TaskCompletionHandler', () => { expect(comments[0]).toMatchObject({ message: 'Completed this task', user_id: userId, - type: 'system', + type: models.taskComment.types.System, }); }); diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js index ad73649ad7..2c8dc5b9bf 100644 --- a/packages/database/src/changeHandlers/TaskCompletionHandler.js +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -68,7 +68,7 @@ export class TaskCompletionHandler extends ChangeHandler { }); } - async handleChanges(_transactingModels, changedResponses) { + async handleChanges(transactingModels, changedResponses) { // if there are no changed responses, we don't need to do anything if (changedResponses.length === 0) return; const tasksToUpdate = await this.fetchTasksForSurveyResponses(changedResponses); @@ -98,7 +98,7 @@ export class TaskCompletionHandler extends ChangeHandler { // Create a new task with the same details as the current task and mark as completed // It is theoretically possible that more than one task could be created for a repeating // task in a reporting period which is ok from a business point of view - await this.models.task.create({ + await transactingModels.task.create({ assignee_id: assigneeId, survey_id: surveyId, entity_id: entityId, @@ -107,7 +107,7 @@ export class TaskCompletionHandler extends ChangeHandler { survey_response_id: matchingSurveyResponse.id, }); } else { - await this.models.task.updateById(id, { + await transactingModels.task.updateById(id, { status: 'completed', survey_response_id: matchingSurveyResponse.id, }); @@ -116,7 +116,7 @@ export class TaskCompletionHandler extends ChangeHandler { await task.addComment( 'Completed this task', matchingSurveyResponse.user_id, - this.model.taskComment.types.System, + transactingModels.taskComment.types.System, ); } } diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index f1b80d89d7..411d03153f 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -150,7 +150,7 @@ export class TaskRecord extends DatabaseRecord { message, type, user_id: userId, - user_name: user.full_name, + user_name: user?.full_name ?? null, }); }