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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 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 08/12] 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 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 09/12] 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 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 10/12] 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 11/12] 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 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 12/12] 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; };