diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..9ec8dacfb8 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 { 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 */ 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..c2e4f87325 --- /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); + + 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..d5760663d3 --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/index.js @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { GETTaskComments } from './GETTaskComments'; 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/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 9b3ceaf282..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`, @@ -490,6 +495,7 @@ export const constructForSingle = (models, recordType) => { }, ], }; + default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } 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'); + }); + }); +}); 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/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js new file mode 100644 index 0000000000..75395074c5 --- /dev/null +++ b/packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js @@ -0,0 +1,79 @@ +'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 }, + message: { type: 'text', notNull: true }, + type: { type: 'TASK_COMMENT_TYPE', notNull: true, defaultValue: 'user' }, + created_at: { + type: 'timestamp with time zone', + 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); + `); +}; + +exports.down = async function (db) { + await db.dropTable('task_comment'); + return db.runSql('DROP TYPE TASK_COMMENT_TYPE;'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 077495a530..90759ec43a 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -43,6 +43,29 @@ 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' }); + } + + 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/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', 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 38cc775df4..46ee95825e 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85831,6 +85831,115 @@ export const TaskUpdateSchema = { "additionalProperties": false } +export const TaskCommentSchema = { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "created_at", + "id", + "message", + "task_id", + "type", + "user_name" + ] +} + +export const TaskCommentCreateSchema = { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "message": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "message", + "task_id", + "user_name" + ] +} + +export const TaskCommentUpdateSchema = { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "message": { + "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": { @@ -86380,6 +86489,14 @@ export const TaskStatusSchema = { "type": "string" } +export const TaskCommentTypeSchema = { + "enum": [ + "system", + "user" + ], + "type": "string" +} + export const SyncGroupSyncStatusSchema = { "enum": [ "ERROR", @@ -87743,8 +87860,6 @@ export const TaskResponseSchema = { }, "required": [ "entity", - "id", - "repeatSchedule", "survey", "taskStatus" ] diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 60ad113173..2744b74da7 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1565,6 +1565,32 @@ export interface TaskUpdate { 'survey_id'?: string; 'survey_response_id'?: string | null; } +export interface TaskComment { + 'created_at': Date; + 'id': string; + 'message': string; + 'task_id': string; + 'type': TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentCreate { + 'created_at'?: Date; + 'message': string; + 'task_id': string; + 'type'?: TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentUpdate { + 'created_at'?: Date; + 'id'?: string; + 'message'?: string; + 'task_id'?: string; + 'type'?: TaskCommentType; + 'user_id'?: string | null; + 'user_name'?: string; +} export interface TupaiaWebSession { 'access_policy': {}; 'access_token': string; @@ -1702,6 +1728,10 @@ export enum TaskStatus { 'cancelled' = 'cancelled', 'completed' = 'completed', } +export enum TaskCommentType { + 'user' = 'user', + 'system' = 'system', +} export enum SyncGroupSyncStatus { 'IDLE' = 'IDLE', 'SYNCING' = 'SYNCING', 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; };