From 147dbe82f2a29bd9ec8bb87570848cc262591eb7 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:41:16 +1200 Subject: [PATCH] feat(datatrakWeb): RN-1335: Tasks Epic (#5863) * No data display * Fix tests * Reorganise files * tweaks * Add loading state * Exclude tasks from ios * tweaks * Types fix * Make due date nullable * Rename repeat_schedule field * Tweaks * Fix tests * Revert "Fix tests" This reverts commit 0e566d38b07c3b11019aa1e9b726b47172d29aab. * Fix tests * Fix validation rules * Link status and assignee name to records at the model level * Generate types * Fix types and apply new field names * Get tasks filters working * Fix pagination * Fix build * refactor(adminPanel): RN-1336: Move modal into ui-components (#5765) * Move modal to ui-components * Fix build * Reorder import * Enable popover portal * feat(datatrakWeb): RN-1336: Create a task workflow (#5763) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * Fix timezone issue * Fix date formatting of filter * remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list --------- Co-authored-by: Tom Caiger * feat(datatrakWeb): RN-1338: Mark tasks as completed when survey responses are submitted (#5766) * Create migration for created_at column * Create change handler * fix(types):Update schemas.ts * feat(datatrakWeb): RN-1358: Assign tasks from dashboard (#5770) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Fix modal button types * Fix types --------- Co-authored-by: Tom Caiger * feat(datatrakWeb): RN-1314: add return to tasks button on success screen (#5747) * add back to tasks button * use useFromLocation * update types * Update SurveySuccessScreen.tsx * save task endpoint * save task mutation * task cancel modal * update types * pass from location into useSubmitSurveyResponse * remove SaveTaskRequest * Update TasksTable.tsx * refactor action button * fix(datatrakWeb): Fix table height * tweak(datatrakWeb): Use full month name in repeat schedule options * fix(datatrakWeb): Handle errors when loading creating task modal * fix(datatrakWeb): Hide actions on completed and cancelled tasks * tweak(datatrakWeb): Update repeating schedule input to be matching autocomplete * fix(datatrakWeb): Change select list focus styling slightly * fix(datatrakWeb): Fix UTC date issue * fix(datatrakWeb): fix focus styles on select filter * fix(datatrakWeb): Fix type error * tweak(datatrakWeb): Change text on complete task button * fix(datatrakWeb): remove redundant file * fix(datatrakWeb): Fix assignee autocomplete * tweak(datatrakWeb): Navigate user to project select screen on 403 entities error * feat(datatrak): RN-1343: Task dashboard filter settings (#5757) * add back to tasks button * use useFromLocation * update types * Update SurveySuccessScreen.tsx * save task endpoint * save task mutation * task cancel modal * update types * pass from location into useSubmitSurveyResponse * build ui * set up filtering * testing filters * Update useTasks.ts * fix front end * filtering with cookies * update cookie handling * clean up types * refactor tasks route * handle invalidFilterValues * Create TaskRoute.test.ts * sort by status * clear cookies on logout * Delete SaveTaskRoute.ts * renaming * tweak(datatrakWeb): Make search fuzzy always * tweak(datatrakWeb): RN-1358: Assign task modal changes (#5784) * Update design for assign modal * Update schemas.ts * PR fixes * Update taskFilterSettings.ts * Update ActionButton.tsx * feat(datatrakWeb): RN-1329: Task details view (#5783) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Link to details page * Fix modal button types * Fix types * Update handling of columns * get task route * WIP * Link to survey for incomplete tasks * Handle unloaded task * Task metadata section * Fix merge issue * WIP * Update schemas.ts * feat(datatrakWeb): RN-1358: Assign tasks from dashboard (#5770) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Fix modal button types * Fix types --------- Co-authored-by: Tom Caiger * Fix merge error * Fix styles and types * Styling * Fix multi re-render * Comments placeholder * Add buttons * Submit changes * Clear changes * Fix merge issues * Disable inputs when task is completed or cancelled * responsive styling * Add cancel menu * Generate types * Fix loading styling * Remove unused var * Handle onSuccess of edit task * PR fixes * PR fixes * Remove form provider --------- Co-authored-by: Tom Caiger * fix tasks button * removeTaskFilterSetting on logout * fix(datatrakWeb): Make 'unassigned' searchable * fix(datatrakWeb): Fix task row links * fix(datatrakWeb): Fix task styling * tweak(datatrakWeb): Tweak styling of task details view * fix(adminPanel): Fix merge * Fix build * feat(datatrak): RN-1314: Auto populate entity question (#5793) * set primary entity * survey response * Update SurveyResponsePage.tsx * feat(datatrak): RN-1313: My tasks section (#5776) * add tasks section * create task tile * NoTasksSection * styling * styling landing page * styling * Update TaskTile.tsx * pr changes * styling * responsive styles * Update useTasks.ts * fix use tasks * Update TasksRoute.ts * Update TaskTile.tsx * Update SurveyScreen.tsx * Fix build * tweak(datatrakWeb): RN-1339: Move survey response page into modal (#5786) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Link to details page * Fix modal button types * Fix types * Update handling of columns * get task route * WIP * Link to survey for incomplete tasks * Handle unloaded task * Task metadata section * Fix merge issue * WIP * Update schemas.ts * feat(datatrakWeb): RN-1358: Assign tasks from dashboard (#5770) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Fix modal button types * Fix types --------- Co-authored-by: Tom Caiger * Fix merge error * Fix styles and types * Styling * Fix multi re-render * Comments placeholder * Add buttons * Submit changes * Clear changes * Fix merge issues * Disable inputs when task is completed or cancelled * responsive styling * Add cancel menu * Generate types * Fix loading styling * Remove unused var * Handle onSuccess of edit task * Display modal * Survey response modal * Handle isResponseScreen flag * Error handling * Error handling * Fix tests * Remove country and survey code from url * Fix imports * feat(datatrakWeb): RN-1339: Link survey responses to tasks (#5788) * Create survey_response_id column * Update change handler * Update TaskCompletionHandler.js * Fix type * feat(datatrakWeb): RN-1339: View completed survey response from task (#5789) * Create survey_response_id column * Update change handler * View completed survey * Error handling * Fix comment * fix tasks button * removeTaskFilterSetting on logout * PR fixes --------- Co-authored-by: Tom Caiger * Fix build --------- Co-authored-by: Tom Caiger * feat(datatrak): RN-1357: Task created toast message (#5798) Update useCreateTask.ts Co-authored-by: alexd-bes <129009580+alexd-bes@users.noreply.github.com> * feat(datatrakWeb): RN-1381: Add new question types (#5807) * Add User and Task question types * Add question config types * Update types * Update types * Fix surveyCode type * feat(datatrak): RN-1364: Completing of repeat tasks (#5802) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Link to details page * Fix modal button types * Fix types * Update handling of columns * get task route * WIP * Link to survey for incomplete tasks * Handle unloaded task * Task metadata section * Fix merge issue * WIP * Update schemas.ts * feat(datatrakWeb): RN-1358: Assign tasks from dashboard (#5770) * Create button * Move modal to ui-components * Move country selector to features folder * Update country selector exports/imports * Update tupaia-pin.svg * Country selector on modal * Move survey selector to features * Move types * Update survey list component to take care of fetching * Survey selector * Move entity selector to features * Fix types * Entity selector * Styling entity selector * Due date * WIP * WIP * assignee input * Add loading state and save user id * Styling repeat scheduler * Comments placholder * Styling * WIP * Create task route * Create task workflow * Clear form when modal is reopened * Update schemas.ts * remove unused import * Handle reset * Fix datatrak tests * Fix central server tests * Move modal to ui-components * Remove unused import * Remove duplicate file * Fix build * Fix tests * Update packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js Co-authored-by: Tom Caiger * Fix error messages * Handle search term in the BE * WIP * WIP * Assignee Id modal * Working assignee * remove unused property * Fix timezone issue * Fix date formatting of filter * remove unused variable * Remove unused variable * Fix casing * Default to showing countries if no primary entity question * Update AssigneeInput.tsx * Show loader when loading project and countries * Fix copy * Exclude internal users * Fix types * Change colour of icon in entity list * Fix modal button types * Fix types --------- Co-authored-by: Tom Caiger * Fix merge error * Fix styles and types * Styling * Fix multi re-render * Comments placeholder * Add buttons * Submit changes * Clear changes * Fix merge issues * Disable inputs when task is completed or cancelled * responsive styling * Add cancel menu * Generate types * Fix loading styling * Remove unused var * Handle onSuccess of edit task * Display modal * Survey response modal * Handle isResponseScreen flag * Error handling * Error handling * Fix tests * Remove country and survey code from url * Fix imports * feat(datatrakWeb): RN-1339: Link survey responses to tasks (#5788) * Create survey_response_id column * Update change handler * Update TaskCompletionHandler.js * Fix type * feat(datatrakWeb): RN-1339: View completed survey response from task (#5789) * Create survey_response_id column * Update change handler * View completed survey * Error handling * Fix comment * fix tasks button * removeTaskFilterSetting on logout * PR fixes --------- Co-authored-by: Tom Caiger * Update TaskCompletionHandler.test.js * handle repeatSchedule tasks * set up duplicate test * clean up PR * allow null repeating tasks --------- Co-authored-by: alexd-bes <129009580+alexd-bes@users.noreply.github.com> * feat(datatrakWeb): RN-1331: Task comments setup (#5800) * Create task comment table and generate types * Create Model * WIP * WIP * Working endpoints * Generate types * Fix test * Update 20240719015050-AddTaskCommentsTable-modifies-schema.js * Remove task comments endpoints * Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. * Fix revert * 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 * Fix merge errors * Fix build * feat(datatrakWeb): RN-1391: Generate system comments for tasks (#5806) * Create task comment table and generate types * Create Model * WIP * WIP * Working endpoints * Generate types * Fix test * Create task with comments * Update 20240719015050-AddTaskCommentsTable-modifies-schema.js * 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 * addTaskComment on model * Generate comment on edit task * Generate comments on create of task * Generate system message on task completion * Syetm task styling * Handle editing a task with just a comment * Move comment handling to central server * Move comment adding to central server * Update schemas.ts * Fix types * Remove task comments endpoints * build fixes * Add comments * Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. * Remove task comment create endpoint as no longer needed * Revert "Remove task comments endpoints" This reverts commit 498b1be6bc0836b68e90307a2d87fbfd53c705ab. * Fix revert * Handle recurring task comments * 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 * Fix import order * PR changes * Fix change handler tests * Update TasksRoute.ts * Update taskFilterSettings.ts * feat(adminPanel): RN-1381: Ability to import task and user questions in surveys (#5813) * Add User and Task question types * Add question config types * Update types * Update types * Fix surveyCode type * WIP * Ability to import config * WIP * User permission group validation * Add comment * Handle exporting of questions * Hide the task question always * Handle BES admin users * Handle multiple task questions * rename entityCode to entityId * Generate types * Update SurveyContext.tsx * Update TaskCompletionHandler.js * feat(datatrakWeb): RN-1362: Datatrak Web User question type (#5815) * New endpoint * Question * Add tests * Generate types * PR fixes * feat(datatrak) RN-1340: Cancel task modal design update (#5819) update modal design * fix merge conflicts * tweak(datatrakWeb): Update tooltip on tasks dashboard * feat(datatrak): RN-1373: Task question (#5809) * task creation handler * get answers for task questions * handle saving string values * Update TaskCreationHandler.test.js * Update surveyScreenComponent.ts * move change handler to central server * move helpers to central server * handle primary entity question * generate types * clean up * Update TaskCreationHandler.js * move back to database * fix fe tests * handle success message * handle should_create_task * fix tests * fix tests * remove test code * update types * refactor * Update getTaskQuestionField.ts * remove toast message * hide task questions on the server side * Update SurveyRoute.ts * remove survey_response_id * fix tests * update types * test multiple task questions * update types * Update schemas.ts * tweak(datatrakWeb): RN-1331: Update to task details/comments (#5823) * WIP * UI comments * Remove comment handling on edit task * Create task comment endpoint * Link to task from landing page, and back button * CHange back icon * Update tests * Don't navigate back on successful task save * Remove double default values * feat(datatrak): RN-1314: Handle task completion when submitting a survey (#5826) * handle task completion inline * update task completion handling * Clean up * Update index.js * update comments * pr review tweaks * fix handle completion * Update TaskDetailsPage.tsx * feat(meditrakApp): RN-1361: User type question (#5820) * Add User and Task question types * Add question config types * Update types * Update types * Fix surveyCode type * WIP * Ability to import config * WIP * User permission group validation * Add comment * Handle exporting of questions * Hide the task question always * Handle BES admin users * Handle multiple task questions * rename entityCode to entityId * Generate types * Add sync configs * Update permissions based sync queue for user entity permissions * Add user entity permissions to sync queue * Sync user accounts * User account syncing * Make realm explorer scroll * Fix permissions based sync queue for user entity permissions * Add database type for user entity permission * Make separate user account model * Working question * Fix user model usage * Working question with permission groups * Update schema.jsx * add comments * Handle filtering and pagination * Use permission group id instead of name * Update filtering * PR fixes * Add internal field to users * fix shouldCreateTask * Update TaskCreationHandler.js * Display parent entity name next to entity name * Update task details buttons * Generate types * Reset pagination on filter change * fix(datatrakWeb): Fix permissions on tasks * feat(tasks): RN-1372: Email templating (#5830) * Working template setup * v1 * Generic template working * Request country access * Delete account request * Rename templates * Password reset template * Verify email * Permission granted email * email after timeout * dashboard subscription emails * Handle no template name * Make subfolder for content * Update pagination no records text * Fix tests * feat(datatrak): RN-1330: View initial request for a task (#5832) * add initial_request_id and modal * remove unused useSurveyResponseWithForm * Update useSurveyResponseWithForm.ts * Update TaskDetails.tsx * update types * Update TaskDetails.tsx * fix tests * feat(datatrakWeb): RN-1337: Send emails to assignee of tasks (#5834) * Working template setup * v1 * Generic template working * Request country access * Delete account request * Rename templates * Password reset template * Verify email * Permission granted email * email after timeout * dashboard subscription emails * Handle no template name * Make subfolder for content * Add email template * WIP * Add env * Update configureEnv.js * Working email handler * Tests * Remove log * Remove duplicate model * Add trigger creation in runPostMigration * Allow assignee question to be empty or non-mandatory * Fix tests * Revert to starts with search * Fix tests * Fix comment details colour * Don't email assignees when completed repeating tasks are created * Fix tests * Update LandingPage.tsx * tweak(datatrakWeb): RN-1417: save parent task to completed repeating tasks (#5840) * Ad column to tasks table * Handle completion of repeating tasks * feat(datatrak): RN-1398: Setup task scheduler (#5841) * setup scheduled task * set up email template * update script * clean up * PR suggestions * Update ScheduledTask.js * Basic responsive styling * Task header back button * Layout tidy ups * Remove warning log * Scroll tidying * Generate types * Merge fixes * Fix build * tweak emails * tweak(datatrakWeb): RN-1400: save task due date in unix time (#5838) * Change due date to be number * Working time zone saving * Save dates * Working filters * Fix tests * Task creation handler tests * database tests * Central server tests * Fix merge conflict error * handle timezones * Fix tests * Handle creating tasks in timezone * Set ms to be 0 * Fix tests * Add task route test * Update packages/datatrak-web-server/src/routes/TasksRoute.ts Co-authored-by: Tom Caiger * Make custom column selector for due dates * Update due date field * build fixes * Build fixes --------- Co-authored-by: Tom Caiger * Make survey success direct to tasks where applicable * add a default sort * Fix user question * Filter surveys by permission group and country * Handle when no permissions for country * fix(datatrakWeb): Limit entity results to 100 (#5847) Fix(datatrakWeb): Limit entity results to 100 * Update SurveyContext.tsx * Show all users for public surveys * Fix font weights * Convert permission groupI ID to name when exporting survey * Handle assignee lists and search * Generate types * feat(datatrakWeb): RN-1341: Repeating tasks (#5844) * WIP rrule * Working rrule utils * Add generic rrule handler * frequency enum * types * handle repeat schedule in task changes * Create and edit repeating tasks * Handle system comments * Creating and editing with comments * WIP * Update filtering * disable sort by repeat frequency * Add next occurrence handler * Apply due date to completed tasks * Handle bugs * Update tests * Update yarn.lock * Mark due date of tasks * Comments and tidy ups * Use timestamp for due date * Fix tests * add comments * Move getRepeatScheduleOptions * Remove duplicate identifier * Allow removing of repeat schedule * feat(datatrak): RN-1314: Auto fill primary entity questions for a survey (#5853) * useEntityAncestors * entityAncestors * clean up * refactoring * refactor getEntityQuestionAncestorAnswers * refactor usePrimaryEntityLocation * autofill in survey context * add test * Update usePrimaryEntityQuestionAutoFill.ts * Update schemas.ts * fix test * Repeating task fixes * Reset tasks pagination to 0 when filter checkbox value changes * Handle overdue tasks with no assignee * styling tidy ups * Handle multiline task comments * Add entity name to modals * Fix user question re-renders * Update task entities on survey response edit * tweak(datatrakWeb): RN-1391: Update system comments handling (#5859) * Task comment changes * Tidy ups * Update Task.js * interceptor for creating tasks * Editing tidyups * central server tests * Task tests * Merge fixes * Comment fix * build fixes * Fix type error * Display entity parent name on survey response modals * Fix user question values disappearing * Fix user not showing in survey response * Display 'user deleted' message for deleted message * Remove review section headings * Generate types * Update SurveyReviewSection.tsx * Amend db migration for meditrak sync queue * Tidy up migration * Handle exporting of answers for user questions * Admin panel user question fixes * Check for user config being allowed access to the survey * Should validate that surveyCode is set * Add validation for task config question types and multi field validation errors * Update TaskConfigValidator.js * Update task assignee question validation * Don't remove instruction questions on survey review screens * Undo change with multiple config being validated at once * Update importSurveyQuestions.js * Remove unused import * Handle user question names with special characters * Use locale for datepicker * feat(datatrakWeb): RN-1374: Task Metrics (#5860) * task metrics * types update * Update useCreateTask.ts * review changes * review updates * update to MUI breakpoints * Review updates * Update TaskCompletionHandler.js * tasks section responsive styles * Update SurveyContext.tsx * Complete tasks based on survey response end time * Create permissions based sync queue on first startup * Handle survey responses with no parent name * Make use of util for survey review screen * tweak(datatrakWeb): RN-1374: Dashboard Metrics Updates (#5864) * task metrics * types update * Update useCreateTask.ts * review changes * review updates * update to MUI breakpoints * Review updates * Update TaskCompletionHandler.js * updates * Update permissions based sync handler on startup * Allow removal of repeat schedule * Handle db migrations error * Fix types * Remove unused var * Test * Update index.js * fix(datatrak): RN-1330: Fix logout request cache clearing (#5865) * Update Tile.tsx * wip * cleanup * Update TaskTile.tsx * tweak(datatrakWeb): RN-1374: Dashboard Metrics Updates (#5866) * task metrics * types update * Update useCreateTask.ts * review changes * review updates * update to MUI breakpoints * Review updates * Update TaskCompletionHandler.js * updates * Rounding up completion rate * Update SingleSurveyResponseRoute.ts * Update getDbMigrator.js * Fix error on due date updater * Fix broken deployment * Wrap sync queue handler in feature flag checker * Update scheduledTask.js * Allow past due dates in some cases * Fix env vars * fix task link * Update usePrimaryEntityQuestionAutoFill.ts * Update TaskTile.tsx * Handle user changes * Update TaskOverdueChecker.js * remove due date from overdue email * Remove due date from assignee emails * fix(datatrak): Fix survey autofill loading (#5883) * add loading * Update TaskOverdueChecker.js * overdue email comment * Update schemas.ts * Update yarn.lock * Regenerate types * Fix build * Fix apostrophe and single quote search * Fix build * Fix random 0 * Sort filtered users * Sync all user entity permissions by country, instead of by country and permission group * Update TaskComment.js * Force a full resync on update to version 26 * TEST * Update version * fix(datatrak): RN-1314: Update autofilling entity when completing a task (#5893) move primary entity code to url params * TEST * Fix react-query updates * Fix ordering of keys * Update migration * Keep survey search on page change * handle loading state * Use isSuccess instead of loading state for use effect * Fix loading race conditions * Disable backdating of tasks --------- Co-authored-by: Tom Caiger Co-authored-by: Salman <114740396+hrazasalman@users.noreply.github.com> --- env/platform.env.example | 4 + .../DashboardItemMetadataForm.jsx | 3 +- .../DataLibrary/component/TransformModal.jsx | 2 +- .../MapOverlay/MapOverlayMetadataForm.jsx | 3 +- .../components/Modal/EditModal.jsx | 9 +- .../Modal/SaveVisualisationModal.jsx | 4 +- .../PreviewOptions/PreviewOptions.jsx | 3 +- .../filters/OrganisationUnitCodesField.jsx | 4 +- packages/admin-panel/src/editor/EditModal.jsx | 2 +- .../src/importExport/ExportModal.jsx | 2 +- .../src/importExport/ImportModal.jsx | 5 +- packages/admin-panel/src/library.js | 9 +- .../admin-panel/src/logsTable/LogsModal.jsx | 2 +- .../resources/editSurvey/EditSurveyPage.jsx | 3 +- .../admin-panel/src/qrCode/QrCodeModal.jsx | 2 +- .../src/routes/surveys/surveyResponses.jsx | 9 +- .../admin-panel/src/routes/surveys/surveys.js | 56 + .../src/surveyResponse/FileQuestionField.jsx | 4 +- .../admin-panel/src/surveyResponse/Form.jsx | 3 +- .../src/surveyResponse/ResponseFields.jsx | 3 +- .../ResubmitSurveyResponseModal.jsx | 3 +- .../src/surveys/useEditSurveyField.js | 2 +- .../DataFetchingTable/DataFetchingTable.jsx | 3 - packages/admin-panel/src/utilities/index.js | 1 - .../admin-panel/src/utilities/useDebounce.js | 22 - .../src/widgets/ConfirmDeleteModal.jsx | 2 +- .../widgets/InputField/CheckboxListField.jsx | 4 +- packages/admin-panel/src/widgets/index.js | 7 - .../api-client/src/connections/CentralApi.ts | 10 +- .../api-client/src/connections/EntityApi.ts | 19 +- .../src/connections/mocks/MockCentralApi.ts | 8 + packages/api-client/src/types.ts | 5 + packages/central-server/package.json | 4 + .../central-server/scripts/scheduledTask.js | 49 + .../src/apiV2/GETHandler/GETHandler.js | 4 +- .../src/apiV2/GETHandler/helpers.js | 1 + .../apiV2/answers/assertAnswerPermissions.js | 5 + .../central-server/src/apiV2/deleteAccount.js | 13 +- .../exportResponsesToFile.js | 8 + .../QuestionConfigCellBuilder.js | 14 +- .../TaskConfigCellBuilder.js | 99 ++ .../UserConfigCellBuilder.js | 36 + .../constructImportEmail.js | 25 +- .../importSurveyResponses.js | 19 + .../ConfigImporter/ConfigImporter.js | 13 +- .../ConfigImporter/processTaskConfig.js | 26 + .../ConfigImporter/processUserConfig.js | 22 + .../importSurveys/Validator/BaseValidator.js | 1 + .../ConfigValidator/ConfigValidator.js | 8 +- .../ConfigValidator/TaskConfigValidator.js | 93 ++ .../ConfigValidator/UserConfigValidator.js | 96 ++ .../Validator/JsonFieldValidator.js | 1 + .../constructQuestionValidators.js | 3 + .../importSurveys/importSurveyQuestions.js | 16 +- packages/central-server/src/apiV2/index.js | 3 + .../src/apiV2/meditrakApp/getChanges.js | 8 +- .../permissionsBasedMeditrakSyncQuery.js | 2 +- .../src/apiV2/requestCountryAccess.js | 38 +- .../src/apiV2/requestPasswordReset.js | 26 +- .../apiV2/surveys/assertSurveyPermissions.js | 27 +- .../apiV2/taskComments/CreateTaskComment.js | 36 + .../src/apiV2/taskComments/GETTaskComments.js | 38 + .../assertTaskCommentPermissions.js | 38 + .../src/apiV2/taskComments/index.js | 7 + .../src/apiV2/tasks/CreateTask.js | 13 +- .../src/apiV2/tasks/EditTask.js | 2 +- .../src/apiV2/tasks/GETTasks.js | 22 +- .../src/apiV2/tasks/assertTaskPermissions.js | 44 +- .../constructNewRecordValidationRules.js | 35 +- .../src/apiV2/utilities/emailVerification.js | 38 +- packages/central-server/src/configureEnv.js | 1 + packages/central-server/src/createApp.js | 1 - .../meditrakSyncQueue/MeditrakSyncQueue.js | 3 +- ...createPermissionsBasedMeditrakSyncQueue.js | 53 +- .../src/database/models/Answer.js | 2 + .../src/database/models/User.js | 45 + .../database/models/UserEntityPermission.js | 31 +- packages/central-server/src/index.js | 45 +- .../RepeatingTaskDueDateHandler.js | 44 + .../src/scheduledTasks/ScheduledTask.js | 96 ++ .../src/scheduledTasks/TaskOverdueChecker.js | 57 + .../src/scheduledTasks/index.js | 7 + .../exportSurveyResponses.test.js | 219 +-- .../importSurveyResponses.fixtures.js | 13 + .../testFunctionality.js | 15 + .../importSurveyResponses/testPermissions.js | 6 + .../taskComments/CreateTaskComment.test.js | 172 +++ .../taskComments/GETTaskComments.test.js | 149 ++ .../src/tests/apiV2/tasks/CreateTask.test.js | 25 +- .../src/tests/apiV2/tasks/EditTask.test.js | 295 +++- .../src/tests/apiV2/tasks/GETTasks.test.js | 19 +- .../functionality/nonPeriodicUpdates.xlsx | Bin 12836 -> 9093 bytes .../importResponsesFromSingleSurvey.xlsx | Bin 9241 -> 6989 bytes packages/database/README.md | 8 +- packages/database/package.json | 2 + packages/database/src/DatabaseModel.js | 33 +- packages/database/src/TupaiaDatabase.js | 14 +- .../TaskAssigneeEmailer.test.js | 173 +++ .../TaskCompletionHandler.test.js | 187 +++ .../TaskCreationHandler.test.js | 210 +++ .../changeHandlers/TaskUpdateHandler.test.js | 177 +++ .../src/__tests__/modelClasses/Task.test.js | 227 +++ .../src/changeHandlers/ChangeHandler.js | 4 + .../src/changeHandlers/TaskAssigneeEmailer.js | 84 ++ .../changeHandlers/TaskCompletionHandler.js | 90 ++ .../src/changeHandlers/TaskCreationHandler.js | 136 ++ .../src/changeHandlers/TaskUpdateHandler.js | 52 + packages/database/src/changeHandlers/index.js | 4 + packages/database/src/configureEnv.js | 1 + packages/database/src/getDbMigrator.js | 4 + ...310-AddCreatedAtToTasks-modifies-schema.js | 30 + ...AddTaskSurveyResponseId-modifies-schema.js | 43 + ...50-AddTaskCommentsTable-modifies-schema.js | 79 + ...askAndUserQuestionTypes-modifies-schema.js | 30 + ...ityPermissionsToSyncQueue-modifies-data.js | 53 + ...33844-AddUsersToSyncQueue-modifies-data.js | 54 + ...AddTaskInitialRequestId-modifies-schema.js | 43 + ...ChangeTaskDueDateToUnix-modifies-schema.js | 51 + ...928-AddParentTaskColumn-modifies-schema.js | 45 + ...dOverdueEmailSentColumn-modifies-schema.js | 20 + ...tTemplateVariableColumn-modifies-schema.js | 38 + packages/database/src/modelClasses/Entity.js | 23 +- .../src/modelClasses/PermissionGroup.js | 11 + .../src/modelClasses/SurveyResponse.js | 4 + packages/database/src/modelClasses/Task.js | 386 ++++- .../database/src/modelClasses/TaskComment.js | 30 + packages/database/src/modelClasses/User.js | 8 + packages/database/src/modelClasses/index.js | 3 + packages/database/src/records.js | 1 + packages/database/src/runPostMigration.js | 1 + packages/datatrak-web-server/examples.http | 5 + packages/datatrak-web-server/package.json | 1 + .../src/__tests__/TasksRoute.test.ts | 203 +++ .../__tests__/processSurveyResponse.test.ts | 16 + .../datatrak-web-server/src/app/createApp.ts | 119 +- packages/datatrak-web-server/src/constants.ts | 19 + .../src/routes/CreateTaskRoute.ts | 44 + .../src/routes/EditTaskRoute.ts | 33 + .../src/routes/EntityAncestorsRoute.ts | 36 + .../src/routes/EntityDescendantsRoute.ts | 2 + .../src/routes/PermissionGroupUsersRoute.ts | 43 + .../src/routes/SingleSurveyResponseRoute.ts | 47 +- .../SubmitSurveyResponseRoute.ts | 12 +- .../handleTaskCompletion.ts | 45 + .../processSurveyResponse.ts | 8 + .../src/routes/SurveyRoute.ts | 10 +- .../src/routes/SurveyUsersRoute.ts | 46 + .../src/routes/SurveysRoute.ts | 7 +- .../src/routes/TaskMetricsRoute.ts | 90 ++ .../src/routes/TaskRoute.ts | 60 + .../src/routes/TasksRoute.ts | 207 +++ .../src/routes/UserRoute.ts | 3 +- .../datatrak-web-server/src/routes/index.ts | 11 + packages/datatrak-web-server/src/types.ts | 8 + .../src/utils/formatTaskChanges.ts | 59 + .../src/utils/formatTaskResponse.ts | 75 + .../src/utils/getFilteredUsers.ts | 80 ++ .../src/utils/getParentEntityName.ts | 32 + .../datatrak-web-server/src/utils/index.ts | 4 + packages/datatrak-web/package.json | 5 + .../.well-known/apple-app-site-association | 1 + packages/datatrak-web/public/tupaia-pin.svg | 10 +- packages/datatrak-web/src/AppProviders.tsx | 2 +- .../Questions/EntityQuestion.test.tsx | 13 +- .../features/Questions/UserQuestion.test.tsx | 110 ++ .../usePrimaryEntityQuestionAutoFill.test.ts | 90 ++ .../src/api/CurrentUserContext.tsx | 2 +- .../datatrak-web/src/api/mutations/index.ts | 5 +- .../src/api/mutations/useCreateTask.ts | 30 + .../src/api/mutations/useCreateTaskComment.ts | 33 + .../src/api/mutations/useEditTask.ts | 33 + .../src/api/mutations/useEditUser.ts | 1 + .../src/api/mutations/useLogout.ts | 8 +- .../api/mutations/useSubmitSurveyResponse.ts | 6 +- .../datatrak-web/src/api/queries/index.ts | 6 + .../src/api/queries/useEntityAncestors.ts | 19 + .../api/queries/usePermissionGroupUsers.ts | 28 + .../src/api/queries/useProjectEntities.ts | 4 +- .../src/api/queries/useProjectSurveys.ts | 12 +- .../src/api/queries/useSurveyResponse.ts | 76 +- .../src/api/queries/useSurveyUsers.ts | 28 + .../datatrak-web/src/api/queries/useTask.ts | 18 + .../src/api/queries/useTaskMetrics.ts | 18 + .../datatrak-web/src/api/queries/useTasks.ts | 52 + .../src/components/Autocomplete.tsx | 51 + .../datatrak-web/src/components/Button.tsx | 16 +- .../src/components/Icons/ArrowLeftIcon.tsx | 24 + .../src/components/Icons/CommentIcon.tsx | 26 + .../src/components/Icons/TaskIcon.tsx | 29 + .../src/components/Icons/index.ts | 3 + .../src/components/SelectList/ListItem.tsx | 4 +- .../src/components/SelectList/SelectList.tsx | 22 +- .../src/components/SmallModal.tsx | 7 +- .../src/components/TaskMetrics/TaskMetric.tsx | 52 + .../components/TaskMetrics/TaskMetrics.tsx | 35 + .../TaskMetrics}/index.ts | 2 +- packages/datatrak-web/src/components/Tile.tsx | 1 + packages/datatrak-web/src/components/index.ts | 3 +- packages/datatrak-web/src/constants/url.ts | 3 + .../CountrySelector/CountrySelector.tsx} | 8 +- .../src/features/CountrySelector/index.ts | 7 + .../CountrySelector}/useUserCountries.ts | 14 +- .../EntitySelector/EntitySelector.tsx | 174 +++ .../ResultsList.tsx | 45 +- .../SearchField.tsx | 19 +- .../src/features/EntitySelector/index.ts | 5 + .../useEntityBaseFilters.ts} | 26 +- .../src/features/GroupedSurveyList.tsx | 114 ++ .../features/Leaderboard/LeaderboardTable.tsx | 2 +- .../src/features/Questions/EntityQuestion.tsx | 52 + .../EntityQuestion/EntityQuestion.tsx | 116 -- .../src/features/Questions/UserQuestion.tsx | 72 + .../src/features/Questions/index.ts | 1 + .../Survey/Components/SurveyQuestion.tsx | 2 + .../Survey/Components/SurveyReviewSection.tsx | 45 +- .../SurveySideMenu/SurveySideMenu.tsx | 11 +- .../features/Survey/Screens/SurveyScreen.tsx | 2 +- .../Survey/Screens/SurveySuccessScreen.tsx | 19 +- .../Survey/SurveyContext/SurveyContext.tsx | 111 +- .../features/Survey/SurveyContext/reducer.ts | 5 +- .../src/features/Survey/SurveyLayout.tsx | 21 +- .../datatrak-web/src/features/Survey/index.ts | 4 +- .../src/features/Survey/useSurveyRouting.ts | 35 +- .../features/Survey/useValidationResolver.ts | 9 + .../src/features/Survey/utils/index.ts | 6 + .../utils/usePrimaryEntityQuestionAutoFill.ts | 54 + .../Survey/utils/useSurveyResponseWithForm.ts | 86 ++ .../src/features/Survey/{ => utils}/utils.ts | 20 +- .../src/features/SurveyResponseModal.tsx | 160 +++ .../src/features/Tasks/AssigneeInput.tsx | 81 ++ .../src/features/Tasks/CancelTaskModal.tsx | 62 + .../src/features/Tasks/CommentsCount.tsx | 37 + .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 298 ++++ .../Tasks/CreateTaskModal/EntityInput.tsx | 78 + .../features/Tasks/CreateTaskModal/index.ts | 6 + .../src/features/Tasks/DueDatePicker.tsx | 135 ++ .../src/features/Tasks/NoTasksSection.tsx | 94 ++ .../features/Tasks/RepeatScheduleInput.tsx | 57 + .../src/features/Tasks/StatusPill.tsx | 60 + .../src/features/Tasks/TaskActionsMenu.tsx | 56 + .../Tasks/TaskDetails/TaskComments.tsx | 226 +++ .../Tasks/TaskDetails/TaskDetails.tsx | 277 ++++ .../Tasks/TaskDetails/TaskMetadata.tsx | 102 ++ .../src/features/Tasks/TaskDetails/index.ts | 6 + .../src/features/Tasks/TaskForm.tsx | 42 + .../src/features/Tasks/TaskPageHeader.tsx | 96 ++ .../src/features/Tasks/TaskSummary.tsx | 115 ++ .../src/features/Tasks/TaskTile.tsx | 123 ++ .../Tasks/TasksTable/ActionButton.tsx | 73 + .../Tasks/TasksTable/AssignTaskModal.tsx | 91 ++ .../Tasks/TasksTable/FilterToolbar.tsx | 84 ++ .../Tasks/TasksTable/RepeatScheduleFilter.tsx | 28 + .../Tasks/TasksTable/SelectFilter.tsx | 100 ++ .../Tasks/TasksTable/StatusFilter.tsx | 45 + .../features/Tasks/TasksTable/TasksTable.tsx | 258 ++++ .../src/features/Tasks/TasksTable/index.ts | 6 + .../datatrak-web/src/features/Tasks/index.ts | 12 + .../datatrak-web/src/features/Tasks/utils.ts | 72 + packages/datatrak-web/src/features/index.ts | 4 + .../datatrak-web/src/layout/Header/Header.tsx | 1 + .../src/layout/MainPageLayout.tsx | 3 +- .../src/layout/ScrollableLayout.tsx | 6 +- .../datatrak-web/src/layout/TasksLayout.tsx | 43 + .../src/layout/UserMenu/DrawerMenu.tsx | 2 +- .../src/layout/UserMenu/PopoverMenu.tsx | 1 - .../layout/UserMenu/ProjectSelectModal.tsx | 3 +- .../src/layout/UserMenu/UserInfo.tsx | 4 +- packages/datatrak-web/src/layout/index.ts | 1 + packages/datatrak-web/src/routes/Routes.tsx | 8 +- .../src/routes/SurveyResponseRoute.tsx | 8 +- .../datatrak-web/src/routes/SurveyRoutes.tsx | 6 +- packages/datatrak-web/src/types/index.ts | 1 + packages/datatrak-web/src/types/task.ts | 15 + .../src/types/userAccountDetails.ts | 4 +- packages/datatrak-web/src/utils/date.ts | 14 +- packages/datatrak-web/src/utils/ga.ts | 2 +- packages/datatrak-web/src/utils/index.ts | 8 +- .../src/utils/taskFilterSettings.ts | 30 + .../datatrak-web/src/utils/useDebounce.ts | 31 - .../datatrak-web/src/utils/useFromLocation.ts | 18 - .../src/utils/useLocationState.ts | 21 + .../DeleteAccountSection/UserDetails.tsx | 2 +- .../src/views/LandingPage/LandingPage.tsx | 39 +- .../src/views/LandingPage/SectionHeading.tsx | 1 + .../LandingPage/SurveyResponsesSection.tsx | 46 +- .../views/LandingPage/SurveySelectSection.tsx | 83 +- .../src/views/LandingPage/TasksSection.tsx | 118 ++ .../datatrak-web/src/views/SurveyPage.tsx | 7 +- .../src/views/SurveyResponsePage.tsx | 101 -- .../SurveySelectPage.tsx | 100 +- .../src/views/SurveySelectPage/index.ts | 6 - .../src/views/Tasks/TaskDetailsPage.tsx | 119 ++ .../src/views/Tasks/TasksDashboardPage.tsx | 65 + .../datatrak-web/src/views/Tasks/index.ts | 7 + packages/datatrak-web/src/views/index.ts | 2 +- .../EntityDescendantRoutes.test.ts | 13 + .../hierarchy/EntityDescendantsRoute.ts | 16 +- .../middleware/attachCommonEntityContext.ts | 2 +- .../src/routes/hierarchy/types.ts | 1 + .../AdminPanel/components/RejectButton.jsx | 9 +- .../meditrak-app/android/app/build.gradle | 2 +- .../meditrak-app/app/assessment/Question.jsx | 2 + .../specificQuestions/UserQuestion.jsx | 159 +++ .../assessment/specificQuestions/index.jsx | 2 + .../app/database/DatabaseAccess.jsx | 56 + .../app/database/RealmExplorer.jsx | 31 +- packages/meditrak-app/app/database/schema.jsx | 3 +- .../app/database/types/PermissionGroup.jsx | 5 +- .../app/database/types/UserAccount.jsx | 32 + .../database/types/UserEntityPermission.jsx | 28 + .../meditrak-app/app/database/types/index.jsx | 2 + .../meditrak-app/app/sync/syncMigrations.jsx | 17 +- .../app/widgets/Autocomplete/Autocomplete.jsx | 3 +- .../TupaiaMediTrak.xcodeproj/project.pbxproj | 4 +- packages/meditrak-app/package.json | 2 +- packages/psss/src/api/mutations.js | 14 +- packages/psss/src/api/queries/useAlerts.js | 10 +- .../src/components/Modal/ConfirmModal.jsx | 15 +- packages/psss/src/components/Modal/index.js | 1 + .../containers/Modals/ArchiveAlertModal.jsx | 2 +- .../containers/Modals/DeleteAlertModal.jsx | 4 +- .../server-boilerplate/src/models/Entity.ts | 6 +- .../server-boilerplate/src/models/Task.ts | 15 + .../src/models/TaskComment.ts | 14 + .../server-boilerplate/src/models/User.ts | 1 + .../src/models/UserEntityPermission.ts | 7 +- .../server-boilerplate/src/models/index.ts | 2 + .../server-boilerplate/src/models/types.ts | 1 + .../src/utils/emailAfterTimeout.ts | 34 +- packages/server-utils/package.json | 7 +- .../server-utils/src/constructExportEmail.ts | 23 +- packages/server-utils/src/email/index.ts | 6 + .../server-utils/src/{ => email}/sendEmail.ts | 57 +- .../content/dashboardSubscription.html | 3 + .../templates/content/deleteAccount.html | 6 + .../templates/content/emailAfterTimeout.html | 4 + .../email/templates/content/overdueTask.html | 12 + .../templates/content/passwordReset.html | 11 + .../templates/content/permissionGranted.html | 13 + .../content/requestCountryAccess.html | 19 + .../email/templates/content/taskAssigned.html | 9 + .../email/templates/content/verifyEmail.html | 8 + .../src/email/templates/wrapper.html | 120 ++ packages/server-utils/src/index.ts | 2 +- .../src/routes/EmailDashboardRoute.ts | 12 +- .../src/api/queries/useEntitySearch.ts | 2 +- packages/tupaia-web/src/utils/index.ts | 1 - packages/types/config/models/config.json | 8 +- packages/types/src/schemas/schemas.ts | 1267 ++++++++++++++++- packages/types/src/types/index.ts | 5 + .../types/src/types/models-extra/index.ts | 3 + .../src/types/models-extra/survey/index.ts | 2 + .../survey/surveyScreenComponent.ts | 34 +- packages/types/src/types/models-extra/task.ts | 42 + packages/types/src/types/models.ts | 66 +- .../EntityDescendantsRequest.ts | 2 + .../SingleSurveyResponseRequest.ts | 3 + .../SubmitSurveyResponseRequest.ts | 7 +- .../datatrak-web-server/SurveyRequest.ts | 2 + .../datatrak-web-server/TaskChangeRequest.ts | 22 + .../datatrak-web-server/TaskMetricsRequest.ts | 8 + .../datatrak-web-server/TaskRequest.ts | 22 + .../datatrak-web-server/TasksRequest.ts | 51 + .../datatrak-web-server/UserRequest.ts | 2 +- .../datatrak-web-server/UsersRequest.ts | 20 + .../requests/datatrak-web-server/index.ts | 5 + packages/types/src/types/requests/index.ts | 5 + .../src/components/ActionsMenu.tsx | 32 +- .../ui-components/src/components/Alert.tsx | 1 + .../ui-components/src/components/Button.tsx | 2 +- .../src/components/FilterableTable/Cells.tsx | 23 +- .../components/FilterableTable/FilterCell.tsx | 65 +- .../FilterableTable/FilterableTable.tsx | 47 +- .../components/FilterableTable/Pagination.tsx | 12 +- .../src/components/Inputs/Autocomplete.tsx | 1 + .../src/components/Modal/ImportModal.jsx | 184 --- .../src/components/Modal/Modal.tsx} | 60 +- .../Modal/ModalCenteredContent.tsx} | 0 .../Modal/ModalContentProvider.tsx} | 60 +- .../src/components/Modal/ModalHeader.tsx} | 28 +- .../src/components/Modal/index.js | 7 - .../src/components/Modal/index.ts} | 0 .../src/components/Pagination.tsx | 44 +- packages/ui-components/src/hooks/index.js | 6 + .../src/hooks}/useDebounce.ts | 5 +- .../src/types/react-table-config.d.ts | 1 - packages/utils/package.json | 1 + packages/utils/src/__tests__/rrule.test.js | 245 ++++ packages/utils/src/index.js | 1 + packages/utils/src/rrule.js | 116 ++ yarn.lock | 163 ++- 391 files changed, 13712 insertions(+), 1714 deletions(-) create mode 100644 env/platform.env.example delete mode 100644 packages/admin-panel/src/utilities/useDebounce.js create mode 100644 packages/central-server/scripts/scheduledTask.js create mode 100644 packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/TaskConfigCellBuilder.js create mode 100644 packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/UserConfigCellBuilder.js create mode 100644 packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processTaskConfig.js create mode 100644 packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processUserConfig.js create mode 100644 packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/TaskConfigValidator.js create mode 100644 packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/UserConfigValidator.js 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/scheduledTasks/RepeatingTaskDueDateHandler.js create mode 100644 packages/central-server/src/scheduledTasks/ScheduledTask.js create mode 100644 packages/central-server/src/scheduledTasks/TaskOverdueChecker.js create mode 100644 packages/central-server/src/scheduledTasks/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 create mode 100644 packages/database/src/__tests__/changeHandlers/TaskAssigneeEmailer.test.js create mode 100644 packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js create mode 100644 packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js create mode 100644 packages/database/src/__tests__/changeHandlers/TaskUpdateHandler.test.js create mode 100644 packages/database/src/__tests__/modelClasses/Task.test.js create mode 100644 packages/database/src/changeHandlers/TaskAssigneeEmailer.js create mode 100644 packages/database/src/changeHandlers/TaskCompletionHandler.js create mode 100644 packages/database/src/changeHandlers/TaskCreationHandler.js create mode 100644 packages/database/src/changeHandlers/TaskUpdateHandler.js create mode 100644 packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js create mode 100644 packages/database/src/migrations/20240715234341-AddTaskSurveyResponseId-modifies-schema.js create mode 100644 packages/database/src/migrations/20240719015050-AddTaskCommentsTable-modifies-schema.js create mode 100644 packages/database/src/migrations/20240724001022-AddTaskAndUserQuestionTypes-modifies-schema.js create mode 100644 packages/database/src/migrations/20240730032928-AddUserEntityPermissionsToSyncQueue-modifies-data.js create mode 100644 packages/database/src/migrations/20240730033844-AddUsersToSyncQueue-modifies-data.js create mode 100644 packages/database/src/migrations/20240806015831-AddTaskInitialRequestId-modifies-schema.js create mode 100644 packages/database/src/migrations/20240809001011-ChangeTaskDueDateToUnix-modifies-schema.js create mode 100644 packages/database/src/migrations/20240813211928-AddParentTaskColumn-modifies-schema.js create mode 100644 packages/database/src/migrations/20240815035734-AddOverdueEmailSentColumn-modifies-schema.js create mode 100644 packages/database/src/migrations/20240821224328-AddTaskCommentTemplateVariableColumn-modifies-schema.js create mode 100644 packages/database/src/modelClasses/TaskComment.js create mode 100644 packages/datatrak-web-server/src/__tests__/TasksRoute.test.ts create mode 100644 packages/datatrak-web-server/src/routes/CreateTaskRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/EditTaskRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/EntityAncestorsRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/PermissionGroupUsersRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/SubmitSurveyReponse/handleTaskCompletion.ts create mode 100644 packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/TaskRoute.ts create mode 100644 packages/datatrak-web-server/src/routes/TasksRoute.ts create mode 100644 packages/datatrak-web-server/src/utils/formatTaskChanges.ts create mode 100644 packages/datatrak-web-server/src/utils/formatTaskResponse.ts create mode 100644 packages/datatrak-web-server/src/utils/getFilteredUsers.ts create mode 100644 packages/datatrak-web-server/src/utils/getParentEntityName.ts create mode 100644 packages/datatrak-web/src/__tests__/features/Questions/UserQuestion.test.tsx create mode 100644 packages/datatrak-web/src/__tests__/features/Survey/usePrimaryEntityQuestionAutoFill.test.ts create mode 100644 packages/datatrak-web/src/api/mutations/useCreateTask.ts create mode 100644 packages/datatrak-web/src/api/mutations/useCreateTaskComment.ts create mode 100644 packages/datatrak-web/src/api/mutations/useEditTask.ts create mode 100644 packages/datatrak-web/src/api/queries/useEntityAncestors.ts create mode 100644 packages/datatrak-web/src/api/queries/usePermissionGroupUsers.ts create mode 100644 packages/datatrak-web/src/api/queries/useSurveyUsers.ts create mode 100644 packages/datatrak-web/src/api/queries/useTask.ts create mode 100644 packages/datatrak-web/src/api/queries/useTaskMetrics.ts create mode 100644 packages/datatrak-web/src/api/queries/useTasks.ts create mode 100644 packages/datatrak-web/src/components/Icons/ArrowLeftIcon.tsx create mode 100644 packages/datatrak-web/src/components/Icons/CommentIcon.tsx create mode 100644 packages/datatrak-web/src/components/Icons/TaskIcon.tsx create mode 100644 packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx create mode 100644 packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx rename packages/datatrak-web/src/{features/Questions/EntityQuestion => components/TaskMetrics}/index.ts (61%) rename packages/datatrak-web/src/{views/SurveySelectPage/SurveyCountrySelector.tsx => features/CountrySelector/CountrySelector.tsx} (90%) create mode 100644 packages/datatrak-web/src/features/CountrySelector/index.ts rename packages/datatrak-web/src/{views/SurveySelectPage => features/CountrySelector}/useUserCountries.ts (83%) create mode 100644 packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx rename packages/datatrak-web/src/features/{Questions/EntityQuestion => EntitySelector}/ResultsList.tsx (58%) rename packages/datatrak-web/src/features/{Questions/EntityQuestion => EntitySelector}/SearchField.tsx (88%) create mode 100644 packages/datatrak-web/src/features/EntitySelector/index.ts rename packages/datatrak-web/src/features/{Questions/EntityQuestion/utils.ts => EntitySelector/useEntityBaseFilters.ts} (50%) create mode 100644 packages/datatrak-web/src/features/GroupedSurveyList.tsx create mode 100644 packages/datatrak-web/src/features/Questions/EntityQuestion.tsx delete mode 100644 packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx create mode 100644 packages/datatrak-web/src/features/Questions/UserQuestion.tsx create mode 100644 packages/datatrak-web/src/features/Survey/utils/index.ts create mode 100644 packages/datatrak-web/src/features/Survey/utils/usePrimaryEntityQuestionAutoFill.ts create mode 100644 packages/datatrak-web/src/features/Survey/utils/useSurveyResponseWithForm.ts rename packages/datatrak-web/src/features/Survey/{ => utils}/utils.ts (73%) create mode 100644 packages/datatrak-web/src/features/SurveyResponseModal.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CancelTaskModal.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CommentsCount.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts create mode 100644 packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/NoTasksSection.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/StatusPill.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskDetails/index.ts create mode 100644 packages/datatrak-web/src/features/Tasks/TaskForm.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskSummary.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TaskTile.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/FilterToolbar.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/RepeatScheduleFilter.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/SelectFilter.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/StatusFilter.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/TasksTable/index.ts create mode 100644 packages/datatrak-web/src/features/Tasks/index.ts create mode 100644 packages/datatrak-web/src/features/Tasks/utils.ts create mode 100644 packages/datatrak-web/src/layout/TasksLayout.tsx create mode 100644 packages/datatrak-web/src/types/task.ts create mode 100644 packages/datatrak-web/src/utils/taskFilterSettings.ts delete mode 100644 packages/datatrak-web/src/utils/useDebounce.ts delete mode 100644 packages/datatrak-web/src/utils/useFromLocation.ts create mode 100644 packages/datatrak-web/src/utils/useLocationState.ts create mode 100644 packages/datatrak-web/src/views/LandingPage/TasksSection.tsx delete mode 100644 packages/datatrak-web/src/views/SurveyResponsePage.tsx rename packages/datatrak-web/src/views/{SurveySelectPage => }/SurveySelectPage.tsx (58%) delete mode 100644 packages/datatrak-web/src/views/SurveySelectPage/index.ts create mode 100644 packages/datatrak-web/src/views/Tasks/TaskDetailsPage.tsx create mode 100644 packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx create mode 100644 packages/datatrak-web/src/views/Tasks/index.ts create mode 100644 packages/meditrak-app/app/assessment/specificQuestions/UserQuestion.jsx create mode 100644 packages/meditrak-app/app/database/types/UserAccount.jsx create mode 100644 packages/meditrak-app/app/database/types/UserEntityPermission.jsx rename packages/{ui-components => psss}/src/components/Modal/ConfirmModal.jsx (91%) create mode 100644 packages/server-boilerplate/src/models/Task.ts create mode 100644 packages/server-boilerplate/src/models/TaskComment.ts create mode 100644 packages/server-utils/src/email/index.ts rename packages/server-utils/src/{ => email}/sendEmail.ts (52%) create mode 100644 packages/server-utils/src/email/templates/content/dashboardSubscription.html create mode 100644 packages/server-utils/src/email/templates/content/deleteAccount.html create mode 100644 packages/server-utils/src/email/templates/content/emailAfterTimeout.html create mode 100644 packages/server-utils/src/email/templates/content/overdueTask.html create mode 100644 packages/server-utils/src/email/templates/content/passwordReset.html create mode 100644 packages/server-utils/src/email/templates/content/permissionGranted.html create mode 100644 packages/server-utils/src/email/templates/content/requestCountryAccess.html create mode 100644 packages/server-utils/src/email/templates/content/taskAssigned.html create mode 100644 packages/server-utils/src/email/templates/content/verifyEmail.html create mode 100644 packages/server-utils/src/email/templates/wrapper.html create mode 100644 packages/types/src/types/models-extra/task.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/UsersRequest.ts delete mode 100644 packages/ui-components/src/components/Modal/ImportModal.jsx rename packages/{admin-panel/src/widgets/Modal/Modal.jsx => ui-components/src/components/Modal/Modal.tsx} (64%) rename packages/{admin-panel/src/widgets/Modal/ModalCenteredContent.jsx => ui-components/src/components/Modal/ModalCenteredContent.tsx} (100%) rename packages/{admin-panel/src/widgets/Modal/ModalContentProvider.jsx => ui-components/src/components/Modal/ModalContentProvider.tsx} (74%) rename packages/{admin-panel/src/widgets/Modal/ModalHeader.jsx => ui-components/src/components/Modal/ModalHeader.tsx} (59%) delete mode 100644 packages/ui-components/src/components/Modal/index.js rename packages/{admin-panel/src/widgets/Modal/index.js => ui-components/src/components/Modal/index.ts} (100%) rename packages/{tupaia-web/src/utils => ui-components/src/hooks}/useDebounce.ts (93%) create mode 100644 packages/utils/src/__tests__/rrule.test.js create mode 100644 packages/utils/src/rrule.js diff --git a/env/platform.env.example b/env/platform.env.example new file mode 100644 index 0000000000..9e7a191df9 --- /dev/null +++ b/env/platform.env.example @@ -0,0 +1,4 @@ +TUPAIA_FRONT_END_URL= +DATATRAK_FRONT_END_URL= +LESMIS_FRONT_END_URL= +ADMIN_PANEL_FRONT_END_URL= diff --git a/packages/admin-panel/src/VizBuilderApp/components/DashboardItem/DashboardItemMetadataForm.jsx b/packages/admin-panel/src/VizBuilderApp/components/DashboardItem/DashboardItemMetadataForm.jsx index 881f8e4e6a..220f77ce7a 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DashboardItem/DashboardItemMetadataForm.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/DashboardItem/DashboardItemMetadataForm.jsx @@ -5,10 +5,9 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useForm, Controller } from 'react-hook-form'; -import { Autocomplete, TextField } from '@tupaia/ui-components'; +import { Autocomplete, TextField, useDebounce } from '@tupaia/ui-components'; import { useSearchPermissionGroups } from '../../api/queries'; import { useVizConfigContext } from '../../context'; -import { useDebounce } from '../../../utilities'; import { DASHBOARD_ITEM_VIZ_TYPES } from '../../constants'; import { REQUIRED_FIELD_ERROR } from '../../../editor/validation'; diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx index 5da6d3715a..687d2cf931 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx @@ -7,7 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Dialog } from '@material-ui/core'; import styled from 'styled-components'; -import { ModalHeader } from '../../../../widgets'; +import { ModalHeader } from '@tupaia/ui-components'; const Wrapper = styled.div` height: 80vh; diff --git a/packages/admin-panel/src/VizBuilderApp/components/MapOverlay/MapOverlayMetadataForm.jsx b/packages/admin-panel/src/VizBuilderApp/components/MapOverlay/MapOverlayMetadataForm.jsx index b784ab7da3..bf596ce2cc 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/MapOverlay/MapOverlayMetadataForm.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/MapOverlay/MapOverlayMetadataForm.jsx @@ -5,11 +5,10 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useForm, Controller } from 'react-hook-form'; -import { Autocomplete, TextField } from '@tupaia/ui-components'; +import { Autocomplete, TextField, useDebounce } from '@tupaia/ui-components'; import Chip from '@material-ui/core/Chip'; import { useCountries, useProjects, useSearchPermissionGroups } from '../../api/queries'; import { useVizConfigContext } from '../../context'; -import { useDebounce } from '../../../utilities'; import { MAP_OVERLAY_VIZ_TYPES } from '../../constants'; import { REQUIRED_FIELD_ERROR } from '../../../editor'; diff --git a/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx index 50754fbf48..7fc17101cb 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx @@ -4,11 +4,16 @@ */ import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Button, Dialog } from '@tupaia/ui-components'; +import { + Button, + Dialog, + ModalContentProvider, + ModalFooter, + ModalHeader, +} from '@tupaia/ui-components'; import { DashboardItemMetadataForm } from '../DashboardItem'; import { MapOverlayMetadataForm } from '../MapOverlay'; import { DASHBOARD_ITEM_OR_MAP_OVERLAY_PARAM } from '../../constants'; -import { ModalContentProvider, ModalFooter, ModalHeader } from '../../../widgets'; export const EditModal = () => { const { dashboardItemOrMapOverlay } = useParams(); diff --git a/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx index 964c2adb75..c737241530 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx @@ -8,11 +8,11 @@ import PropTypes from 'prop-types'; import styled from 'styled-components'; import { Link as RouterLink, useParams } from 'react-router-dom'; import Typography from '@material-ui/core/Typography'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { DASHBOARD_ITEM_OR_MAP_OVERLAY_PARAM, MODAL_STATUS } from '../../constants'; import { useVisualisationContext, useVizConfigContext } from '../../context'; import { useSaveDashboardVisualisation, useSaveMapOverlayVisualisation } from '../../api'; import { useVizBuilderBasePath } from '../../utils'; -import { Modal, ModalCenteredContent } from '../../../widgets'; const Heading = styled(Typography).attrs({ variant: 'h3', @@ -95,7 +95,7 @@ export const SaveVisualisationModal = ({ isOpen, onClose }) => { isOpen={isOpen} title="Save visualisation" isLoading={status === MODAL_STATUS.LOADING} - errorMessage={error?.message} + error={error} buttons={[ { text: 'Cancel', diff --git a/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/PreviewOptions.jsx b/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/PreviewOptions.jsx index ad0ae4cbeb..af20a122eb 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/PreviewOptions.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/PreviewOptions/PreviewOptions.jsx @@ -6,13 +6,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import InsertDriveFileIcon from '@material-ui/icons/InsertDriveFile'; -import { FlexEnd, FlexSpaceBetween, FlexStart, ImportModal } from '@tupaia/ui-components'; +import { FlexEnd, FlexSpaceBetween, FlexStart } from '@tupaia/ui-components'; import { usePreviewDataContext, useVizConfigContext } from '../../context'; import { LinkButton } from '../LinkButton'; import { useUploadTestData } from '../../api'; import { ProjectField } from './ProjectField'; import { LocationField } from './LocationField'; import { DateRangeField } from './DateRangeField'; +import { ImportModal } from '../../../importExport'; const Container = styled(FlexSpaceBetween)` padding: 24px 0; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.jsx b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.jsx index f94d974e1e..b38d6d3257 100644 --- a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.jsx +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.jsx @@ -4,13 +4,11 @@ */ import React, { useState } from 'react'; - import PropTypes from 'prop-types'; - +import { useDebounce } from '@tupaia/ui-components'; import { ParameterType } from '../../editing'; import { useEntities } from '../../../../VizBuilderApp/api'; import { Autocomplete } from '../../../../autocomplete'; -import { useDebounce } from '../../../../utilities'; import { getArrayFieldValue } from './utils'; export const OrganisationUnitCodesField = ({ name, onChange }) => { diff --git a/packages/admin-panel/src/editor/EditModal.jsx b/packages/admin-panel/src/editor/EditModal.jsx index 6e38bdd9ea..3ced053303 100644 --- a/packages/admin-panel/src/editor/EditModal.jsx +++ b/packages/admin-panel/src/editor/EditModal.jsx @@ -6,9 +6,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { Modal } from '@tupaia/ui-components'; import { dismissEditor } from './actions'; import { UsedBy } from '../usedBy/UsedBy'; -import { Modal } from '../widgets'; import { useEditFiles } from './useEditFiles'; import { FieldsEditor } from './FieldsEditor'; import { withConnectedEditor } from './withConnectedEditor'; diff --git a/packages/admin-panel/src/importExport/ExportModal.jsx b/packages/admin-panel/src/importExport/ExportModal.jsx index 1c7a2c3803..0c0e51902f 100644 --- a/packages/admin-panel/src/importExport/ExportModal.jsx +++ b/packages/admin-panel/src/importExport/ExportModal.jsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { Modal } from '../widgets'; +import { Modal } from '@tupaia/ui-components'; import { useApiContext } from '../utilities/ApiProvider'; import { ActionButton } from '../editor'; import { ExportIcon } from '../icons'; diff --git a/packages/admin-panel/src/importExport/ImportModal.jsx b/packages/admin-panel/src/importExport/ImportModal.jsx index 4346a40ca1..ef4515b59d 100644 --- a/packages/admin-panel/src/importExport/ImportModal.jsx +++ b/packages/admin-panel/src/importExport/ImportModal.jsx @@ -6,13 +6,12 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { FileUploadField } from '@tupaia/ui-components'; -import { Modal } from '../widgets'; +import { FileUploadField, Modal } from '@tupaia/ui-components'; +import { InputField } from '../widgets'; import { useApiContext } from '../utilities/ApiProvider'; import { DATA_CHANGE_ERROR, DATA_CHANGE_REQUEST, DATA_CHANGE_SUCCESS } from '../table/constants'; import { checkVisibilityCriteriaAreMet, labelToId } from '../utilities'; import { ActionButton } from '../editor'; -import { InputField } from '../widgets/InputField/InputField'; import { ImportIcon } from '../icons'; const STATUS = { diff --git a/packages/admin-panel/src/library.js b/packages/admin-panel/src/library.js index 35cf466161..5140ac74a7 100644 --- a/packages/admin-panel/src/library.js +++ b/packages/admin-panel/src/library.js @@ -31,14 +31,7 @@ export { PrivateRoute } from './authentication'; export { getHasBESAdminAccess } from './utilities/getHasBESAdminAccess'; export * from './pages/resources'; export { ReduxAutocomplete } from './autocomplete'; -export { - IconButton, - ModalContentProvider, - Modal, - ModalFooter, - ModalHeader, - ModalCenteredContent, -} from './widgets'; +export { IconButton } from './widgets'; export { AdminPanelDataProviders } from './utilities/AdminPanelProviders'; export { useApiContext } from './utilities/ApiProvider'; export { DataChangeAction, ActionButton } from './editor'; diff --git a/packages/admin-panel/src/logsTable/LogsModal.jsx b/packages/admin-panel/src/logsTable/LogsModal.jsx index 366b313fef..8095a6a142 100644 --- a/packages/admin-panel/src/logsTable/LogsModal.jsx +++ b/packages/admin-panel/src/logsTable/LogsModal.jsx @@ -6,8 +6,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { Modal } from '@tupaia/ui-components'; import { changeLogsTablePage, closeLogsModal } from './actions'; -import { Modal } from '../widgets'; import { LogsTable } from './LogsTable'; export const LogsModalComponent = ({ diff --git a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx index bce2bbc0ea..28ce086b10 100644 --- a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx +++ b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx @@ -8,7 +8,7 @@ import keyBy from 'lodash.keyby'; import { connect } from 'react-redux'; import { useNavigate, useParams } from 'react-router'; import styled from 'styled-components'; -import { Button, SpinningLoader } from '@tupaia/ui-components'; +import { Button, SpinningLoader, Modal } from '@tupaia/ui-components'; import { Breadcrumbs } from '../../../layout'; import { useItemDetails } from '../../../api/queries/useResourceDetails'; import { useValidationScroll, withConnectedEditor } from '../../../editor'; @@ -16,7 +16,6 @@ import { useEditFiles } from '../../../editor/useEditFiles'; import { FileUploadField } from '../../../widgets/InputField/FileUploadField'; import { FieldsEditor } from '../../../editor/FieldsEditor'; import { dismissEditor, loadEditor, resetEdits } from '../../../editor/actions'; -import { Modal } from '../../../widgets'; import { useLinkToPreviousSearchState } from '../../../utilities'; const Wrapper = styled.div` diff --git a/packages/admin-panel/src/qrCode/QrCodeModal.jsx b/packages/admin-panel/src/qrCode/QrCodeModal.jsx index ac4546f5cf..c8f2bff785 100644 --- a/packages/admin-panel/src/qrCode/QrCodeModal.jsx +++ b/packages/admin-panel/src/qrCode/QrCodeModal.jsx @@ -6,9 +6,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { Modal } from '@tupaia/ui-components'; import { QrCodeContainer } from './QrCodeContainer'; import { closeQrCodeModal } from './actions'; -import { Modal } from '../widgets'; export const QrCodeModalComponent = ({ isOpen, onDismiss, qrCodeContents, humanReadableId }) => { return ( diff --git a/packages/admin-panel/src/routes/surveys/surveyResponses.jsx b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx index 31ef7824ed..a5870ef4fa 100644 --- a/packages/admin-panel/src/routes/surveys/surveyResponses.jsx +++ b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx @@ -150,7 +150,9 @@ export const ANSWER_COLUMNS = [ source: 'text', type: 'tooltip', accessor: row => { - return row['entity.code'] || row.text; + if (row['entity.code']) return row['entity.code']; + if (row['user.full_name']) return `${row['user.full_name']} (${row.text})`; + return row.text; }, }, { @@ -158,6 +160,11 @@ export const ANSWER_COLUMNS = [ show: false, source: 'entity.code', }, + { + Header: 'UserName', + show: false, + source: 'user.full_name', + }, ]; const IMPORT_CONFIG = { diff --git a/packages/admin-panel/src/routes/surveys/surveys.js b/packages/admin-panel/src/routes/surveys/surveys.js index 85385f8e3f..159e9b2856 100644 --- a/packages/admin-panel/src/routes/surveys/surveys.js +++ b/packages/admin-panel/src/routes/surveys/surveys.js @@ -490,6 +490,62 @@ const QUESTION_COLUMNS = [ }, ], }, + { + label: 'Task', + fieldName: 'task', + type: 'json', + getJsonFieldSchema: () => [ + { + label: 'Should create task', + fieldName: 'shouldCreateTask', + type: 'json', + getJsonFieldSchema: () => [{ label: 'Question Id', fieldName: 'questionId' }], + }, + { + label: 'Entity ID', + fieldName: 'entityId', + type: 'json', + getJsonFieldSchema: () => [{ label: 'Question Id', fieldName: 'questionId' }], + }, + + { + label: 'Survey code', + fieldName: 'surveyCode', + optionsEndpoint: 'surveys', + optionLabelKey: 'name', + optionValueKey: 'code', + labelTooltip: 'Select the survey this task should be assigned for', + }, + { + label: 'Due date', + fieldName: 'dueDate', + type: 'json', + getJsonFieldSchema: () => [{ label: 'Question Id', fieldName: 'questionId' }], + }, + + { + label: 'Assignee', + fieldName: 'assignee', + type: 'json', + getJsonFieldSchema: () => [{ label: 'Question Id', fieldName: 'questionId' }], + }, + ], + }, + { + label: 'User', + fieldName: 'user', + type: 'json', + getJsonFieldSchema: () => [ + { + label: 'Permission group name', + fieldName: 'permissionGroup', + optionsEndpoint: 'permissionGroups', + optionLabelKey: 'name', + optionValueKey: 'id', + labelTooltip: 'Select the permission group the user list should be filtered by', + }, + ], + }, ], }, }, diff --git a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx index fa845cc082..3184c97a30 100644 --- a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx +++ b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx @@ -7,13 +7,13 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import generateId from 'uuid/v1'; -import { TextField } from '@tupaia/ui-components'; +import { TextField, Modal } from '@tupaia/ui-components'; import { getUniqueFileNameParts } from '@tupaia/utils'; import EditIcon from '@material-ui/icons/Edit'; import DeleteIcon from '@material-ui/icons/Delete'; import ExportIcon from '@material-ui/icons/GetApp'; import { FileUploadField } from '../widgets/InputField/FileUploadField'; -import { IconButton, Modal } from '../widgets'; +import { IconButton } from '../widgets'; import { useApiContext } from '../utilities/ApiProvider'; const Container = styled.div` diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index 4bfbcd9e76..fe1035991f 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -6,9 +6,8 @@ import React, { useState, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { Button } from '@tupaia/ui-components'; +import { Button, ModalContentProvider, ModalFooter } from '@tupaia/ui-components'; import { useGetExistingData } from './useGetExistingData'; -import { ModalContentProvider, ModalFooter } from '../widgets'; import { useEditSurveyResponse } from '../api/mutations/useEditSurveyResponse'; import { ResponseFields } from './ResponseFields'; diff --git a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx index ab593f8047..51c397247f 100644 --- a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx +++ b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx @@ -7,11 +7,10 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import PropTypes from 'prop-types'; import { Typography } from '@material-ui/core'; -import { Select, DateTimePicker } from '@tupaia/ui-components'; +import { Select, DateTimePicker, useDebounce } from '@tupaia/ui-components'; import { ApprovalStatus } from '@tupaia/types'; import { format } from 'date-fns'; import { Autocomplete } from '../autocomplete'; -import { useDebounce } from '../utilities'; import { useEntities } from '../VizBuilderApp/api'; import { EntityOptionLabel } from '../widgets'; diff --git a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx index bd03253ade..33158c5968 100644 --- a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx +++ b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx @@ -6,10 +6,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { Dialog } from '@tupaia/ui-components'; +import { Dialog, ModalHeader } from '@tupaia/ui-components'; import { closeResubmitSurveyModal, onAfterMutate as onAfterMutateAction } from './actions'; import { Form } from './Form'; -import { ModalHeader } from '../widgets'; export const ResubmitSurveyResponseModalComponent = ({ isOpen, diff --git a/packages/admin-panel/src/surveys/useEditSurveyField.js b/packages/admin-panel/src/surveys/useEditSurveyField.js index e953342129..80609510c5 100644 --- a/packages/admin-panel/src/surveys/useEditSurveyField.js +++ b/packages/admin-panel/src/surveys/useEditSurveyField.js @@ -4,7 +4,7 @@ */ import { useEffect, useState } from 'react'; -import { useDebounce } from '../utilities'; +import { useDebounce } from '@tupaia/ui-components'; import { useApiContext } from '../utilities/ApiProvider'; import { useSuggestSurveyCode } from './useSuggestSurveyCode'; diff --git a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx index c5129e818e..6e2f7d04d2 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx +++ b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx @@ -221,7 +221,6 @@ const DataFetchingTableComponent = memo( diff --git a/packages/admin-panel/src/utilities/index.js b/packages/admin-panel/src/utilities/index.js index 0b5d4068f7..1a3582dda8 100644 --- a/packages/admin-panel/src/utilities/index.js +++ b/packages/admin-panel/src/utilities/index.js @@ -9,7 +9,6 @@ export { convertSearchTermToFilter } from './convertSearchTermToFilter'; export { makeSubstitutionsInString } from './makeSubstitutionsInString'; export { usePortalWithCallback } from './usePortalWithCallback'; export * from './pretty'; -export * from './useDebounce'; export { checkVisibilityCriteriaAreMet } from './visibilityCriteria'; export { labelToId } from './labelToId'; export { getColumns, getRows } from './getRowsAndColumns'; diff --git a/packages/admin-panel/src/utilities/useDebounce.js b/packages/admin-panel/src/utilities/useDebounce.js deleted file mode 100644 index 7c5497b220..0000000000 --- a/packages/admin-panel/src/utilities/useDebounce.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ -import React from 'react'; - -export const useDebounce = (value, delay = 500) => { - const [debouncedValue, setDebouncedValue] = React.useState(value); - - React.useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - // Cancel the timeout if value changes (also on delay change or unmount) - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; -}; diff --git a/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx b/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx index 45199978ae..87189b2a04 100644 --- a/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx +++ b/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx @@ -7,7 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Typography from '@material-ui/core/Typography'; import styled from 'styled-components'; -import { Modal, ModalCenteredContent } from './Modal'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; const Heading = styled(Typography).attrs({ variant: 'h3', diff --git a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx index 86d1911825..da6e81ab9b 100644 --- a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx +++ b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx @@ -16,10 +16,10 @@ import { Typography, } from '@material-ui/core'; import { Search } from '@material-ui/icons'; -import { InputLabel } from '@tupaia/ui-components'; +import { InputLabel, useDebounce } from '@tupaia/ui-components'; import { get } from '../../VizBuilderApp/api/api'; import { Checkbox } from '../Checkbox'; -import { convertSearchTermToFilter, useDebounce } from '../../utilities'; +import { convertSearchTermToFilter } from '../../utilities'; const useOptions = ( endpoint, diff --git a/packages/admin-panel/src/widgets/index.js b/packages/admin-panel/src/widgets/index.js index b9f573ca30..7c4a095149 100644 --- a/packages/admin-panel/src/widgets/index.js +++ b/packages/admin-panel/src/widgets/index.js @@ -8,13 +8,6 @@ export { InputField, JsonEditorInputField } from './InputField'; export { Tabs } from './Tabs'; export { PageHeader } from './PageHeader'; export { PageBody, Footer } from '../layout'; -export { - ModalContentProvider, - Modal, - ModalFooter, - ModalHeader, - ModalCenteredContent, -} from './Modal'; export { JsonEditor, JsonTreeEditor } from './JsonEditor'; export { SecondaryNavbar } from '../layout/navigation/SecondaryNavbar'; export { ConfirmDeleteModal } from './ConfirmDeleteModal'; diff --git a/packages/api-client/src/connections/CentralApi.ts b/packages/api-client/src/connections/CentralApi.ts index 637db38264..f4b3cf1346 100644 --- a/packages/api-client/src/connections/CentralApi.ts +++ b/packages/api-client/src/connections/CentralApi.ts @@ -5,7 +5,7 @@ import type { MeditrakSurveyResponseRequest } from '@tupaia/types'; import { ProjectCountryAccessListRequest } from '@tupaia/types'; -import { QueryParameters } from '../types'; +import { QueryParameters, SurveyResponseCreatedResponse } from '../types'; import { RequestBody } from './ApiConnection'; import { BaseApi } from './BaseApi'; import { PublicInterface } from './types'; @@ -63,6 +63,14 @@ export class CentralApi extends BaseApi { } } + public async createSurveyResponse( + response: MeditrakSurveyResponseRequest, + queryParameters?: QueryParameters, + ): Promise { + const data = await this.connection.post('surveyResponse', queryParameters, response); + return data?.results[0]; + } + public async resubmitSurveyResponse( originalResponseId: string, newResponse: MeditrakSurveyResponseRequest, diff --git a/packages/api-client/src/connections/EntityApi.ts b/packages/api-client/src/connections/EntityApi.ts index 64f26538b8..7abb9d1a3f 100644 --- a/packages/api-client/src/connections/EntityApi.ts +++ b/packages/api-client/src/connections/EntityApi.ts @@ -160,15 +160,28 @@ export class EntityApi extends BaseApi { field?: string; fields?: string[]; filter?: any; + pageSize?: number; }, includeRootEntity = false, isPublic = false, ) { - return this.connection.get(`hierarchy/${hierarchyName}/${entityCode}/descendants`, { - ...this.stringifyQueryParameters(queryOptions), + const { pageSize, ...otherQueryOptions } = queryOptions || {}; + const params: { + pageSize?: number; + includeRootEntity: string; + isPublic: string; + field?: string; + fields?: string; + filter?: string; + } = { + ...this.stringifyQueryParameters(otherQueryOptions), includeRootEntity: `${includeRootEntity}`, isPublic: `${isPublic}`, - }); + }; + if (pageSize) { + params.pageSize = pageSize; + } + return this.connection.get(`hierarchy/${hierarchyName}/${entityCode}/descendants`, params); } public async getDescendantsOfEntities( diff --git a/packages/api-client/src/connections/mocks/MockCentralApi.ts b/packages/api-client/src/connections/mocks/MockCentralApi.ts index b750be91f0..f22b44a4dd 100644 --- a/packages/api-client/src/connections/mocks/MockCentralApi.ts +++ b/packages/api-client/src/connections/mocks/MockCentralApi.ts @@ -8,6 +8,7 @@ import type { MeditrakSurveyResponseRequest } from '@tupaia/types'; import { ProjectCountryAccessListRequest } from '@tupaia/types'; import { CentralApiInterface } from '..'; import { RequestBody } from '../ApiConnection'; +import { SurveyResponseCreatedResponse } from '../../types'; type Data = Record[]; @@ -79,6 +80,13 @@ export class MockCentralApi implements CentralApiInterface { public createSurveyResponses(responses: MeditrakSurveyResponseRequest[]): Promise { throw new Error('Method not implemented.'); } + + public createSurveyResponse( + response: MeditrakSurveyResponseRequest, + ): Promise { + throw new Error('Method not implemented.'); + } + public resubmitSurveyResponse( originalResponseId: string, newResponse: MeditrakSurveyResponseRequest, diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index b527a18cb3..fca26d5528 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -12,3 +12,8 @@ export type QueryParameters = Record Promise; } + +export interface SurveyResponseCreatedResponse { + surveyResponseId: string; + answers: string[]; +} diff --git a/packages/central-server/package.json b/packages/central-server/package.json index 7ce5852730..143fd26c11 100644 --- a/packages/central-server/package.json +++ b/packages/central-server/package.json @@ -18,6 +18,7 @@ "build-dev": "npm run build", "lint": "yarn package:lint", "lint:fix": "yarn lint --fix", + "scheduled-task": "babel-node ./scripts/scheduledTask.js --config-file '../../babel.config.json'", "start": "node dist", "start-dev": "yarn package:start:backend-start-dev 9999", "start-verbose": "LOG_LEVEL=debug yarn start-dev", @@ -47,6 +48,7 @@ "compare-versions": "^6.1.0", "cors": "^2.8.5", "countrynames": "^0.1.1", + "date-fns": "^2.29.2", "del": "^2.2.2", "express": "^4.19.2", "form-data": "^2.3.3", @@ -62,6 +64,7 @@ "moment-timezone": "^0.5.45", "morgan": "^1.9.0", "multer": "^1.4.3", + "node-schedule": "^2.1.1", "public-ip": "^2.5.0", "react-autobind": "^1.0.6", "react-native-uuid": "^1.4.9", @@ -72,6 +75,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@babel/node": "^7.10.5", "chai": "^4.1.2", "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", diff --git a/packages/central-server/scripts/scheduledTask.js b/packages/central-server/scripts/scheduledTask.js new file mode 100644 index 0000000000..40c5aba330 --- /dev/null +++ b/packages/central-server/scripts/scheduledTask.js @@ -0,0 +1,49 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import '@babel/polyfill'; +import { configureEnv } from '../src/configureEnv'; +import { ModelRegistry, TupaiaDatabase } from '@tupaia/database'; +import winston from '../src/log'; +import { RepeatingTaskDueDateHandler, TaskOverdueChecker } from '../src/scheduledTasks'; +import * as modelClasses from '../src/database/models'; + +const SCHEDULED_TASK_MODULES = { + TaskOverdueChecker, + RepeatingTaskDueDateHandler, +}; + +configureEnv(); + +const getTaskArg = argv => { + const taskAgr = argv[4]; + if (!taskAgr || !Object.keys(SCHEDULED_TASK_MODULES).find(t => t === taskAgr)) { + const availableOptions = Object.keys(SCHEDULED_TASK_MODULES).join(', '); + throw new Error(`You need to specify one of the following tasks to run: ${availableOptions}`); + } + + return argv[4]; +}; + +(async () => { + const database = new TupaiaDatabase(); + try { + winston.info('Starting scheduled task script'); + const start = Date.now(); + const taskArg = getTaskArg(process.argv); + const taskKey = Object.keys(SCHEDULED_TASK_MODULES).find(t => t === taskArg); + const taskModule = taskKey && SCHEDULED_TASK_MODULES[taskKey]; + winston.info(`Running ${taskArg} module`); + const models = new ModelRegistry(database, modelClasses, true); + const taskInstance = new taskModule(models); + await taskInstance.run(); + const end = Date.now(); + winston.info(`Completed in ${end - start}ms`); + } catch (error) { + winston.error(error.message); + winston.error(error.stack); + } finally { + await database.closeConnections(); + } +})(); diff --git a/packages/central-server/src/apiV2/GETHandler/GETHandler.js b/packages/central-server/src/apiV2/GETHandler/GETHandler.js index 92a006f6da..0066e332a8 100644 --- a/packages/central-server/src/apiV2/GETHandler/GETHandler.js +++ b/packages/central-server/src/apiV2/GETHandler/GETHandler.js @@ -58,7 +58,7 @@ export class GETHandler extends CRUDHandler { } async getDbQueryOptions() { - const { sort: sortString, distinct = false } = this.req.query; + const { sort: sortString, rawSort, distinct = false } = this.req.query; // set up db query options const columns = await this.getProcessedColumns(); @@ -73,7 +73,7 @@ export class GETHandler extends CRUDHandler { const { limit, page } = this.getPaginationParameters(); const offset = limit * page; - const dbQueryOptions = { multiJoin, columns, sort, distinct, limit, offset }; + const dbQueryOptions = { multiJoin, columns, sort, rawSort, distinct, limit, offset }; // add any user requested sorting to the start of the sort clause if (sortString) { diff --git a/packages/central-server/src/apiV2/GETHandler/helpers.js b/packages/central-server/src/apiV2/GETHandler/helpers.js index cd8b38b9ac..ae6c923cdd 100644 --- a/packages/central-server/src/apiV2/GETHandler/helpers.js +++ b/packages/central-server/src/apiV2/GETHandler/helpers.js @@ -144,6 +144,7 @@ export const getQueryOptionsForColumns = ( customJoinConditions, joinType, ); + joinConditions.forEach(j => { if (!recordTypesInQuery.has(j.joinWith)) multiJoin.push(j); recordTypesInQuery.add(j.joinWith); diff --git a/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js b/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js index 5b3d1420e8..20e268662a 100644 --- a/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js +++ b/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js @@ -121,6 +121,11 @@ export const createAnswerViaSurveyResponseDBFilter = async ( [`${RECORDS.SURVEY_SCREEN}.id`, `${RECORDS.SURVEY_SCREEN_COMPONENT}.screen_id`], ], }, + { + joinWith: RECORDS.USER_ACCOUNT, + joinCondition: [`${RECORDS.USER_ACCOUNT}.id`, `${RECORDS.ANSWER}.text`], + joinType: JOIN_TYPES.LEFT, + }, ], dbOptions.multiJoin, ); diff --git a/packages/central-server/src/apiV2/deleteAccount.js b/packages/central-server/src/apiV2/deleteAccount.js index 6b6b14cb1f..beec13a516 100644 --- a/packages/central-server/src/apiV2/deleteAccount.js +++ b/packages/central-server/src/apiV2/deleteAccount.js @@ -4,23 +4,24 @@ */ import { requireEnv, respond } from '@tupaia/utils'; import { sendEmail } from '@tupaia/server-utils'; -import { getUserInfoInString } from './utilities'; -const sendRequest = userInfo => { +const sendRequest = user => { const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS'); - const emailText = `${userInfo} has requested to delete their account`; return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, { subject: 'Tupaia Account Deletion Request', - text: emailText, + templateName: 'deleteAccount', + templateContext: { + user, + }, }); }; export const deleteAccount = async (req, res) => { const { userId: requestUserId, params, models } = req; const userId = requestUserId || params.userId; - const userInfo = await getUserInfoInString(userId, models); - await sendRequest(userInfo); + const user = await models.user.findById(userId); + await sendRequest(user); respond(res, { message: 'Account deletion requested.' }, 200); }; diff --git a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js index c5c1222825..1bad77f428 100644 --- a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js +++ b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js @@ -197,6 +197,14 @@ export async function exportResponsesToFile( } return entity.code; } + + if (answer.type === ANSWER_TYPES.USER) { + const user = await models.user.findById(answer.text); + if (!user) { + return `Could not find user with id ${answer.text}`; + } + return `${user.first_name} ${user.last_name} (${user.id})`; + } return answer.text; }; diff --git a/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/QuestionConfigCellBuilder.js b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/QuestionConfigCellBuilder.js index 39ad664b5d..8bc67150f5 100644 --- a/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/QuestionConfigCellBuilder.js +++ b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/QuestionConfigCellBuilder.js @@ -9,15 +9,11 @@ import { AutocompleteConfigCellBuilder } from './AutocompleteConfigCellBuilder'; import { CodeGeneratorConfigCellBuilder } from './CodeGeneratorConfigCellBuilder'; import { ConditionConfigCellBuilder } from './ConditionConfigCellBuilder'; import { EntityConfigCellBuilder } from './EntityConfigCellBuilder'; +import { TaskConfigCellBuilder } from './TaskConfigCellBuilder'; +import { UserConfigCellBuilder } from './UserConfigCellBuilder'; -const { - CODE_GENERATOR, - ARITHMETIC, - CONDITION, - AUTOCOMPLETE, - ENTITY, - PRIMARY_ENTITY, -} = ANSWER_TYPES; +const { CODE_GENERATOR, ARITHMETIC, CONDITION, AUTOCOMPLETE, ENTITY, PRIMARY_ENTITY, TASK, USER } = + ANSWER_TYPES; export class QuestionConfigCellBuilder { constructor(models) { @@ -30,6 +26,8 @@ export class QuestionConfigCellBuilder { [ARITHMETIC]: new ArithmeticConfigCellBuilder(models), [ENTITY]: new EntityConfigCellBuilder(models), [PRIMARY_ENTITY]: new EntityConfigCellBuilder(models), + [TASK]: new TaskConfigCellBuilder(models), + [USER]: new UserConfigCellBuilder(models), }; } diff --git a/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/TaskConfigCellBuilder.js b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/TaskConfigCellBuilder.js new file mode 100644 index 0000000000..46a24360e9 --- /dev/null +++ b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/TaskConfigCellBuilder.js @@ -0,0 +1,99 @@ +/** + * Tupaia MediTrak + * Copyright (c) 2019 Beyond Essential Systems Pty Ltd + */ + +import { KeyValueCellBuilder } from '../KeyValueCellBuilder'; + +const fetchQuestionCode = async (questionId, models) => { + const question = await models.question.findById(questionId); + if (!question) { + throw new Error(`Could not find a question with id matching ${questionId}`); + } + return question.code; +}; + +const FIELD_TRANSLATION = { + 'shouldCreateTask.questionId': 'shouldCreateTask', + 'entityId.questionId': 'entityId', + 'dueDate.questionId': 'dueDate', + 'assignee.questionId': 'assignee', +}; + +const VALUE_TRANSLATION = { + shouldCreateTask: fetchQuestionCode, + entityId: fetchQuestionCode, + dueDate: fetchQuestionCode, + assignee: fetchQuestionCode, +}; + +/** + * { + * cat { + * name: Buddy + * age: 2 + * } + * } + * => + * { + * cat.name: Buddy + * cat.age: 2 + * } + */ + +const flattenObject = (value, field, flattenedObject = {}) => { + if (!(typeof value === 'object' && !Array.isArray(value) && value !== null)) { + // eslint-disable-next-line no-param-reassign + flattenedObject[field] = value; + return; + } + + Object.entries(value).forEach(([subField, subValue]) => { + flattenObject(subValue, `${field}.${subField}`, flattenedObject); + }); +}; + +export class TaskConfigCellBuilder extends KeyValueCellBuilder { + async processValue(value, field) { + return VALUE_TRANSLATION[field] ? VALUE_TRANSLATION[field](value, this.models) : value; + } + + extractRelevantObject({ task }) { + return task; + } + + async processField(rawValue, rawField) { + const flattenedFields = {}; + flattenObject(rawValue, rawField, flattenedFields); + + const processedFieldsAndValues = await Promise.all( + Object.entries(flattenedFields).map(async ([field, value]) => { + const translatedField = FIELD_TRANSLATION[field] || field; + const translatedValue = await this.processValue(value, translatedField); + return `${translatedField}: ${translatedValue}`; + }), + ); + return processedFieldsAndValues; + } + + async build(jsonStringOrObject) { + if (!jsonStringOrObject) { + return ''; + } + + const fullObject = + typeof jsonStringOrObject === 'string' ? JSON.parse(jsonStringOrObject) : jsonStringOrObject; + const object = this.extractRelevantObject(fullObject) || {}; + + const processedFields = []; + + await Promise.all( + Object.entries(object).map(async ([field, value]) => { + const processedFieldsAndValues = await this.processField(value, field); + + processedFields.push(...processedFieldsAndValues); + }), + ); + return processedFields.join('\r\n'); + } +} diff --git a/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/UserConfigCellBuilder.js b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/UserConfigCellBuilder.js new file mode 100644 index 0000000000..56b876d2e0 --- /dev/null +++ b/packages/central-server/src/apiV2/export/exportSurveys/cellBuilders/questionConfigCellBuilders/UserConfigCellBuilder.js @@ -0,0 +1,36 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { KeyValueCellBuilder } from '../KeyValueCellBuilder'; + +export class UserConfigCellBuilder extends KeyValueCellBuilder { + extractRelevantObject({ user }) { + return user; + } + + async fetchPermissionGroupName(permissionGroupId) { + const permissionGroup = await this.models.permissionGroup.findById(permissionGroupId); + if (!permissionGroup) return ''; + return permissionGroup.name; + } + + async build(jsonStringOrObject) { + if (!jsonStringOrObject) { + return ''; + } + + const fullObject = + typeof jsonStringOrObject === 'string' ? JSON.parse(jsonStringOrObject) : jsonStringOrObject; + const userObject = this.extractRelevantObject(fullObject); + if (!userObject) return ''; + + const { permissionGroup } = userObject; + if (!permissionGroup) return ''; + + const permissionGroupName = await this.fetchPermissionGroupName(permissionGroup); + + return `permissionGroup: ${permissionGroupName}`; + } +} diff --git a/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js b/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js index 1ce4d17d19..65f5dd796f 100644 --- a/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js +++ b/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js @@ -13,24 +13,37 @@ Any responses not listed here have been successfully imported, and can be remove return message; }; -const constructMessage = responseBody => { +const constructTemplateContextMessage = responseBody => { const { error, failures = [] } = responseBody; // global error, whole import has failed if (error) { - return `Unfortunately, your survey response import failed. + return { + message: `Unfortunately, your survey response import failed. -${error}`; +${error}`, + + title: 'Import Failed', + }; } // at least one response failed, but import finished processing if (failures.length > 0) { - return constructFailuresMessage(failures); + return { + message: constructFailuresMessage(failures), + title: 'Import Finished with Failures', + }; } - return 'Your survey responses have been successfully imported.'; + return { + message: 'Your survey responses have been successfully imported.', + title: 'Import Successful', + }; }; export const constructImportEmail = responseBody => { - return { subject: 'Tupaia Survey Response Import', message: constructMessage(responseBody) }; + return { + subject: 'Tupaia Survey Response Import', + templateContext: constructTemplateContextMessage(responseBody), + }; }; diff --git a/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js b/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js index f68ef08363..df41a9ac0d 100644 --- a/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js +++ b/packages/central-server/src/apiV2/import/importSurveyResponses/importSurveyResponses.js @@ -46,6 +46,25 @@ const ANSWER_TRANSFORMERS = { } return entity.id; }, + [ANSWER_TYPES.USER]: async (models, answerValue) => { + if (!answerValue) { + return answerValue; + } + + const userIdRegex = new RegExp(/(?<=\().+?(?=\))/); + const userId = answerValue.match(userIdRegex)?.[0]; + + if (!userId) { + throw new Error(`Could not find user id in ${answerValue}`); + } + const user = await models.user.findById(userId); + + if (!user) { + throw new Error(`Could not find user with id ${userId}`); + } + + return user.id; + }, }; const IMPORT_MODES = { diff --git a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js index 44fb3a209e..ed08da68f5 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js +++ b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js @@ -11,8 +11,10 @@ import { processArithmeticConfig } from './processArithmeticConfig'; import { processConditionConfig } from './processConditionConfig'; import { processAutocompleteConfig } from './processAutocompleteConfig'; import { processEntityConfig } from './processEntityConfig'; +import { processTaskConfig } from './processTaskConfig'; +import { processUserConfig } from './processUserConfig'; -const { CODE_GENERATOR, ARITHMETIC, CONDITION, AUTOCOMPLETE, ENTITY, PRIMARY_ENTITY } = +const { CODE_GENERATOR, ARITHMETIC, CONDITION, AUTOCOMPLETE, ENTITY, PRIMARY_ENTITY, TASK, USER } = ANSWER_TYPES; export class ConfigImporter { @@ -85,6 +87,15 @@ export class ConfigImporter { const entityConfig = await processEntityConfig(this.models, config); return { entity: entityConfig }; } + case TASK: { + const taskConfig = await processTaskConfig(this.models, config); + return { task: taskConfig }; + } + case USER: { + const userConfig = await processUserConfig(this.models, config); + return { user: userConfig }; + } + default: return {}; } diff --git a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processTaskConfig.js b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processTaskConfig.js new file mode 100644 index 0000000000..a76e8978d8 --- /dev/null +++ b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processTaskConfig.js @@ -0,0 +1,26 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { translateQuestionCodeToId } from '../../../utilities'; + +const QUESTION_FIELDS = ['shouldCreateTask', 'entityId', 'dueDate', 'assignee']; + +const translateValues = async (config, models) => { + const translatedValuesWithFields = await Promise.all( + Object.entries(config).map(async ([field, value]) => { + if (QUESTION_FIELDS.includes(field)) { + const translatedValue = await translateQuestionCodeToId(models.question, value); + return [field, translatedValue]; + } + return [field, value]; + }), + ); + return Object.fromEntries(translatedValuesWithFields); +}; + +export const processTaskConfig = async (models, config) => { + const translatedConfig = await translateValues(config, models); + return translatedConfig; +}; diff --git a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processUserConfig.js b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processUserConfig.js new file mode 100644 index 0000000000..99277bda07 --- /dev/null +++ b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/processUserConfig.js @@ -0,0 +1,22 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +const translatePermissionGroup = async (config, models) => { + const { permissionGroup } = config; + const actualPermissionGroup = await models.permissionGroup.findOne({ name: permissionGroup }); + if (!actualPermissionGroup) { + throw new Error(`Permission group ${permissionGroup} not found`); + } + + return { + ...config, + permissionGroup: actualPermissionGroup.id, + }; +}; + +export const processUserConfig = async (models, config) => { + const translatedConfig = await translatePermissionGroup(config, models); + return translatedConfig; +}; diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/BaseValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/BaseValidator.js index 017377ccbd..fb14aaca59 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/BaseValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/BaseValidator.js @@ -1,4 +1,5 @@ import { ValidationError } from '@tupaia/utils'; +import { convertCellToJson } from '../utilities'; export class BaseValidator { constructor(questions) { diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js index 4f18cfcb8d..2b54431a9f 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js @@ -10,8 +10,10 @@ import { CodeGeneratorConfigValidator } from './CodeGeneratorConfigValidator'; import { EntityConfigValidator } from './EntityConfigValidator'; import { ArithmeticConfigValidator } from './ArithmeticConfigValidator'; import { ConditionConfigValidator } from './ConditionConfigValidator'; +import { UserConfigValidator } from './UserConfigValidator'; +import { TaskConfigValidator } from './TaskConfigValidator'; -const { CODE_GENERATOR, ENTITY, PRIMARY_ENTITY, ARITHMETIC, CONDITION } = ANSWER_TYPES; +const { CODE_GENERATOR, ENTITY, PRIMARY_ENTITY, ARITHMETIC, CONDITION, USER, TASK } = ANSWER_TYPES; export class ConfigValidator extends BaseValidator { constructor(...constructorArgs) { @@ -37,6 +39,10 @@ export class ConfigValidator extends BaseValidator { return ArithmeticConfigValidator; case CONDITION: return ConditionConfigValidator; + case USER: + return UserConfigValidator; + case TASK: + return TaskConfigValidator; default: return IsEmptyValidator; } diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/TaskConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/TaskConfigValidator.js new file mode 100644 index 0000000000..02a1c84948 --- /dev/null +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/TaskConfigValidator.js @@ -0,0 +1,93 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { constructIsNotPresentOr, ValidationError } from '@tupaia/utils'; +import { JsonFieldValidator } from '../JsonFieldValidator'; +import { convertCellToJson } from '../../utilities'; + +export class TaskConfigValidator extends JsonFieldValidator { + constructor(questions, models) { + super(questions); + this.models = models; + } + + static fieldName = 'config'; + + getFieldValidators(rowIndex) { + const referencesExistingSurvey = this.constructReferencesExistingSurvey(); + + const pointsToAnotherQuestion = this.constructReferencesPreceedingQuestion(rowIndex, ['User']); + + return { + shouldCreateTask: [this.constructReferencesPreceedingMandatoryQuestion(rowIndex, ['Binary'])], + entityId: [ + this.constructReferencesPreceedingMandatoryQuestion(rowIndex, ['Entity', 'PrimaryEntity']), + ], + surveyCode: [referencesExistingSurvey], + dueDate: [ + this.constructReferencesPreceedingMandatoryQuestion(rowIndex, ['Date', 'DateTime']), + ], + assignee: [constructIsNotPresentOr(pointsToAnotherQuestion)], + }; + } + + constructReferencesExistingSurvey = () => { + return async value => { + if (!value) { + throw new ValidationError('Survey code is required'); + } + const isValidRecord = await this.models.survey.findOne({ code: value }); + + if (!isValidRecord) { + throw new ValidationError('Referenced survey does not exist'); + } + return true; + }; + }; + + constructReferencesPreceedingQuestion = (rowIndex, acceptedQuestionTypes) => { + return value => { + const question = this.findOtherQuestion(value, rowIndex, rowIndex); + if (!question) { + throw new ValidationError('Referenced question does not exist'); + } + + if (!acceptedQuestionTypes.includes(question.type)) { + throw new ValidationError( + `Referenced question should be of type ${acceptedQuestionTypes.join(' or ')}`, + ); + } + return true; + }; + }; + + constructReferencesPreceedingMandatoryQuestion = (rowIndex, acceptedQuestionTypes) => { + return value => { + const question = this.findOtherQuestion(value, rowIndex, rowIndex); + if (!question) { + throw new ValidationError('Referenced question does not exist'); + } + + if (!acceptedQuestionTypes.includes(question.type)) { + throw new ValidationError( + `Referenced question should be of type ${acceptedQuestionTypes.join(' or ')}`, + ); + } + + if (!question.validationCriteria) { + throw new ValidationError('Referenced question should be mandatory'); + } + + const { validationCriteria } = question; + + const parsedValidationCriteria = convertCellToJson(validationCriteria); + + if (!parsedValidationCriteria.mandatory || parsedValidationCriteria.mandatory !== 'true') { + throw new ValidationError('Referenced question should be mandatory'); + } + return true; + }; + }; +} diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/UserConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/UserConfigValidator.js new file mode 100644 index 0000000000..94f454817b --- /dev/null +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/UserConfigValidator.js @@ -0,0 +1,96 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { hasContent, ValidationError } from '@tupaia/utils'; +import { JsonFieldValidator } from '../JsonFieldValidator'; +import { convertCellToJson } from '../../utilities'; + +export class UserConfigValidator extends JsonFieldValidator { + constructor(questions, models) { + super(questions); + this.models = models; + } + + static fieldName = 'config'; + + getFieldValidators(rowIndex) { + const isPermissionGroup = this.constructIsPermissionGroup(rowIndex); + const permissionGroupIsValidForTaskQuestion = + this.permissionGroupIsValidForTaskQuestion(rowIndex); + + return { + permissionGroup: [hasContent, isPermissionGroup, permissionGroupIsValidForTaskQuestion], + }; + } + + /** + * Checks if the permission group specified has access to the survey specified in the task question, if it exists + */ + permissionGroupIsValidForTaskQuestion = rowIndex => { + return async value => { + const currentQuestion = this.getQuestion(rowIndex); + const taskQuestion = this.questions.find(({ type, config }) => { + if (type !== 'Task') return false; + const parsedConfig = convertCellToJson(config); + return parsedConfig.assignee === currentQuestion.code; + }); + + // if there is no task question, return true because this validation is not relevant + if (!taskQuestion) return true; + + const { config } = taskQuestion; + if (!config) { + throw new ValidationError( + "Can't validate permissionGroup: Task question should have config with survey code", + ); + } + const parsedConfig = convertCellToJson(config); + const { surveyCode } = parsedConfig; + const survey = await this.models.survey.findOne({ code: surveyCode }); + + if (!survey) { + throw new ValidationError( + "Can't validate permissionGroup: Referenced survey in task question config does not exist", + ); + } + + // BES Admin has access to all surveys + if (value === 'BES Admin') return true; + + // Check if the permission group has access to the survey + const { permission_group_id: permissionGroupId } = survey; + + const surveyPermissionGroup = await this.models.permissionGroup.findOne({ + id: permissionGroupId, + }); + + // Get the ancestors of the permission group + const surveyPermissionGroupAncestors = await surveyPermissionGroup.getAncestors(); + + const allowedPermissionGroups = [ + surveyPermissionGroup.name, + ...surveyPermissionGroupAncestors.map(({ name }) => name), + ]; + + // Check if the permission group is in the allowed permission groups, if not throw an error + if (!allowedPermissionGroups.some(name => name === value)) { + throw new ValidationError( + 'Permission group does not have access to the referenced survey in the task question', + ); + } + }; + }; + + constructIsPermissionGroup = () => { + return async value => { + const permissionGroup = await this.models.permissionGroup.findOne({ name: value }); + if (!permissionGroup) { + throw new ValidationError('Referenced permission group does not exist'); + } + + return true; + }; + }; +} diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js index aed4d19165..c465bce5bf 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js @@ -14,6 +14,7 @@ export class JsonFieldValidator extends BaseValidator { const fieldValidators = this.getFieldValidators(rowIndex); const otherFieldValidators = this.getOtherFieldValidators(); + await new ObjectValidator(fieldValidators, otherFieldValidators).validate( config, constructError, diff --git a/packages/central-server/src/apiV2/import/importSurveys/constructQuestionValidators.js b/packages/central-server/src/apiV2/import/importSurveys/constructQuestionValidators.js index eaf5a845b5..ce166ae787 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/constructQuestionValidators.js +++ b/packages/central-server/src/apiV2/import/importSurveys/constructQuestionValidators.js @@ -156,6 +156,9 @@ export const constructQuestionValidators = models => ({ } } else { const question = await models.question.findOne({ code: questionCode }); + if (!question) { + throw new Error(`Question with code ${questionCode} does not exist`); + } switch (question.type) { case 'Radio': if ( diff --git a/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js b/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js index 2226d31ef3..0d494e83d4 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js +++ b/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js @@ -246,14 +246,28 @@ export async function importSurveysQuestions({ models, file, survey, dataGroup, } else if (questionCode === 'hidden') { processedVisibilityCriteria.hidden = answers[0] === 'true'; } else { - const { id: questionId } = await models.question.findOne({ + const relatedQuestion = await models.question.findOne({ code: questionCode, }); + if (!relatedQuestion) { + throw new ImportValidationError( + `Question with code ${questionCode} does not exist`, + excelRowNumber, + 'visibilityCriteria', + tabName, + ); + } + const { id: questionId } = relatedQuestion; processedVisibilityCriteria[questionId] = answers; } }), ); + // If the question is a task, set it to hidden always + if (type === ANSWER_TYPES.TASK && !processedVisibilityCriteria.hidden) { + processedVisibilityCriteria.hidden = true; + } + currentSurveyScreenComponent = await models.surveyScreenComponent.create({ screen_id: currentScreen.id, question_id: question.id, diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 1d605fed9c..236efbe2ac 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -146,6 +146,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) => { @@ -270,6 +271,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 */ @@ -317,6 +319,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/meditrakApp/getChanges.js b/packages/central-server/src/apiV2/meditrakApp/getChanges.js index ae4ce5191e..3929bfab57 100644 --- a/packages/central-server/src/apiV2/meditrakApp/getChanges.js +++ b/packages/central-server/src/apiV2/meditrakApp/getChanges.js @@ -7,7 +7,6 @@ import keyBy from 'lodash.keyby'; import groupBy from 'lodash.groupby'; import { respond, DatabaseError } from '@tupaia/utils'; import { RECORDS } from '@tupaia/database'; -import { camel } from 'case'; import { getColumnsForMeditrakApp } from './utilities'; import { supportsPermissionsBasedSync, @@ -33,10 +32,11 @@ function getRecordForSync(models, record, recordType, appVersion) { } }); + const model = models.getModelForDatabaseRecord(recordType); + // Translate values in columns based on meditrak app version - const selectedModel = models[camel(recordType)]; - const translatedRecord = selectedModel?.meditrakConfig.translateRecordForSync - ? selectedModel.meditrakConfig.translateRecordForSync(recordWithoutNulls, appVersion) + const translatedRecord = model?.meditrakConfig.translateRecordForSync + ? model.meditrakConfig.translateRecordForSync(recordWithoutNulls, appVersion) : recordWithoutNulls; return translatedRecord; } diff --git a/packages/central-server/src/apiV2/meditrakApp/meditrakSync/permissionsBasedMeditrakSyncQuery.js b/packages/central-server/src/apiV2/meditrakApp/meditrakSync/permissionsBasedMeditrakSyncQuery.js index 0d9da582b2..0bfa148c8f 100644 --- a/packages/central-server/src/apiV2/meditrakApp/meditrakSync/permissionsBasedMeditrakSyncQuery.js +++ b/packages/central-server/src/apiV2/meditrakApp/meditrakSync/permissionsBasedMeditrakSyncQuery.js @@ -17,7 +17,7 @@ import { supportsPermissionsBasedSync, } from './supportsPermissionsBasedSync'; -const recordTypesToAlwaysSync = ['country', 'permission_group']; +const recordTypesToAlwaysSync = ['country', 'permission_group', 'user_account']; const entityTypesToAlwaysSync = ['world', 'country']; // TODO: Tidy this up as part of RN-502 diff --git a/packages/central-server/src/apiV2/requestCountryAccess.js b/packages/central-server/src/apiV2/requestCountryAccess.js index d3bf7c158e..6c13db3472 100644 --- a/packages/central-server/src/apiV2/requestCountryAccess.js +++ b/packages/central-server/src/apiV2/requestCountryAccess.js @@ -6,7 +6,6 @@ import { requireEnv, respond, UnauthenticatedError, ValidationError } from '@tupaia/utils'; import { sendEmail } from '@tupaia/server-utils'; import { getTokenClaimsFromBearerAuth } from '@tupaia/auth'; -import { getUserInfoInString } from './utilities'; const checkUserPermission = (req, userId) => { const authHeader = req.headers.authorization; @@ -17,26 +16,28 @@ const checkUserPermission = (req, userId) => { } }; -const sendRequest = (userInfo, countryNames, message, project) => { +const sendRequest = async (userId, models, countries, message, project) => { + const user = await models.user.findById(userId); + const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS'); - const emailText = ` -${userInfo} has requested access to countries: -${countryNames.map(n => ` - ${n}`).join('\n')} -${ - project - ? ` -For the project ${project.code} (linked to permission groups: ${project.permission_groups.join( - ', ', - )}) - ` - : '' -} -With the message: '${message}' -`; return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, { subject: 'Tupaia Country Access Request', - text: emailText, + templateName: 'requestCountryAccess', + templateContext: { + title: 'You have a new country request!', + cta: { + url: `${process.env.ADMIN_PANEL_FRONT_END_URL}/users/access-requests/${userId}`, + text: 'Approve or deny request', + }, + countries, + message, + project: { + code: project.code, + permissionGroups: project.permission_groups.join(', '), + }, + user, + }, }); }; @@ -79,13 +80,12 @@ export const requestCountryAccess = async (req, res) => { } catch (error) { throw new UnauthenticatedError(error.message); } - const userInfo = await getUserInfoInString(userId, models); const project = projectCode && (await models.project.findOne({ code: projectCode })); await createAccessRequests(models, userId, entities, message, project); const countryNames = entities.map(e => e.name); - await sendRequest(userInfo, countryNames, message, project); + await sendRequest(userId, models, countryNames, message, project); respond(res, { message: 'Country access requested' }, 200); }; diff --git a/packages/central-server/src/apiV2/requestPasswordReset.js b/packages/central-server/src/apiV2/requestPasswordReset.js index 2366ef1399..66a0bda621 100644 --- a/packages/central-server/src/apiV2/requestPasswordReset.js +++ b/packages/central-server/src/apiV2/requestPasswordReset.js @@ -2,7 +2,7 @@ * Tupaia MediTrak * Copyright (c) 2017 Beyond Essential Systems Pty Ltd */ -import { respond, DatabaseError, FormValidationError } from '@tupaia/utils'; +import { respond, DatabaseError, FormValidationError, requireEnv } from '@tupaia/utils'; import { sendEmail } from '@tupaia/server-utils'; import { allowNoPermissions } from '../permissions'; @@ -28,22 +28,24 @@ export const requestPasswordReset = async (req, res) => { user_id: user.id, }); + const TUPAIA_FRONT_END_URL = requireEnv('TUPAIA_FRONT_END_URL'); // allow overriding the default url for the front end, so that this route can be used from Tupaia and also datatrak const passwordResetUrl = `${ - resetPasswordUrl || process.env.TUPAIA_FRONT_END_URL + resetPasswordUrl || TUPAIA_FRONT_END_URL }/reset-password?passwordResetToken={token}`; const resetUrl = passwordResetUrl.replace('{token}', token); - const emailText = `Dear ${user.fullName}, -You are receiving this email because someone requested a password reset for -this user account on Tupaia.org. To reset your password follow the link below. - -${resetUrl} - -If you believe this email was sent to you in error, please contact us immediately at -admin@tupaia.org.`; - - sendEmail(user.email, { subject: 'Password reset on Tupaia.org', text: emailText }); + sendEmail(user.email, { + subject: 'Password reset on Tupaia.org', + templateName: 'passwordReset', + templateContext: { + userName: user.first_name, + cta: { + text: 'Reset your password', + url: resetUrl, + }, + }, + }); respond(res, { success: true, diff --git a/packages/central-server/src/apiV2/surveys/assertSurveyPermissions.js b/packages/central-server/src/apiV2/surveys/assertSurveyPermissions.js index 54c3682807..e0fa3c54ff 100644 --- a/packages/central-server/src/apiV2/surveys/assertSurveyPermissions.js +++ b/packages/central-server/src/apiV2/surveys/assertSurveyPermissions.js @@ -94,25 +94,28 @@ export const createSurveyViaCountryDBFilter = async (accessPolicy, models, crite parameters: countryId, }; } else { - dbConditions[RAW] = { - sql: ` + const permissionGroupsForCountry = Object.keys(countryIdsByPermissionGroupId).filter( + permissionGroupId => countryIdsByPermissionGroupId[permissionGroupId].includes(countryId), + ); + + if (permissionGroupsForCountry.length === 0) { + dbConditions.id = { + comparator: '=', + comparisonValue: null, + }; // Return no results because we don't have access to any permission groups for this country + } else + dbConditions[RAW] = { + sql: ` ( ( ARRAY[?] <@ survey.country_ids ) - AND - ( - survey.country_ids - && - ARRAY( - SELECT TRIM('"' FROM JSON_ARRAY_ELEMENTS(?::JSON->survey.permission_group_id)::TEXT) - ) - ) + AND survey.permission_group_id IN (${permissionGroupsForCountry.map(() => '?').join(',')}) )`, - parameters: [countryId, JSON.stringify(countryIdsByPermissionGroupId)], - }; + parameters: [countryId, ...permissionGroupsForCountry], + }; } return dbConditions; }; 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..447020826c --- /dev/null +++ b/packages/central-server/src/apiV2/taskComments/CreateTaskComment.js @@ -0,0 +1,36 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +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 { + async assertUserHasAccess() { + const createPermissionChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, this.parentRecordId); + + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), + ); + } + + async createRecord() { + return this.models.wrapInTransaction(async transactingModels => { + const task = await transactingModels.task.findById(this.parentRecordId); + const { message, type, templateVariables } = this.newRecordData; + const newComment = await task.addComment({ + message, + userId: this.req.user.id, + type, + templateVariables, + }); + return { id: newComment.id }; + }); + } +} 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..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/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js index d1eda45751..bd4ac972ba 100644 --- a/packages/central-server/src/apiV2/tasks/CreateTask.js +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -21,6 +21,17 @@ export class CreateTask extends CreateHandler { } async createRecord() { - await this.insertRecord(); + const { comment, ...newRecordData } = this.newRecordData; + await this.models.wrapInTransaction(async transactingModels => { + const task = await transactingModels.task.create(newRecordData, this.req.user.id); + // Add the user comment first, since the transaction will mean that all comments have the same created_at time, but we want the user comment to be the 'most recent' + if (comment) { + await task.addUserComment(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..dc6c99cce5 100644 --- a/packages/central-server/src/apiV2/tasks/EditTask.js +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -15,6 +15,6 @@ export class EditTask extends EditHandler { } async editRecord() { - await this.updateRecord(); + return this.models.task.updateById(this.recordId, this.updatedFields, this.req.user.id); } } diff --git a/packages/central-server/src/apiV2/tasks/GETTasks.js b/packages/central-server/src/apiV2/tasks/GETTasks.js index 846a2a7c01..65fa0fd7a4 100644 --- a/packages/central-server/src/apiV2/tasks/GETTasks.js +++ b/packages/central-server/src/apiV2/tasks/GETTasks.js @@ -5,6 +5,7 @@ import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; import { GETHandler } from '../GETHandler'; +import { mergeMultiJoin } from '../utilities'; import { assertUserHasTaskPermissions, createTaskDBFilter } from './assertTaskPermissions'; export class GETTasks extends GETHandler { @@ -25,16 +26,23 @@ export class GETTasks extends GETHandler { } async getDbQueryOptions() { - const { multiJoin, sort, ...restOfOptions } = await super.getDbQueryOptions(); + const { multiJoin, sort, rawSort, ...restOfOptions } = await super.getDbQueryOptions(); - return { + const options = { ...restOfOptions, - // Strip table prefix from `task_status` and `assignee_name` as these are customColumns - sort: sort.map(s => - s.replace('task.task_status', 'task_status').replace('task.assignee_name', 'assignee_name'), - ), // Appending the multi-join from the Record class so that we can fetch the `task_status` and `assignee_name` - multiJoin: multiJoin.concat(this.models.task.DatabaseRecordClass.joins), + multiJoin: mergeMultiJoin(multiJoin, this.models.task.DatabaseRecordClass.joins), }; + + if (rawSort) { + options.rawSort = rawSort; + } else { + // Strip table prefix from `task_status` and `assignee_name` as these are customColumns + options.sort = sort?.map(s => + s.replace('task.task_status', 'task_status').replace('task.assignee_name', 'assignee_name'), + ); + } + + return options; } } diff --git a/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js index b0cfb93a19..6068d8846b 100644 --- a/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js +++ b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js @@ -3,9 +3,9 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { RECORDS } from '@tupaia/database'; +import { QUERY_CONJUNCTIONS } from '@tupaia/database'; import { hasBESAdminAccess } from '../../permissions'; -import { fetchCountryCodesByPermissionGroupId, mergeFilter, mergeMultiJoin } from '../utilities'; +import { fetchCountryCodesByPermissionGroupId } from '../utilities'; const getUserSurveys = async (models, accessPolicy, projectId) => { const query = {}; @@ -13,7 +13,7 @@ const getUserSurveys = async (models, accessPolicy, projectId) => { query.project_id = projectId; } const userSurveys = await models.survey.findByAccessPolicy(accessPolicy, query, { - columns: ['id'], + columns: ['id', 'permission_group_id', 'country_ids'], }); return userSurveys; }; @@ -22,41 +22,13 @@ export const createTaskDBFilter = async (accessPolicy, models, criteria, options if (hasBESAdminAccess(accessPolicy)) { return { dbConditions: criteria, dbOptions: options }; } - const { projectId, ...dbConditions } = { ...criteria }; + const dbConditions = { ...criteria }; const dbOptions = { ...options }; - const countryCodesByPermissionGroupId = await fetchCountryCodesByPermissionGroupId( - accessPolicy, - models, - ); - - const surveys = await getUserSurveys(models, accessPolicy, projectId); - - dbConditions['entity.country_code'] = mergeFilter( - { - comparator: 'IN', - comparisonValue: Object.values(countryCodesByPermissionGroupId).flat(), - }, - dbConditions['entity.country_code'], - ); - - dbConditions['task.survey_id'] = mergeFilter( - { - comparator: 'IN', - comparisonValue: surveys.map(survey => survey.id), - }, - dbConditions['task.survey_id'], - ); - - dbOptions.multiJoin = mergeMultiJoin( - [ - { - joinWith: RECORDS.ENTITY, - joinCondition: [`${RECORDS.ENTITY}.id`, `${RECORDS.TASK}.entity_id`], - }, - ], - dbOptions.multiJoin, - ); + const taskPermissionsQuery = await models.task.createAccessPolicyQueryClause(accessPolicy); + + dbConditions[QUERY_CONJUNCTIONS.RAW] = taskPermissionsQuery; + return { dbConditions, dbOptions }; }; diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 567a1a7f90..5e696b6bfc 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`, @@ -445,32 +450,10 @@ export const constructForSingle = (models, recordType) => { entity_id: [constructRecordExistsWithId(models.entity)], survey_id: [constructRecordExistsWithId(models.survey)], assignee_id: [constructIsEmptyOr(constructRecordExistsWithId(models.user))], - due_date: [ - (value, { status }) => { - if (status !== 'repeating' && !value) { - throw new Error('Due date is required for non-recurring tasks'); - } - return true; - }, - ], - repeat_schedule: [ - (value, { status }) => { - if (status === 'repeating' && !value) { - throw new Error('Repeat frequency is required for recurring tasks'); - } - return true; - }, - ], - status: [ - (value, { repeat_schedule: repeatSchedule }) => { - if (repeatSchedule) return true; - if (!value) { - throw new Error('Status is required'); - } - return true; - }, - ], + due_date: [hasContent], + status: [hasContent], }; + default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } diff --git a/packages/central-server/src/apiV2/utilities/emailVerification.js b/packages/central-server/src/apiV2/utilities/emailVerification.js index 96080eba74..3f499712a7 100644 --- a/packages/central-server/src/apiV2/utilities/emailVerification.js +++ b/packages/central-server/src/apiV2/utilities/emailVerification.js @@ -10,35 +10,23 @@ import { requireEnv } from '@tupaia/utils'; const EMAILS = { tupaia: { subject: 'Tupaia email verification', - body: (token, url) => - 'Thank you for registering with tupaia.org.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', + platformName: 'tupaia.org', }, datatrak: { subject: 'Tupaia Datatrak email verification', - body: (token, url) => - 'Thank you for registering with datatrak.tupaia.org.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', + platformName: 'datatrak.tupaia.org', }, lesmis: { subject: 'LESMIS email verification', - body: (token, url) => - 'Thank you for registering with lesmis.la.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/en/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', signOff: 'Best regards,\nThe LESMIS Team', + platformName: 'lesmis.la', }, }; export const sendEmailVerification = async user => { const token = encryptPassword(user.email + user.password_hash, user.password_salt); const platform = user.primary_platform ? user.primary_platform : 'tupaia'; - const { subject, body, signOff } = EMAILS[platform]; + const { subject, signOff, platformName } = EMAILS[platform]; const TUPAIA_FRONT_END_URL = requireEnv('TUPAIA_FRONT_END_URL'); const LESMIS_FRONT_END_URL = requireEnv('LESMIS_FRONT_END_URL'); const DATATRAK_FRONT_END_URL = requireEnv('DATATRAK_FRONT_END_URL'); @@ -46,10 +34,24 @@ export const sendEmailVerification = async user => { const url = { tupaia: TUPAIA_FRONT_END_URL, datatrak: DATATRAK_FRONT_END_URL, - lesmis: LESMIS_FRONT_END_URL, + lesmis: `${LESMIS_FRONT_END_URL}/en`, }[platform]; - return sendEmail(user.email, { subject, text: body(token, url), signOff }); + const fullUrl = `${url}/verify-email?verifyEmailToken=${token}`; + + return sendEmail(user.email, { + subject, + signOff, + templateName: 'verifyEmail', + templateContext: { + title: 'Verify your email address', + platform: platformName, + cta: { + text: 'Verify email', + url: fullUrl, + }, + }, + }); }; export const verifyEmailHelper = async (models, searchCondition, token) => { diff --git a/packages/central-server/src/configureEnv.js b/packages/central-server/src/configureEnv.js index f64abfbb5c..83ee3698ec 100644 --- a/packages/central-server/src/configureEnv.js +++ b/packages/central-server/src/configureEnv.js @@ -16,6 +16,7 @@ const envFilePaths = [ path.resolve(__dirname, '../../../env/aws.env'), path.resolve(__dirname, '../../../env/aggregation.env'), path.resolve(__dirname, '../../../env/api-client.env'), + path.resolve(__dirname, '../../../env/platform.env'), path.resolve(__dirname, '../.env'), ]; diff --git a/packages/central-server/src/createApp.js b/packages/central-server/src/createApp.js index f31b2fde90..94727c5e8b 100644 --- a/packages/central-server/src/createApp.js +++ b/packages/central-server/src/createApp.js @@ -14,7 +14,6 @@ import { buildBasicBearerAuthMiddleware } from '@tupaia/server-boilerplate'; import { handleError } from './apiV2/middleware'; import { apiV2 } from './apiV2'; - /** * Set up express server with middleware, */ diff --git a/packages/central-server/src/database/meditrakSyncQueue/MeditrakSyncQueue.js b/packages/central-server/src/database/meditrakSyncQueue/MeditrakSyncQueue.js index bcd16f23fd..bfd1c3d881 100644 --- a/packages/central-server/src/database/meditrakSyncQueue/MeditrakSyncQueue.js +++ b/packages/central-server/src/database/meditrakSyncQueue/MeditrakSyncQueue.js @@ -10,7 +10,7 @@ import { ChangeHandler } from '@tupaia/database'; import { MeditrakSyncRecordUpdater } from './MeditrakSyncRecordUpdater'; const modelValidator = model => { - if (!model.meditrakConfig.minAppVersion) { + if (!model.meditrakConfig?.minAppVersion) { throw new Error( `Model for ${model.databaseRecord} must have a meditrakConfig.minAppVersion property`, ); @@ -33,6 +33,7 @@ export class MeditrakSyncQueue extends ChangeHandler { super(models, 'meditrak-sync-queue'); const typesToSync = models.getTypesToSyncWithMeditrak(); + const modelNamesToSync = Object.entries(models) .filter(([, model]) => typesToSync.includes(model.databaseRecord)) .map(([modelName]) => modelName); diff --git a/packages/central-server/src/database/meditrakSyncQueue/createPermissionsBasedMeditrakSyncQueue.js b/packages/central-server/src/database/meditrakSyncQueue/createPermissionsBasedMeditrakSyncQueue.js index 503b2a66c2..21a7ef6f75 100644 --- a/packages/central-server/src/database/meditrakSyncQueue/createPermissionsBasedMeditrakSyncQueue.js +++ b/packages/central-server/src/database/meditrakSyncQueue/createPermissionsBasedMeditrakSyncQueue.js @@ -27,59 +27,104 @@ SELECT msq.*, max(e."type") AS entity_type, COALESCE( + -- Country ${groupToArrayOrNull('co.id')}, + -- Entity ${groupToArrayOrNull('e_co.id')}, + -- Clinic ${groupToArrayOrNull('c.country_id')}, + -- Geographical Area ${groupToArrayOrNull('ga.country_id')}, + -- Survey ${groupToFlatArrayOrNull('s.country_ids')}, + -- Survey Group ${groupToFlatArrayOrNull('sg_s.country_ids')}, + -- Survey Screen ${groupToFlatArrayOrNull('ss_s.country_ids')}, + -- Survey Screen Component ${groupToFlatArrayOrNull('ssc_ss_s.country_ids')}, + -- Question ${groupToFlatArrayOrNull('q_ssc_ss_s.country_ids')}, + -- Option Set ${groupToFlatArrayOrNull('os_q_ssc_ss_s.country_ids')}, - ${groupToFlatArrayOrNull('o_os_q_ssc_ss_s.country_ids')} + -- Option + ${groupToFlatArrayOrNull('o_os_q_ssc_ss_s.country_ids')}, + -- User Entity Permission + ${groupToArrayOrNull('uep_e_co.id')} ) as country_ids, COALESCE( + -- Permission Group ${groupToArrayOrNull('pg."name"')}, + -- Survey ${groupToArrayOrNull('s_pg."name"')}, + -- Survey Group ${groupToArrayOrNull('sg_s_pg."name"')}, + -- Survey Screen ${groupToArrayOrNull('ss_s_pg."name"')}, + -- Survey Screen Component ${groupToArrayOrNull('ssc_ss_s_pg."name"')}, + -- Question ${groupToArrayOrNull('q_ssc_ss_s_pg."name"')}, + -- Option Set ${groupToArrayOrNull('os_q_ssc_ss_s_pg."name"')}, + -- Option ${groupToArrayOrNull('o_os_q_ssc_ss_s_pg."name"')} ) as permission_groups FROM meditrak_sync_queue msq + +-- Country LEFT JOIN country co ON msq.record_id = co.id + +-- Entity LEFT JOIN entity e ON msq.record_id = e.id LEFT JOIN country e_co ON e_co.code = e.country_code + +-- Clinic LEFT JOIN clinic c ON msq.record_id = c.id + +-- Geographical Area LEFT JOIN geographical_area ga ON msq.record_id = ga.id + +-- Permission Group LEFT JOIN permission_group pg ON msq.record_id = pg.id + +-- Survey LEFT JOIN survey s ON msq.record_id = s.id LEFT JOIN permission_group s_pg ON s.permission_group_id = s_pg.id + +-- Survey Group LEFT JOIN survey_group sg ON msq.record_id = sg.id LEFT JOIN survey sg_s ON sg_s.survey_group_id = sg.id LEFT JOIN permission_group sg_s_pg ON sg_s.permission_group_id = sg_s_pg.id + +-- Survey Screen LEFT JOIN survey_screen ss ON msq.record_id = ss.id LEFT JOIN survey ss_s ON ss.survey_id = ss_s.id LEFT JOIN permission_group ss_s_pg ON ss_s.permission_group_id = ss_s_pg.id + +-- Survey Screen Component LEFT JOIN survey_screen_component ssc ON msq.record_id = ssc.id LEFT JOIN survey_screen ssc_ss ON ssc.screen_id = ssc_ss.id LEFT JOIN survey ssc_ss_s ON ssc_ss.survey_id = ssc_ss_s.id LEFT JOIN permission_group ssc_ss_s_pg ON ssc_ss_s.permission_group_id = ssc_ss_s_pg.id + +-- Question LEFT JOIN question q ON msq.record_id = q.id LEFT JOIN survey_screen_component q_ssc ON q_ssc.question_id = q.id LEFT JOIN survey_screen q_ssc_ss ON q_ssc.screen_id = q_ssc_ss.id LEFT JOIN survey q_ssc_ss_s ON q_ssc_ss.survey_id = q_ssc_ss_s.id LEFT JOIN permission_group q_ssc_ss_s_pg ON q_ssc_ss_s.permission_group_id = q_ssc_ss_s_pg.id + +-- Option Set LEFT JOIN option_set os ON msq.record_id = os.id LEFT JOIN question os_q ON os_q.option_set_id = os.id LEFT JOIN survey_screen_component os_q_ssc ON os_q_ssc.question_id = os_q.id LEFT JOIN survey_screen os_q_ssc_ss ON os_q_ssc.screen_id = os_q_ssc_ss.id LEFT JOIN survey os_q_ssc_ss_s ON os_q_ssc_ss.survey_id = os_q_ssc_ss_s.id LEFT JOIN permission_group os_q_ssc_ss_s_pg ON os_q_ssc_ss_s.permission_group_id = os_q_ssc_ss_s_pg.id + +-- Option LEFT JOIN "option" o ON msq.record_id = o.id LEFT JOIN option_set o_os ON o.option_set_id = o_os.id LEFT JOIN question o_os_q ON o_os_q.option_set_id = o_os.id @@ -87,6 +132,12 @@ LEFT JOIN survey_screen_component o_os_q_ssc ON o_os_q_ssc.question_id = o_os_q. LEFT JOIN survey_screen o_os_q_ssc_ss ON o_os_q_ssc.screen_id = o_os_q_ssc_ss.id LEFT JOIN survey o_os_q_ssc_ss_s ON o_os_q_ssc_ss.survey_id = o_os_q_ssc_ss_s.id LEFT JOIN permission_group o_os_q_ssc_ss_s_pg ON o_os_q_ssc_ss_s.permission_group_id = o_os_q_ssc_ss_s_pg.id + +-- User Entity Permission +LEFT JOIN user_entity_permission uep ON msq.record_id = uep.id +LEFT JOIN entity uep_e ON uep.entity_id = uep_e.id +LEFT JOIN country uep_e_co ON uep_e_co.code = uep_e.country_code + GROUP BY msq.id; CREATE UNIQUE INDEX permissions_based_meditrak_sync_queue_id_idx ON permissions_based_meditrak_sync_queue (id); `); diff --git a/packages/central-server/src/database/models/Answer.js b/packages/central-server/src/database/models/Answer.js index 7c91ae25d1..43955be65c 100644 --- a/packages/central-server/src/database/models/Answer.js +++ b/packages/central-server/src/database/models/Answer.js @@ -34,6 +34,8 @@ export const ANSWER_TYPES = { ARITHMETIC: 'Arithmetic', CONDITION: 'Condition', FILE: 'File', + TASK: 'Task', + USER: 'User', // If adding a new type, add validation in both importSurveys and updateSurveyResponses }; diff --git a/packages/central-server/src/database/models/User.js b/packages/central-server/src/database/models/User.js index 3e50186ca5..71516b4ba7 100644 --- a/packages/central-server/src/database/models/User.js +++ b/packages/central-server/src/database/models/User.js @@ -5,6 +5,22 @@ import { UserRecord as CommonUserRecord, UserModel as CommonUserModel } from '@tupaia/database'; +// Internal users who should be flagged in the meditrak app to exclude from user lists +const INTERNAL_USERS = [ + 'edmofro@gmail.com', // Edwin + 'kahlinda.mahoney@gmail.com', // Kahlinda + 'lparish1980@gmail.com', // Lewis + 'sus.lake@gmail.com', // Susie + 'michaelnunan@hotmail.com', // Michael + 'vanbeekandrew@gmail.com', // Andrew + 'gerardckelly@gmail.com', // Gerry K + 'geoffreyfisher@hotmail.com', // Geoff F + 'josh@sussol.net', // mSupply API Client + 'unicef.laos.edu@gmail.com', // Laos Schools Data Collector +]; + +const INTERNAL_EMAIL_REGEXP = /((@bes.au)|(@tupaia.org)|(@beyondessential.com.au))/; + // Currently our pattern is that session tables don't have models // in the generic database package, this is a quick and dirty way to get // context for them into central-server @@ -30,6 +46,35 @@ class UserRecord extends CommonUserRecord { } export class UserModel extends CommonUserModel { + meditrakConfig = { + // only sync id and first and last name + ignorableFields: [ + 'gender', + 'creation_date', + 'employer', + 'position', + 'mobile_number', + 'password_hash', + 'password_salt', + 'verified_email', + 'profile_image', + 'primary_platform', + 'preferences', + 'full_name', // ignore this because it isn't a real field, it's a derived field + ], + translateRecordForSync: record => { + const { email, ...restOfRecord } = record; + const isInternal = INTERNAL_USERS.includes(email) || INTERNAL_EMAIL_REGEXP.test(email); + + // Flag internal users. These will be filtered out in meditrak-app + if (isInternal) { + return { ...restOfRecord, internal: true }; + } + return restOfRecord; + }, + minAppVersion: '1.14.144', + }; + get DatabaseRecordClass() { return UserRecord; } diff --git a/packages/central-server/src/database/models/UserEntityPermission.js b/packages/central-server/src/database/models/UserEntityPermission.js index 29f7fa60c3..2a7c19ba8f 100644 --- a/packages/central-server/src/database/models/UserEntityPermission.js +++ b/packages/central-server/src/database/models/UserEntityPermission.js @@ -7,27 +7,23 @@ import { UserEntityPermissionModel as CommonUserEntityPermissionModel } from '@t import { sendEmail } from '@tupaia/server-utils'; export class UserEntityPermissionModel extends CommonUserEntityPermissionModel { + meditrakConfig = { + minAppVersion: '1.14.144', + }; + notifiers = [onUpsertSendPermissionGrantEmail, expireAccess]; } const EMAILS = { tupaia: { subject: 'Tupaia Permission Granted', - body: (userName, permissionGroupName, entityName) => - `Hi ${userName},\n\n` + - `This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` + - 'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on Tupaia.org.\n\n' + - "Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" + - 'Have fun exploring Tupaia, and feel free to get in touch if you have any questions.\n', + description: + 'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on Tupaia.org.', }, lesmis: { subject: 'LESMIS Permission Granted', - body: (userName, permissionGroupName, entityName) => - `Hi ${userName},\n\n` + - `This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` + - 'This allows you to see reports and map overlays on lesmis.la.\n\n' + - "Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" + - 'Feel free to get in touch if you have any questions.\n', + description: + 'This allows you to see reports and map overlays on lesmis.la.', signOff: 'Best regards,\nThe LESMIS Team', }, }; @@ -51,12 +47,19 @@ async function onUpsertSendPermissionGrantEmail( const permissionGroup = await models.permissionGroup.findById(newRecord.permission_group_id); const platform = user.primary_platform ? user.primary_platform : 'tupaia'; - const { subject, body, signOff } = EMAILS[platform]; + const { subject, description, signOff } = EMAILS[platform]; sendEmail(user.email, { subject, - text: body(user.first_name, permissionGroup.name, entity.name), signOff, + templateName: 'permissionGranted', + templateContext: { + title: 'Permission Granted', + description, + userName: user.first_name, + entityName: entity.name, + permissionGroupName: permissionGroup.name, + }, }); } diff --git a/packages/central-server/src/index.js b/packages/central-server/src/index.js index 40296a5943..30fe69117e 100644 --- a/packages/central-server/src/index.js +++ b/packages/central-server/src/index.js @@ -5,24 +5,28 @@ import '@babel/polyfill'; import http from 'http'; +import nodeSchedule from 'node-schedule'; import { AnalyticsRefresher, EntityHierarchyCacher, ModelRegistry, SurveyResponseOutdater, + TaskCompletionHandler, + TaskCreationHandler, TupaiaDatabase, getDbMigrator, + TaskAssigneeEmailer, + TaskUpdateHandler, } from '@tupaia/database'; import { isFeatureEnabled } from '@tupaia/utils'; - -import { MeditrakSyncQueue } from './database'; +import { createPermissionsBasedMeditrakSyncQueue, MeditrakSyncQueue } from './database'; import * as modelClasses from './database/models'; import { startSyncWithDhis } from './dhis'; import { startSyncWithMs1 } from './ms1'; import { startSyncWithKoBo } from './kobo'; import { startFeedScraper } from './social'; import { createApp } from './createApp'; - +import { TaskOverdueChecker, RepeatingTaskDueDateHandler } from './scheduledTasks'; import winston from './log'; import { configureEnv } from './configureEnv'; @@ -55,6 +59,28 @@ configureEnv(); const surveyResponseOutdater = new SurveyResponseOutdater(models); surveyResponseOutdater.listenForChanges(); + // Add listener to handle survey response changes for tasks + const taskCompletionHandler = new TaskCompletionHandler(models); + taskCompletionHandler.listenForChanges(); + + // Add listener to handle creating tasks when submitting survey responses + const taskCreationHandler = new TaskCreationHandler(models); + taskCreationHandler.listenForChanges(); + + // Add listener to handle assignee changes for tasks + const taskAssigneeEmailer = new TaskAssigneeEmailer(models); + taskAssigneeEmailer.listenForChanges(); + + // Add listener to handle survey response entity changes for tasks + const taskUpdateHandler = new TaskUpdateHandler(models); + taskUpdateHandler.listenForChanges(); + + /** + * Scheduled tasks + */ + new TaskOverdueChecker(models).init(); + new RepeatingTaskDueDateHandler(models).init(); + /** * Set up actual app with routes etc. */ @@ -99,8 +125,21 @@ configureEnv(); const dbMigrator = getDbMigrator(); await dbMigrator.up(); winston.info('Database migrations complete'); + + if (isFeatureEnabled('MEDITRAK_SYNC_QUEUE')) { + winston.info('Creating permissions based meditrak sync queue'); + // don't await this as it's not critical, and will hold up the process if it fails + createPermissionsBasedMeditrakSyncQueue(database); + } } catch (error) { winston.error(error.message); } } + + /** + * Gracefully handle shutdown of ScheduledTasks + */ + process.on('SIGINT', function () { + nodeSchedule.gracefulShutdown().then(() => process.exit(0)); + }); })(); diff --git a/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js b/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js new file mode 100644 index 0000000000..1a2aed76b6 --- /dev/null +++ b/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js @@ -0,0 +1,44 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import winston from 'winston'; +import { getNextOccurrence } from '@tupaia/utils'; +import { ScheduledTask } from './ScheduledTask'; + +export class RepeatingTaskDueDateHandler extends ScheduledTask { + constructor(models) { + // run RepeatingTaskDueDateHandler every hour + super(models, 'RepeatingTaskDueDateHandler', '0 * * * *'); + } + + async run() { + const { task } = this.models; + // find all repeating tasks that have passed their current due date + const repeatingTasks = await task.find( + { + task_status: 'repeating', + due_date: { comparator: '<', comparisonValue: new Date().getTime() }, + }, + { + columns: ['task.id', 'repeat_schedule'], + }, + ); + + winston.info(`Found ${repeatingTasks.length} repeating task(s)`); + + // update the due date for each repeating task to the next occurrence + for (const repeatingTask of repeatingTasks) { + const { repeat_schedule: repeatSchedule } = repeatingTask; + + const nextDueDate = getNextOccurrence({ + ...repeatSchedule, + dtstart: new Date(repeatSchedule.dtstart), // convert string to date because rrule.js expects a Date object + }); + + await task.updateById(repeatingTask.id, { due_date: new Date(nextDueDate).getTime() }); + + winston.info(`Updated due date for task ${repeatingTask.id} to ${nextDueDate}`); + } + } +} diff --git a/packages/central-server/src/scheduledTasks/ScheduledTask.js b/packages/central-server/src/scheduledTasks/ScheduledTask.js new file mode 100644 index 0000000000..54b078e1ca --- /dev/null +++ b/packages/central-server/src/scheduledTasks/ScheduledTask.js @@ -0,0 +1,96 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { scheduleJob } from 'node-schedule'; +import winston from 'winston'; + +/** + * Base class for scheduled tasks. Uses 'node-schedule' for scheduling based on cron tab syntax + * Subclasses should implement the run method and need to be initialised by instantiating the + * class and calling init in the central-server index.js file + */ +export class ScheduledTask { + /** + * Cron tab config for scheduling the task + */ + schedule = null; + + /** + * Name of the task for logging + */ + name = null; + + /** + * Holds the scheduled job object for the task + */ + job = null; + + /** + * Keeps track of start time for logging + */ + start = null; + + /** + * Lock key for database advisory lock + */ + lockKey = null; + + /** + * Model registry for database access + */ + models = null; + + constructor(models, name, schedule) { + if (!name) { + throw new Error(`ScheduledTask has no name`); + } + + if (!schedule) { + throw new Error(`ScheduledTask ${name} has no schedule`); + } + + this.name = name; + this.schedule = schedule; + this.models = models; + this.lockKey = name; + winston.info(`Initialising scheduled task ${this.name}`); + } + + async run() { + throw new Error('Any subclass of ScheduledTask must implement the "run" method'); + } + + async runTask() { + this.start = Date.now(); + + try { + await this.models.wrapInTransaction(async transactingModels => { + // Acquire a database advisory lock for the transaction + // Ensures no other server instance can execute its change handler at the same time + await transactingModels.database.acquireAdvisoryLockForTransaction(this.lockKey); + await this.run(); + const durationMs = Date.now() - this.start; + winston.info(`ScheduledTask: ${this.name}: Succeeded in ${durationMs}`); + return true; + }); + } catch (e) { + const durationMs = Date.now() - this.start; + winston.error(`ScheduledTask: ${this.name}: Failed`, { durationMs }); + winston.error(e.stack); + return false; + } finally { + this.start = null; + } + } + + init() { + if (!this.job) { + winston.info(`ScheduledTask: ${this.name}: Scheduled for ${this.schedule}`); + this.job = scheduleJob(this.schedule, async () => { + await this.runTask(); + }); + } + } +} diff --git a/packages/central-server/src/scheduledTasks/TaskOverdueChecker.js b/packages/central-server/src/scheduledTasks/TaskOverdueChecker.js new file mode 100644 index 0000000000..818e8ba329 --- /dev/null +++ b/packages/central-server/src/scheduledTasks/TaskOverdueChecker.js @@ -0,0 +1,57 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { sendEmail } from '@tupaia/server-utils'; +import { requireEnv } from '@tupaia/utils'; +import winston from 'winston'; +import { ScheduledTask } from './ScheduledTask'; + +export class TaskOverdueChecker extends ScheduledTask { + constructor(models) { + // run TaskOverdueChecker every hour + super(models, 'TaskOverdueChecker', '0 * * * *'); + } + + async run() { + const { task, user, taskComment } = this.models; + const overdueTasks = await task.find({ + task_status: 'overdue', + overdue_email_sent: null, + }); + + winston.info(`Found ${overdueTasks.length} overdue task(s)`); + + for (const overdueTask of overdueTasks) { + if (!overdueTask.assignee_id) { + winston.info(`Task ${overdueTask.id} has no assignee`); + continue; + } + const assignee = await user.findById(overdueTask.assignee_id); + + if (!assignee) { + winston.error(`Assignee with id ${overdueTask.assignee_id} not found`); + continue; + } + const datatrakURL = requireEnv('DATATRAK_FRONT_END_URL'); + + const result = await sendEmail(assignee.email, { + subject: 'Task overdue on Tupaia.org', + templateName: 'overdueTask', + templateContext: { + userName: assignee.first_name, + surveyName: overdueTask.survey_name, + entityName: overdueTask.entity_name, + cta: { + url: `${datatrakURL}/tasks/${overdueTask.id}`, + text: 'View task', + }, + }, + }); + + winston.info(`Email sent to ${assignee.email} with status: ${result.response}`); + await task.updateById(overdueTask.id, { overdue_email_sent: new Date() }); + await overdueTask.addOverdueComment(assignee.id); + } + } +} diff --git a/packages/central-server/src/scheduledTasks/index.js b/packages/central-server/src/scheduledTasks/index.js new file mode 100644 index 0000000000..edcece7bc4 --- /dev/null +++ b/packages/central-server/src/scheduledTasks/index.js @@ -0,0 +1,7 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { TaskOverdueChecker } from './TaskOverdueChecker'; +export { RepeatingTaskDueDateHandler } from './RepeatingTaskDueDateHandler'; diff --git a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js index dc7cf1c8d0..9f35150e43 100644 --- a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js +++ b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js @@ -42,99 +42,117 @@ const expectAccessibleExportDataHeaderRow = exportData => { describe('exportSurveyResponses(): GET export/surveysResponses', () => { const app = new TestableApp(); const { models } = app; + let vanuatuCountry; + let kiribatiCountry; + let survey1; + let survey2; + let user; + + before(async () => { + await resetTestData(); + + sinon.stub(xlsx.utils, 'aoa_to_sheet'); + + user = await findOrCreateDummyRecord(models.user, { + email: 'test_user@email.com', + first_name: 'Test', + last_name: 'User', + id: 'test_user', + }); - describe('Test permissions when exporting survey responses', async () => { - let vanuatuCountry; - let kiribatiCountry; - let survey1; - let survey2; - - before(async () => { - await resetTestData(); - - sinon.stub(xlsx.utils, 'aoa_to_sheet'); - - const adminPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Admin', - }); - const donorPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { - name: 'Donor', - }); - - ({ country: vanuatuCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'VU', - name: 'Vanuatu', - })); - ({ country: kiribatiCountry } = await findOrCreateDummyCountryEntity(models, { - code: 'KI', - name: 'Kiribati', - })); - - [{ survey: survey1 }, { survey: survey2 }] = await buildAndInsertSurveys(models, [ - { - code: TEST_SURVEY_1_CODE, - name: TEST_SURVEY_1_NAME, - country_ids: [vanuatuCountry.id], - permission_group_id: adminPermissionGroup.id, - }, - { - code: TEST_SURVEY_2_CODE, - name: TEST_SURVEY_2_NAME, - country_ids: [vanuatuCountry.id], - permission_group_id: donorPermissionGroup.id, - }, - ]); - - await buildAndInsertSurveyResponses(models, [ - { - surveyCode: survey1.code, - entityCode: vanuatuCountry.code, - data_time: new Date(), - answers: { - question_1_test: 'question_1_test answer', - question_2_test: 'question_2_test answer', - }, + const adminPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + const donorPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + ({ country: vanuatuCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'VU', + name: 'Vanuatu', + })); + ({ country: kiribatiCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'KI', + name: 'Kiribati', + })); + + [{ survey: survey1 }, { survey: survey2 }] = await buildAndInsertSurveys(models, [ + { + code: TEST_SURVEY_1_CODE, + name: TEST_SURVEY_1_NAME, + country_ids: [vanuatuCountry.id], + permission_group_id: adminPermissionGroup.id, + questions: [ + { code: 'question_1_test', text: 'Question 1 Test', type: 'FreeText' }, + { code: 'question_2_test', text: 'Question 2 Test', type: 'FreeText' }, + { code: 'question_3_test', text: 'Question 3 Test', type: 'FreeText' }, + { code: 'question_4_test', text: 'Question 4 Test', type: 'User' }, + ], + }, + { + code: TEST_SURVEY_2_CODE, + name: TEST_SURVEY_2_NAME, + country_ids: [vanuatuCountry.id], + permission_group_id: donorPermissionGroup.id, + questions: [ + { code: 'question_5_test', text: 'Question 5 Test', type: 'FreeText' }, + { code: 'question_6_test', text: 'Question 6 Test', type: 'User' }, + { code: 'question_7_test', text: 'Question 7 Test', type: 'FreeText' }, + { code: 'question_8_test', text: 'Question 8 Test', type: 'FreeText' }, + ], + }, + ]); + + await buildAndInsertSurveyResponses(models, [ + { + surveyCode: survey1.code, + entityCode: vanuatuCountry.code, + data_time: new Date(), + answers: { + question_1_test: 'question_1_test answer', + question_2_test: 'question_2_test answer', }, - { - surveyCode: survey1.code, - entityCode: vanuatuCountry.code, - data_time: new Date(), - answers: { - question_3_test: 'question_3_test answer', - question_4_test: 'question_4_test answer', - }, + }, + { + surveyCode: survey1.code, + entityCode: vanuatuCountry.code, + data_time: new Date(), + answers: { + question_3_test: 'question_3_test answer', + question_4_test: user.id, }, - { - surveyCode: survey2.code, - entityCode: vanuatuCountry.code, - data_time: new Date(), - answers: { - question_5_test: 'question_5_test answer', - question_6_test: 'question_6_test answer', - }, + }, + { + surveyCode: survey2.code, + entityCode: vanuatuCountry.code, + data_time: new Date(), + answers: { + question_5_test: 'question_5_test answer', + question_6_test: 'unknown_user_id', }, - { - surveyCode: survey2.code, - entityCode: kiribatiCountry.code, - outdated: true, - data_time: new Date(), - answers: { - question_7_test: 'question_7_test answer', - question_8_test: 'question_8_test answer', - }, + }, + { + surveyCode: survey2.code, + entityCode: kiribatiCountry.code, + outdated: true, + data_time: new Date(), + answers: { + question_7_test: 'question_7_test answer', + question_8_test: 'question_8_test answer', }, - ]); - }); + }, + ]); + }); - after(() => { - xlsx.utils.aoa_to_sheet.restore(); - }); + after(() => { + xlsx.utils.aoa_to_sheet.restore(); + }); - afterEach(() => { - app.revokeAccess(); - xlsx.utils.aoa_to_sheet.resetHistory(); - }); + afterEach(() => { + app.revokeAccess(); + xlsx.utils.aoa_to_sheet.resetHistory(); + }); + describe('Test permissions when exporting survey responses', async () => { describe('Should allow exporting a survey if users have Tupaia Admin Panel and survey permission group access to the corresponding countries', () => { it('one survey', async () => { await app.grantAccess(DEFAULT_POLICY); @@ -234,4 +252,35 @@ describe('exportSurveyResponses(): GET export/surveysResponses', () => { }); }); }); + + describe('Test user answers get generated', async () => { + it('should export the user answers with name and id', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.get( + `export/surveyResponses?surveyCodes=${survey1.code}&countryCode=${vanuatuCountry.code}`, + ); + + expect(xlsx.utils.aoa_to_sheet).to.have.been.calledOnce; + + const exportData = xlsx.utils.aoa_to_sheet.getCall(0).args[0]; + + const userAnswerRow = exportData.find(row => row[1] === 'User'); + expect(userAnswerRow.includes('Test User (test_user)')).to.be.true; + }); + + it('should ignore the entry if the user id is invalid', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.get( + `export/surveyResponses?surveyCodes=${survey2.code}&countryCode=${vanuatuCountry.code}`, + ); + + expect(xlsx.utils.aoa_to_sheet).to.have.been.calledOnce; + + const exportData = xlsx.utils.aoa_to_sheet.getCall(0).args[0]; + + const userAnswerRow = exportData.find(row => row[1] === 'User'); + + expect(userAnswerRow.includes('Could not find user with id unknown_user_id')).to.be.true; + }); + }); }); diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/importSurveyResponses.fixtures.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/importSurveyResponses.fixtures.js index c87413c642..13ea20cbc2 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/importSurveyResponses.fixtures.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/importSurveyResponses.fixtures.js @@ -95,6 +95,11 @@ export const FACILITY_FUNDAMENTALS_SURVEY = { code: 'TFF_Catchment_pop', type: 'Number', }, + { + id: 'tff_assigned_user___test', + code: 'TFF_Assigned_user', + type: 'User', + }, ], }; @@ -113,6 +118,12 @@ export const BASIC_SURVEY_A = { code: 'basic_survey_a_q2', type: 'FreeText', }, + { + id: 'basic_survey_a_q3___test', + code: 'basic_survey_a_q3', + type: 'User', + text: 'User assigned', + }, ], }; @@ -259,6 +270,7 @@ export const NON_PERIODIC_RESPONSES_AFTER_UPDATES = { answers: { tff_other_names_____test: 'FNQ', tff_catchment_pop___test: '7500', + tff_assigned_user___test: 'test_user_id_1', }, }, ], @@ -339,6 +351,7 @@ export const NON_PERIODIC_RESPONSES_AFTER_UPDATES = { answers: { tff_other_names_____test: 'Thorno', tff_catchment_pop___test: '8000', + tff_assigned_user___test: 'test_user_id_2', }, }, ], diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js index 4a97a6e1b3..264f376917 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testFunctionality.js @@ -11,6 +11,7 @@ import moment from 'moment'; import { buildAndInsertSurveys, findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, findOrCreateRecords, } from '@tupaia/database'; import { resetTestData, TestableApp } from '../../../testUtilities'; @@ -109,6 +110,20 @@ export const testFunctionality = async () => { before(async () => { await app.grantFullAccess(); + await findOrCreateDummyRecord(models.user, { + email: 'test_email1@email.com', + first_name: 'Test', + last_name: 'User1', + id: 'test_user_id_1', + }); + + await findOrCreateDummyRecord(models.user, { + email: 'test_email2@email.com', + first_name: 'Test', + last_name: 'User2', + id: 'test_user_id_2', + }); + await findOrCreateDummyCountryEntity(models, { code: 'DL', name: 'Demo Land' }); const entities = [ { code: 'DL_1', name: 'Port Douglas' }, diff --git a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js index a2324ea3a5..b3cb601fe5 100644 --- a/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js +++ b/packages/central-server/src/tests/apiV2/import/importSurveyResponses/testPermissions.js @@ -69,6 +69,12 @@ export const testPermissions = async () => { type: 'FreeText', text: 'Opening hours', }, + { + id: 'fdfcc42a44456c123a9_test', + code: 'TEST_IMPORT_SURVEY_RESPONSES_1_question_3_test', + type: 'User', + text: 'User assigned', + }, ], }, { 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..22ee62fc98 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -0,0 +1,172 @@ +/** + * 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'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; + +describe('Permissions checker for CreateTaskComment', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + TO: ['Donor'], + }; + + const app = new TestableApp(); + const { models } = app; + let tasks; + + 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 assignee = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + await findOrCreateDummyRecord(models.user, assignee); + + const dueDate = new Date('2021-12-31').getTime(); + + 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, + assignee_id: assignee.id, + due_date: null, + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, + status: null, + }, + ]; + + await Promise.all( + tasks.map(task => + findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ), + ), + ); + }); + + afterEach(async () => { + await models.taskComment.delete({ task_id: tasks[0].id }); + await models.taskComment.delete({ task_id: tasks[1].id }); + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('POST /tasks/:id/taskComments', async () => { + it('Sufficient permissions: Successfully creates a task comment when the user has BES Admin permissions', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.post(`tasks/${tasks[0].id}/taskComments`, { + body: { + message: 'This is a test comment', + type: 'user', + }, + }); + const comment = await models.taskComment.findOne({ task_id: tasks[0].id }); + expect(comment.message).to.equal('This is a test comment'); + }); + + it('Sufficient permissions: Successfully creates a task comment when user has access to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.post(`tasks/${tasks[1].id}/taskComments`, { + body: { + message: 'This is a test comment', + type: 'user', + }, + }); + const comment = await models.taskComment.findOne({ task_id: tasks[1].id }); + expect(comment.message).to.equal('This is a test comment'); + }); + + it('Insufficient permissions: throws an error if trying to create a comment for a task the user does not have permissions for', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post(`tasks/${tasks[0].id}/taskComments`, { + body: { + message: 'This is a test comment', + type: 'user', + }, + }); + + 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..5f1c01648a --- /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').getTime(); + + 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 c7c79ea9a0..874f6573c2 100644 --- a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js @@ -45,10 +45,12 @@ describe('Permissions checker for CreateTask', async () => { last_name: 'Pan', }; + const dueDate = new Date('2021-12-31').getTime(); + const BASE_TASK = { assignee_id: assignee.id, - repeat_schedule: '{}', - due_date: new Date('2021-12-31'), + repeat_schedule: null, + due_date: dueDate, status: 'to_do', }; @@ -135,5 +137,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..8e581fc036 100644 --- a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -10,6 +10,7 @@ import { findOrCreateDummyRecord, generateId, } from '@tupaia/database'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; import { TestableApp, resetTestData } from '../../testUtilities'; import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; @@ -55,7 +56,7 @@ describe('Permissions checker for EditTask', async () => { last_name: 'Pan', }; - const dueDate = new Date('2021-12-31'); + const dueDate = new Date('2021-12-31').getTime(); let tasks; @@ -100,6 +101,7 @@ describe('Permissions checker for EditTask', async () => { survey_id: surveys[0].survey.id, entity_id: facilities[0].id, due_date: dueDate, + repeat_schedule: null, status: 'to_do', }, { @@ -108,6 +110,7 @@ describe('Permissions checker for EditTask', async () => { entity_id: facilities[1].id, assignee_id: assignee.id, due_date: dueDate, + repeat_schedule: null, status: 'to_do', }, ]; @@ -129,83 +132,251 @@ describe('Permissions checker for EditTask', async () => { }); describe('PUT /tasks/:id', async () => { - it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => { - await app.grantAccess(BES_ADMIN_POLICY); - await app.put(`tasks/${tasks[1].id}`, { - body: { - entity_id: facilities[0].id, - survey_id: surveys[0].survey.id, - }, + describe('Permissions', async () => { + it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[0].id); + expect(result[0].survey_id).to.equal(surveys[0].survey.id); }); - const result = await models.task.find({ - id: tasks[1].id, + + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + status: 'completed', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].status).to.equal('completed'); + }); + + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); }); - expect(result[0].entity_id).to.equal(facilities[0].id); - expect(result[0].survey_id).to.equal(surveys[0].survey.id); - }); - it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => { - await app.grantAccess(DEFAULT_POLICY); - await app.put(`tasks/${tasks[1].id}`, { - body: { - status: 'completed', - }, + it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[0].id}`, { + body: { + status: 'completed', + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the country of the task'); }); - const result = await models.task.find({ - id: tasks[1].id, + + it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[0].survey.id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new survey of the task'); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new entity of the task'); }); - expect(result[0].status).to.equal('completed'); }); - it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => { - await app.grantAccess({ - DL: ['Donor'], - TO: ['Donor'], + describe('System generated comments', () => { + it('Adds a comment when the due date changes on a task', async () => { + const newDate = new Date('2025-11-30').getTime(); + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + due_date: newDate, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'due_date', + }); + expect(comment).not.to.be.null; + }); + + it('Adds a comment when the repeat schedule changes from not repeating to repeating on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, + }, + }); + + const repeatComment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'repeat_schedule', + }); + + expect(repeatComment).not.to.be.null; }); - await app.put(`tasks/${tasks[1].id}`, { - body: { - survey_id: surveys[1].survey.id, - entity_id: facilities[1].id, - }, + + it('Does not add a comment when the due date changes but the repeat frequency stays the same', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + + const repeatingTask = await findOrCreateDummyRecord(models.task, { + ...tasks[1], + id: generateId(), + due_date: new Date('2025-11-30').getTime(), + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + dtstart: new Date('2025-11-30'), + }, + }); + await app.put(`tasks/${repeatingTask.id}`, { + body: { + due_date: new Date('2025-12-30').getTime(), + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + dtstart: new Date('2025-12-30'), + }, + }, + }); + + const repeatComment = await models.taskComment.findOne({ + task_id: repeatingTask.id, + type: models.taskComment.types.System, + 'template_variables->>field': 'repeat_schedule', + }); + + const dueDateComment = await models.taskComment.findOne({ + task_id: repeatingTask.id, + type: models.taskComment.types.System, + 'template_variables->>field': 'due_date', + }); + + expect(repeatComment).to.be.null; + expect(dueDateComment).to.be.null; }); - const result = await models.task.find({ - id: tasks[1].id, + + it('Adds a comment when the repeat schedule changes from repeating to not repeating on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, + }, + }); + + await app.put(`tasks/${tasks[1].id}`, { + body: { + repeat_schedule: null, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'repeat_schedule', + }); + expect(comment).not.to.be.null; }); - expect(result[0].entity_id).to.equal(facilities[1].id); - expect(result[0].survey_id).to.equal(surveys[1].survey.id); - }); - it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[0].id}`, { - body: { - status: 'completed', - }, + it('Adds a comment when the status changes on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + status: 'completed', + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'status', + }); + expect(comment).not.to.be.null; }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the country of the task'); - }); - it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[1].id}`, { - body: { - survey_id: surveys[0].survey.id, - }, + it('Adds a comment when the assignee changes to Unassigned on a task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + assignee_id: null, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[1].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'assignee_id', + }); + expect(comment).not.to.be.null; }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the new survey of the task'); - }); - it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => { - await app.grantAccess(DEFAULT_POLICY); - const { body: result } = await app.put(`tasks/${tasks[1].id}`, { - body: { - entity_id: facilities[0].id, - }, + it('Adds a comment when the assignee changes from unassigned to assigned on a task', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.put(`tasks/${tasks[0].id}`, { + body: { + assignee_id: assignee.id, + }, + }); + + const comment = await models.taskComment.findOne({ + task_id: tasks[0].id, + type: models.taskComment.types.System, + 'template_variables->>field': 'assignee_id', + }); + expect(comment).not.to.be.null; }); - expect(result).to.have.keys('error'); - expect(result.error).to.include('Need to have access to the new entity of the task'); }); }); }); diff --git a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js index 9f6be0c21b..2a93dce501 100644 --- a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js @@ -10,6 +10,7 @@ import { findOrCreateDummyRecord, generateId, } from '@tupaia/database'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; import { TestableApp, resetTestData } from '../../testUtilities'; import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; @@ -20,7 +21,6 @@ describe('Permissions checker for GETTasks', async () => { const DEFAULT_POLICY = { DL: ['Donor'], - TO: ['Donor'], }; const PUBLIC_POLICY = { @@ -88,7 +88,7 @@ describe('Permissions checker for GETTasks', async () => { }; await findOrCreateDummyRecord(models.user, assignee); - const dueDate = new Date('2021-12-31'); + const dueDate = new Date('2021-12-31').getTime(); tasks = [ { @@ -105,7 +105,20 @@ describe('Permissions checker for GETTasks', async () => { entity_id: facilities[1].id, assignee_id: assignee.id, due_date: null, - repeat_schedule: '{}', + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, + status: null, + }, + { + id: generateId(), + survey_id: surveys[1].survey.id, + entity_id: facilities[0].id, + assignee_id: assignee.id, + due_date: null, + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, status: null, }, ]; diff --git a/packages/central-server/src/tests/testData/surveyResponses/functionality/nonPeriodicUpdates.xlsx b/packages/central-server/src/tests/testData/surveyResponses/functionality/nonPeriodicUpdates.xlsx index 0a90b4acf3af79f63d748c4809abe5b2eb9defdc..c37140bf01dee34d8957fad743fb4cfbe25c436f 100644 GIT binary patch literal 9093 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 literal 12836 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 diff --git a/packages/central-server/src/tests/testData/surveyResponses/importResponsesFromSingleSurvey.xlsx b/packages/central-server/src/tests/testData/surveyResponses/importResponsesFromSingleSurvey.xlsx index e9de214768022c3e7c519f8fc711e599a2ceb3ef..ea30495b84e5e4d59afebc4aa2d06367b30abf93 100644 GIT binary patch literal 6989 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 literal 9241 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 { const qualifiedName = this.fullyQualifyColumn(fieldName); - const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; - if (customSelector) { - return { [fieldName]: customSelector(qualifiedName) }; + const customColumnSelector = this.getColumnSelector(fieldName, qualifiedName); + if (customColumnSelector) { + return { [fieldName]: customColumnSelector }; } return qualifiedName; }); @@ -148,6 +153,14 @@ export class DatabaseModel { return { ...options, ...customQueryOptions }; } + getColumnSelector(fieldName, qualifiedName) { + const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; + if (customSelector) { + return customSelector(qualifiedName); + } + return null; + } + async getDbConditions(dbConditions = {}) { const fieldNames = await this.fetchFieldNames(); const fullyQualifiedConditions = {}; @@ -162,9 +175,15 @@ export class DatabaseModel { // Don't touch RAW conditions fullyQualifiedConditions[field] = value; } else { - const fullyQualifiedField = fieldNames.includes(field) - ? this.fullyQualifyColumn(field) - : field; + const qualifiedName = this.fullyQualifyColumn(field); + const customSelector = this.getColumnSelector(field, qualifiedName); + let fieldSelector = qualifiedName; + // if there is a custom selector, and it is a string, use it as the field selector. In some cases it will be an object, e.g. `castAs: 'text'` which is used to cast the field to a specific type, but this is not used as the field selector as an error will be thrown. + if (customSelector && typeof customSelector === 'string') { + fieldSelector = customSelector; + } + + const fullyQualifiedField = fieldNames.includes(field) ? fieldSelector : field; fullyQualifiedConditions[fullyQualifiedField] = value; } } diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index 18d2d4887c..c8f875f7ad 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -70,6 +70,8 @@ const COMPARATORS = { */ const supportedFunctions = ['ST_AsGeoJSON', 'COALESCE']; +const RAW_INPUT_PATTERN = /(^CASE)|(^to_timestamp)/; + // no math here, just hand-tuned to be as low as possible while // keeping all the tests passing const HANDLER_DEBOUNCE_DURATION = 250; @@ -514,6 +516,7 @@ export class TupaiaDatabase { */ function buildQuery(connection, queryConfig, where = {}, options = {}) { const { recordType, queryMethod, queryMethodParameter } = queryConfig; + let query = connection(recordType); // Query starts as just the table, but will be built up // If an innerQuery is defined, make the outer query wrap it @@ -560,9 +563,8 @@ function buildQuery(connection, queryConfig, where = {}, options = {}) { } } - // Special case to handle CASE statements, otherwise they get interpreted as column names - const CASE_PATTERN = /^CASE/; - if (CASE_PATTERN.test(selector)) { + // Special case to handle raw input statements, otherwise they get interpreted as column names + if (RAW_INPUT_PATTERN.test(selector)) { return { [alias]: connection.raw(selector) }; } @@ -676,6 +678,7 @@ function addWhereClause(connection, baseQuery, where) { } const columnKey = getColSelector(connection, key); + const columnSelector = castAs ? connection.raw(`??::${castAs}`, [columnKey]) : columnKey; const { args = [comparator, sanitizeComparisonValue(comparator, comparisonValue)] } = value; @@ -739,8 +742,9 @@ function getColSelector(connection, inputColStr) { return connection.raw(`COALESCE(${identifiers})`, bindings); } - const casePattern = /^CASE/; - if (casePattern.test(inputColStr)) { + + // Special handling of raw input statements + if (RAW_INPUT_PATTERN.test(inputColStr)) { return connection.raw(inputColStr); } diff --git a/packages/database/src/__tests__/changeHandlers/TaskAssigneeEmailer.test.js b/packages/database/src/__tests__/changeHandlers/TaskAssigneeEmailer.test.js new file mode 100644 index 0000000000..495c9ae964 --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskAssigneeEmailer.test.js @@ -0,0 +1,173 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { TaskAssigneeEmailer } from '../../changeHandlers'; +import { + buildAndInsertSurvey, + findOrCreateDummyRecord, + getTestModels, + upsertDummyRecord, +} from '../../testUtilities'; +import { generateId } from '../../utilities'; + +const USER = { + first_name: 'Test', + last_name: 'User', + email: 'test@email.com', + id: generateId(), +}; +const ENTITY = { + name: 'Test Entity', + code: 'TEST_ENTITY', + id: generateId(), +}; + +const SURVEY = { + name: 'Test Survey', + code: 'TEST_TASK_SURVEY', + id: generateId(), +}; + +const TASK = { + survey_id: SURVEY.id, + entity_id: ENTITY.id, + status: 'to_do', + repeat_schedule: null, + assignee_id: null, + due_date: new Date('2021-01-01 00:00:00').getTime(), + id: generateId(), +}; + +describe('TaskAssigneeEmailer', () => { + const models = getTestModels(); + const taskAssigneeEmailer = new TaskAssigneeEmailer(models); + taskAssigneeEmailer.setDebounceTime(50); // short debounce time so tests run more quickly + let task; + + beforeAll(async () => { + await upsertDummyRecord(models.entity, ENTITY); + await buildAndInsertSurvey(models, SURVEY); + await upsertDummyRecord(models.user, USER); + task = await findOrCreateDummyRecord(models.task, TASK); + }); + + beforeEach(async () => { + taskAssigneeEmailer.listenForChanges(); + }); + + afterEach(async () => { + taskAssigneeEmailer.stopListeningForChanges(); + await models.task.updateById(task.id, { assignee_id: null }); + }); + + describe('handleChanges', () => { + it('Succeeds when entityId, surveyId, and assigneeId are valid ids', async () => { + await expect(async () => + taskAssigneeEmailer.handleChanges(models, [ + { + ...task, + assignee_id: USER.id, + }, + ]), + ).not.toThrow(); + }); + + it('Throws an error when assigneeId is not valid', async () => { + await expect(async () => + taskAssigneeEmailer.handleChanges(models, [ + { + ...task, + assignee_id: 'invalid id', + }, + ]), + ).rejects.toThrow('User with id invalid id not found'); + }); + + it('Throws an error when entityId is not valid', async () => { + await expect(async () => + taskAssigneeEmailer.handleChanges(models, [ + { + ...task, + assignee_id: USER.id, + entity_id: 'invalid id', + }, + ]), + ).rejects.toThrow('Entity with id invalid id not found'); + }); + + it('Throws an error when surveyId is not valid', async () => { + await expect(async () => + taskAssigneeEmailer.handleChanges(models, [ + { + ...task, + assignee_id: USER.id, + survey_id: 'invalid id', + }, + ]), + ).rejects.toThrow('Survey with id invalid id not found'); + }); + }); + + describe('getUpdatedTasks', () => { + it('Ignores tasks when the change type is not `update`', () => { + expect(taskAssigneeEmailer.getUpdatedTasks({ type: 'delete', old_record: TASK })).toEqual([]); + }); + + it('Ignores tasks when the assignee_id is not set on the new record', () => { + expect(taskAssigneeEmailer.getUpdatedTasks({ type: 'update', new_record: TASK })).toEqual([]); + }); + + it('Ignores tasks when the assignee_id is the same as the old assignee_id', () => { + expect( + taskAssigneeEmailer.getUpdatedTasks({ + type: 'update', + new_record: { + ...TASK, + assignee_id: USER.id, + }, + old_record: { + ...TASK, + assignee_id: USER.id, + }, + }), + ).toEqual([]); + }); + + it('Returns tasks when the assignee_id is set on new records and there is no old record', () => { + expect( + taskAssigneeEmailer.getUpdatedTasks({ + type: 'update', + new_record: { + ...TASK, + assignee_id: USER.id, + }, + }), + ).toEqual([ + { + ...TASK, + assignee_id: USER.id, + }, + ]); + }); + + it('Returns tasks when the assignee_id is has changed from the old record', () => { + expect( + taskAssigneeEmailer.getUpdatedTasks({ + type: 'update', + new_record: { + ...TASK, + assignee_id: USER.id, + }, + old_record: TASK, + }), + ).toEqual([ + { + ...TASK, + assignee_id: USER.id, + }, + ]); + }); + }); +}); diff --git a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js new file mode 100644 index 0000000000..f3dd8766b0 --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js @@ -0,0 +1,187 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { TaskCompletionHandler } from '../../changeHandlers'; +import { + buildAndInsertSurveys, + findOrCreateDummyRecord, + getTestModels, + populateTestData, + upsertDummyRecord, +} from '../../testUtilities'; +import { generateId } from '../../utilities'; + +const buildSurvey = () => { + const code = 'Test_survey'; + + return { + id: generateId(), + code, + questions: [{ code: `${code}1`, type: 'Number' }], + }; +}; + +const userId = generateId(); + +const SURVEY = buildSurvey(); + +describe('TaskCompletionHandler', () => { + const models = getTestModels(); + const taskCompletionHandler = new TaskCompletionHandler(models); + taskCompletionHandler.setDebounceTime(50); // short debounce time so tests run more quickly + + const createResponses = async data => { + const { surveyResponses } = await populateTestData(models, { + surveyResponse: data.map(({ date, ...otherFields }) => { + // append time if required + const datetime = date ?? `${date}T12:00:00`.slice(0, 'YYYY-MM-DDThh:mm:ss'.length); + + return { + start_time: datetime, + end_time: datetime, + data_time: '2024-12-21 09:00:00', + user_id: userId, + survey_id: SURVEY.id, + id: generateId(), + ...otherFields, + }; + }), + }); + + return surveyResponses.map(sr => sr.id); + }; + + const assertTaskStatus = async (taskId, expectedStatus, expectedSurveyResponseId) => { + await models.database.waitForAllChangeHandlers(); + const task = await models.task.findById(taskId); + + expect(task.status).toBe(expectedStatus); + expect(task.survey_response_id).toBe(expectedSurveyResponseId); + }; + + let tonga; + let task; + + beforeAll(async () => { + await buildAndInsertSurveys(models, [SURVEY]); + tonga = await findOrCreateDummyRecord(models.entity, { code: 'TO' }); + task = await findOrCreateDummyRecord(models.task, { + entity_id: tonga.id, + survey_id: SURVEY.id, + created_at: '2024-07-08', + status: 'to_do', + due_date: new Date('2024-07-20').getTime(), + survey_response_id: null, + }); + await upsertDummyRecord(models.user, { id: userId }); + }); + + beforeEach(async () => { + taskCompletionHandler.listenForChanges(); + }); + + afterEach(async () => { + taskCompletionHandler.stopListeningForChanges(); + await models.surveyResponse.delete({ survey_id: SURVEY.id }); + await models.task.update({ id: task.id }, { status: 'to_do', survey_response_id: null }); + }); + + describe('creating a survey response', () => { + it('created response marks associated tasks as completed if created_time < end_time, and links survey response IDs to the task', async () => { + const responseIds = await createResponses([ + { entity_id: tonga.id, date: '2024-07-20' }, + { entity_id: tonga.id, date: '2024-07-21' }, + ]); + await assertTaskStatus(task.id, 'completed', responseIds[0]); + const comments = await models.taskComment.find({ + task_id: task.id, + type: models.taskComment.types.System, + 'template_variables->>type': models.taskComment.systemCommentTypes.Complete, + }); + expect(comments).toHaveLength(1); + }); + + it('created response marks associated tasks as completed if created_time === end_time', async () => { + const responseIds = await createResponses([{ entity_id: tonga.id, date: '2024-07-08' }]); + await assertTaskStatus(task.id, 'completed', responseIds[0]); + }); + + it('created response does not mark associated tasks as completed if created_time > end_time', async () => { + await createResponses([{ entity_id: tonga.id, date: '2021-07-08' }]); + await assertTaskStatus(task.id, 'to_do', null); + }); + }); + + describe('updating a survey response', () => { + it('updating a survey response does not mark a task as completed', async () => { + await createResponses([{ entity_id: tonga.id, date: '2021-07-20' }]); + await models.surveyResponse.update({ entity_id: tonga.id }, { end_time: '2024-07-25' }); + await assertTaskStatus(task.id, 'to_do', null); + }); + }); + + describe('Repeating tasks', () => { + it('creating a survey response for a repeating task creates a new completed task', async () => { + const samoa = await findOrCreateDummyRecord(models.entity, { code: 'WS' }); + const repeatTask = await findOrCreateDummyRecord(models.task, { + entity_id: samoa.id, + survey_id: SURVEY.id, + created_at: '2024-07-08', + due_date: new Date('2024-07-25').getTime(), + status: null, + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, + }); + + const responses = await createResponses([{ entity_id: samoa.id, date: '2024-07-20' }]); + // Check that the repeat task has stayed as is + await assertTaskStatus(repeatTask.id, null, null); + + const newTask = await models.task.findOne({ + survey_response_id: responses[0], + entity_id: samoa.id, + parent_task_id: repeatTask.id, + due_date: new Date('2024-07-25').getTime(), + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, + }); + await assertTaskStatus(newTask.id, 'completed', responses[0]); + }); + + it('creating a survey response for a repeating task with status to_do creates a new completed task', async () => { + const fiji = await findOrCreateDummyRecord(models.entity, { code: 'FJ' }); + const repeatTask = await findOrCreateDummyRecord(models.task, { + entity_id: fiji.id, + survey_id: SURVEY.id, + created_at: '2024-07-08', + status: 'to_do', + due_date: new Date('2024-07-08').getTime(), + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, + }); + + const responses = await createResponses([{ entity_id: fiji.id, date: '2024-07-20' }]); + // Check that the repeat task has stayed as is + await assertTaskStatus(repeatTask.id, 'to_do', null); + const newTask = await models.task.findOne({ + survey_response_id: responses[0], + entity_id: fiji.id, + parent_task_id: repeatTask.id, + due_date: new Date('2024-07-08').getTime(), + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, + }); + await assertTaskStatus(newTask.id, 'completed', responses[0]); + }); + }); +}); diff --git a/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js new file mode 100644 index 0000000000..73695f1dfe --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskCreationHandler.test.js @@ -0,0 +1,210 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { TaskCreationHandler } from '../../changeHandlers'; +import { + buildAndInsertSurvey, + buildAndInsertSurveyResponses, + getTestModels, + upsertDummyRecord, +} from '../../testUtilities'; +import { generateId } from '../../utilities'; + +const userId = generateId(); +const entityId = generateId(); +const entityCode = 'TO'; +const taskSurveyId = generateId(); +const taskSurveyCode = 'TEST_TASK_SURVEY'; + +const buildEntity = async (models, data) => { + return upsertDummyRecord(models.entity, { id: entityId, code: entityCode, ...data }); +}; +const buildTaskCreationSurvey = async (models, config) => { + const survey = { + id: generateId(), + code: generateId(), + questions: [ + { + id: 'TEST_ID_00', + code: 'TEST_CODE_00', + type: 'PrimaryEntity', + }, + { + id: 'TEST_ID_01', + code: 'TEST_CODE_01', + type: 'Binary', + }, + { + id: 'TEST_ID_02', + code: 'TEST_CODE_02', + type: 'Date', + }, + { + id: 'TEST_ID_03', + code: 'TEST_CODE_03', + type: 'FreeText', + }, + { + id: 'TEST_ID_04', + code: 'TEST_CODE_04', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + { + id: 'TEST_ID_05', + code: 'TEST_CODE_05', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + ], + }; + + await Promise.all( + survey.questions.map(q => { + return upsertDummyRecord(models.question, q); + }), + ); + + return buildAndInsertSurvey(models, survey); +}; + +const buildSurveyResponse = async (models, surveyCode, answers) => { + const surveyResponse = { + date: '2024-07-20', + entityCode, + surveyCode, + answers, + id: generateId(), + timezone: 'Pacific/Auckland', + }; + + const surveyResponses = await buildAndInsertSurveyResponses(models, [surveyResponse]); + return surveyResponses[0]; +}; + +const TEST_DATA = [ + [ + 'Sets task values based on configured question values', + { + config: { + task: { + surveyCode: taskSurveyCode, + entityId: { questionId: 'TEST_ID_00' }, + shouldCreateTask: { questionId: 'TEST_ID_01' }, + dueDate: { questionId: 'TEST_ID_02' }, + assignee: { questionId: 'TEST_ID_03' }, + }, + }, + answers: { + TEST_CODE_00: entityId, + TEST_CODE_01: 'Yes', + // answers come in iso format + TEST_CODE_02: new Date('2024-06-06 00:00:00+12:00').toISOString(), + TEST_CODE_03: userId, + }, + }, + { + survey_id: taskSurveyId, + // due date will be set to the last second of the day in the survey response timezone and converted to a timestamp + due_date: new Date('2024-06-06 23:59:59+12:00').getTime(), + assignee_id: userId, + entity_id: entityId, + }, + ], + [ + 'Handles optional and missing values', + { + config: { + task: { + shouldCreateTask: { questionId: 'TEST_ID_01' }, + surveyCode: taskSurveyCode, + entityId: { questionId: 'TEST_ID_00' }, + dueDate: { questionId: 'TEST_ID_02' }, + }, + }, + answers: { + TEST_CODE_00: entityId, + TEST_CODE_01: 'Yes', + }, + }, + { entity_id: entityId, survey_id: taskSurveyId, due_date: null }, + ], +]; + +describe('TaskCreationHandler', () => { + const models = getTestModels(); + const taskCreationHandler = new TaskCreationHandler(models); + taskCreationHandler.setDebounceTime(50); // short debounce time so tests run more quickly + + beforeAll(async () => { + await buildEntity(models); + await buildAndInsertSurvey(models, { id: taskSurveyId, code: taskSurveyCode }); + await upsertDummyRecord(models.user, { id: userId }); + }); + + beforeEach(async () => { + taskCreationHandler.listenForChanges(); + }); + + afterEach(async () => { + taskCreationHandler.stopListeningForChanges(); + await models.surveyResponse.delete({ survey_id: taskSurveyId }); + }); + + it.each(TEST_DATA)('%s', async (_name, { config, answers = {} }, result) => { + const { survey } = await buildTaskCreationSurvey(models, config); + const response = await buildSurveyResponse(models, survey.code, answers); + + await models.database.waitForAllChangeHandlers(); + const tasks = await models.task.find({ entity_id: entityId }, { sort: ['created_at DESC'] }); + + const { + survey_id, + entity_id, + status, + due_date, + assignee_id, + repeat_schedule, + initial_request_id, + } = tasks[0]; + + expect({ + survey_id, + entity_id, + assignee_id, + due_date, + status, + repeat_schedule, + initial_request_id, + }).toMatchObject({ + repeat_schedule: null, + due_date: null, + status: 'to_do', + initial_request_id: response.surveyResponse.id, + ...result, + }); + }); + + it('Does not create a task if shouldCreateTask is false', async () => { + const beforeTasks = await models.task.find({ survey_id: taskSurveyId }); + const { survey } = await buildTaskCreationSurvey(models, { + task: { + shouldCreateTask: { + surveyCode: taskSurveyCode, + questionId: 'TEST_ID_01', + entityId: { questionId: 'TEST_ID_00' }, + }, + }, + }); + await buildSurveyResponse(models, survey.code, { TEST_CODE_01: 'No', TEST_CODE_00: entityId }); + await models.database.waitForAllChangeHandlers(); + const afterTasks = await models.task.find({ survey_id: taskSurveyId }); + expect(beforeTasks.length).toEqual(afterTasks.length); + }); +}); diff --git a/packages/database/src/__tests__/changeHandlers/TaskUpdateHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskUpdateHandler.test.js new file mode 100644 index 0000000000..f47cb9eb76 --- /dev/null +++ b/packages/database/src/__tests__/changeHandlers/TaskUpdateHandler.test.js @@ -0,0 +1,177 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { TaskUpdateHandler } from '../../changeHandlers'; +import { + buildAndInsertSurvey, + buildAndInsertSurveyResponses, + findOrCreateDummyRecord, + getTestModels, + upsertDummyRecord, +} from '../../testUtilities'; +import { generateId } from '../../utilities'; + +const userId = generateId(); +const initialEntityId = generateId(); +const initialEntityCode = 'TO_1'; +const newEntityId = generateId(); +const newEntityCode = 'TO_2'; +const taskSurveyId = generateId(); +const taskSurveyCode = 'TEST_TASK_SURVEY'; + +const buildEntities = async (models, data) => { + await upsertDummyRecord(models.entity, { id: initialEntityId, code: initialEntityCode, ...data }); + await upsertDummyRecord(models.entity, { id: newEntityId, code: newEntityCode, ...data }); +}; +const buildTaskCreationSurvey = async (models, config) => { + const survey = { + id: generateId(), + code: generateId(), + questions: [ + { + id: 'TEST_ID_00', + code: 'TEST_CODE_00', + type: 'PrimaryEntity', + }, + { + id: 'TEST_ID_01', + code: 'TEST_CODE_01', + type: 'Binary', + }, + { + id: 'TEST_ID_02', + code: 'TEST_CODE_02', + type: 'Date', + }, + { + id: 'TEST_ID_03', + code: 'TEST_CODE_03', + type: 'FreeText', + }, + { + id: 'TEST_ID_04', + code: 'TEST_CODE_04', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + { + id: 'TEST_ID_05', + code: 'TEST_CODE_05', + type: 'Task', + surveyScreenComponent: { + config, + }, + }, + ], + }; + + await Promise.all( + survey.questions.map(q => { + return upsertDummyRecord(models.question, q); + }), + ); + + return buildAndInsertSurvey(models, survey); +}; + +const buildSurveyResponse = async (models, surveyCode, answers) => { + const surveyResponse = { + date: '2024-07-20', + entityCode: initialEntityCode, + surveyCode, + answers, + id: generateId(), + timezone: 'Pacific/Auckland', + }; + + const surveyResponses = await buildAndInsertSurveyResponses(models, [surveyResponse]); + return surveyResponses[0]; +}; + +describe('TaskUpdateHandler', () => { + const models = getTestModels(); + const taskUpdateHandler = new TaskUpdateHandler(models); + taskUpdateHandler.setDebounceTime(50); // short debounce time so tests run more quickly + let surveyResponse; + let task; + + beforeAll(async () => { + await buildEntities(models); + await buildAndInsertSurvey(models, { id: taskSurveyId, code: taskSurveyCode }); + await upsertDummyRecord(models.user, { id: userId }); + const { survey } = await buildTaskCreationSurvey(models, { + task: { + surveyCode: taskSurveyCode, + entityId: { questionId: 'TEST_ID_00' }, + shouldCreateTask: { questionId: 'TEST_ID_01' }, + dueDate: { questionId: 'TEST_ID_02' }, + assignee: { questionId: 'TEST_ID_03' }, + }, + }); + const surveyResponseResult = await buildSurveyResponse(models, survey?.code, { + TEST_CODE_00: initialEntityId, + TEST_CODE_01: 'Yes', + // answers come in iso format + TEST_CODE_02: new Date('2024-06-06 00:00:00+12:00').toISOString(), + TEST_CODE_03: userId, + }); + surveyResponse = surveyResponseResult.surveyResponse; + task = await findOrCreateDummyRecord(models.task, { + initial_request_id: surveyResponse.id, + survey_id: taskSurveyId, + entity_id: initialEntityId, + }); + }); + + beforeEach(async () => { + taskUpdateHandler.listenForChanges(); + await models.surveyResponse.update( + { + entity_id: newEntityId, + }, + { entity_id: initialEntityId }, + ); + await models.task.update( + { + id: task?.id, + }, + { status: 'to_do', entity_id: initialEntityId }, + ); + }); + + afterEach(async () => { + taskUpdateHandler.stopListeningForChanges(); + }); + + it('Updates a task entity_id when a survey response is updated with a new entity_id', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { entity_id: newEntityId }); + await models.database.waitForAllChangeHandlers(); + + const afterTask = await models.task.findOne({ initial_request_id: surveyResponse.id }); + expect(afterTask.entity_id).toEqual(newEntityId); + }); + + it('Does not update a task entity id if the task is already completed', async () => { + await models.task.update( + { + initial_request_id: surveyResponse.id, + }, + { status: 'completed' }, + ); + await models.surveyResponse.update( + { + entity_id: initialEntityId, + }, + { + entity_id: newEntityId, + }, + ); + await models.database.waitForAllChangeHandlers(); + const afterTask = await models.task.findOne({ initial_request_id: surveyResponse.id }); + expect(afterTask.entity_id).toEqual(initialEntityId); + }); +}); diff --git a/packages/database/src/__tests__/modelClasses/Task.test.js b/packages/database/src/__tests__/modelClasses/Task.test.js new file mode 100644 index 0000000000..41eb4936e3 --- /dev/null +++ b/packages/database/src/__tests__/modelClasses/Task.test.js @@ -0,0 +1,227 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { + upsertDummyRecord, + getTestModels, + findOrCreateDummyRecord, + findOrCreateDummyCountryEntity, +} from '../../testUtilities'; +import { QUERY_CONJUNCTIONS } from '../../TupaiaDatabase'; +import { generateId } from '../../utilities'; + +const resetTestData = async (models, tasks) => { + await models.task.delete({ id: tasks.map(task => task.id) }); + await models.survey.delete({ code: ['PUBLIC_SURVEY', 'ADMIN_SURVEY'] }); + await models.entity.delete({ code: ['TC1', 'TC2', 'TF1', 'TF2', 'TEST_PROJECT'] }); + await models.country.delete({ code: ['TC1', 'TC2'] }); + await models.project.delete({ code: 'TEST_PROJECT' }); + await models.permissionGroup.delete({ name: ['Admin', 'Public'] }); +}; + +describe('TaskModel', () => { + const models = getTestModels(); + let tasks; + + beforeAll(async () => { + const adminPermissionGroup = await upsertDummyRecord(models.permissionGroup, { + name: 'Admin', + parent_id: null, + }); + + const publicPermissionGroup = await upsertDummyRecord(models.permissionGroup, { + name: 'Public', + parent_id: adminPermissionGroup.id, + }); + + const projectEntity = await findOrCreateDummyRecord(models.entity, { + code: 'TEST_PROJECT', + type: 'project', + name: 'Test Project', + }); + + const project = await upsertDummyRecord(models.project, { + code: 'TEST_PROJECT', + entity_id: projectEntity.id, + permission_groups: [adminPermissionGroup.id, publicPermissionGroup.id], + }); + + const { entity: countryEntity1 } = await findOrCreateDummyCountryEntity(models, { + code: 'TC1', + name: 'Test Country 1', + parent_id: projectEntity.id, + }); + + const { entity: countryEntity2 } = await findOrCreateDummyCountryEntity(models, { + code: 'TC2', + name: 'Test Country 2', + parent_id: projectEntity.id, + }); + + const facilities = [ + { + id: generateId(), + code: 'TF1', + name: 'TEST_FACILITY_1', + parent_id: countryEntity1.id, + country_code: countryEntity1.code, + }, + { + id: generateId(), + code: 'TF2', + name: 'TEST_FACILITY_2', + parent_id: countryEntity2.id, + country_code: countryEntity2.code, + }, + ]; + + await Promise.all(facilities.map(facility => upsertDummyRecord(models.entity, facility))); + + const SURVEYS = [ + { + id: generateId(), + code: 'PUBLIC_SURVEY', + name: 'PUBLIC_SURVEY', + project_id: project.id, + permission_group_id: publicPermissionGroup.id, + country_ids: [countryEntity1.id, countryEntity2.id], + }, + { + id: generateId(), + code: 'ADMIN_SURVEY', + name: 'ADMIN_SURVEY', + project_id: project.id, + permission_group_id: adminPermissionGroup.id, + country_ids: [countryEntity1.id], + }, + ]; + + await Promise.all(SURVEYS.map(survey => upsertDummyRecord(models.survey, survey))); + + const dueDate = new Date('2020-01-01 00:00:00+00').getTime(); + + const TASKS = [ + { + id: generateId(), + survey_id: SURVEYS[0].id, + status: 'to_do', + entity_id: facilities[0].id, + due_date: dueDate, + }, + { + id: generateId(), + survey_id: SURVEYS[1].id, + status: 'to_do', + entity_id: facilities[1].id, + due_date: dueDate, + }, + { + id: generateId(), + survey_id: SURVEYS[0].id, + status: 'to_do', + entity_id: facilities[1].id, + due_date: dueDate, + }, + ]; + + tasks = await Promise.all(TASKS.map(task => upsertDummyRecord(models.task, task))); + }); + + afterAll(async () => { + await resetTestData(models, tasks); + }); + + describe('createAccessPolicyQueryClause', () => { + it('Should handle when user has access to only 1 country in a survey', async () => { + const query = await models.task.createAccessPolicyQueryClause({ + Public: ['TC1'], + getPermissionGroups: () => ['Public'], + getEntitiesAllowed: () => ['TC1'], + }); + const results = await models.task.find({ + [QUERY_CONJUNCTIONS.RAW]: query, + }); + + expect(results).toHaveLength(1); + + expect(results[0].id).toEqual(tasks[0].id); + }); + + it('Should handle when user has access to all countries in a survey', async () => { + const query = await models.task.createAccessPolicyQueryClause({ + Public: ['TC1', 'TC2'], + getPermissionGroups: () => ['Public'], + getEntitiesAllowed: () => ['TC1', 'TC2'], + }); + const results = await models.task.find({ + [QUERY_CONJUNCTIONS.RAW]: query, + }); + + expect(results).toHaveLength(2); + const ids = results.map(r => r.id); + expect(ids).toContain(tasks[0].id); + expect(ids).toContain(tasks[2].id); + }); + }); + + describe('countTasksForAccessPolicy', () => { + it('Should handle when user has access to only 1 country in a survey', async () => { + const result = await models.task.countTasksForAccessPolicy( + { + Public: ['TC1'], + getPermissionGroups: () => ['Public'], + getEntitiesAllowed: () => ['TC1'], + allowsSome: () => false, + }, + { + status: 'to_do', + }, + { + multiJoin: models.task.DatabaseRecordClass.joins, + }, + ); + + expect(result).toEqual(1); + }); + + it('Should handle when user has access to all countries in a survey', async () => { + const result = await models.task.countTasksForAccessPolicy( + { + Public: ['TC1', 'TC2'], + getPermissionGroups: () => ['Public'], + getEntitiesAllowed: () => ['TC1', 'TC2'], + allowsSome: () => false, + }, + { + status: 'to_do', + }, + { + multiJoin: models.task.DatabaseRecordClass.joins, + }, + ); + + expect(result).toEqual(2); + }); + + it('Should handle when user has BES admin access', async () => { + const result = await models.task.countTasksForAccessPolicy( + { + Public: ['TC1', 'TC2'], + getPermissionGroups: () => ['Public'], + getEntitiesAllowed: () => ['TC1', 'TC2'], + allowsSome: () => true, + }, + { + status: 'to_do', + }, + { + multiJoin: models.task.DatabaseRecordClass.joins, + }, + ); + + expect(result).toEqual(3); + }); + }); +}); diff --git a/packages/database/src/changeHandlers/ChangeHandler.js b/packages/database/src/changeHandlers/ChangeHandler.js index 5a2ad09786..f29b9182ca 100644 --- a/packages/database/src/changeHandlers/ChangeHandler.js +++ b/packages/database/src/changeHandlers/ChangeHandler.js @@ -7,6 +7,10 @@ import winston from 'winston'; const MAX_RETRY_ATTEMPTS = 3; +/** + * @description Base class for listen to changes to records in the database. + * IMPORTANT: Make sure the table has a trigger on it, otherwise this will not work. + */ export class ChangeHandler { /** * A map of change translators by record type. Each translator can alter the change details that diff --git a/packages/database/src/changeHandlers/TaskAssigneeEmailer.js b/packages/database/src/changeHandlers/TaskAssigneeEmailer.js new file mode 100644 index 0000000000..4ac493ecf5 --- /dev/null +++ b/packages/database/src/changeHandlers/TaskAssigneeEmailer.js @@ -0,0 +1,84 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import winston from 'winston'; +import { format } from 'date-fns'; +import { sendEmail } from '@tupaia/server-utils'; +import { requireEnv } from '@tupaia/utils'; +import { ChangeHandler } from './ChangeHandler'; + +export class TaskAssigneeEmailer extends ChangeHandler { + constructor(models) { + super(models, 'task-assignee-emailer'); + + this.changeTranslators = { + task: change => this.getUpdatedTasks(change), + }; + } + + getUpdatedTasks(changeDetails) { + const { type, new_record: newRecord, old_record: oldRecord } = changeDetails; + + // only interested in updates where the assignee has changed + if ( + type !== 'update' || + !newRecord.assignee_id || + (oldRecord && oldRecord.assignee_id === newRecord.assignee_id) || + // ignore tasks that are autogenerated from completed repeating tasks + (!oldRecord && newRecord.status === 'completed') + ) { + return []; + } + + return [newRecord]; + } + + async handleChanges(models, changedTasks) { + const start = Date.now(); + // if there are no changed tasks, we don't need to do anything + if (changedTasks.length === 0) return; + + for (const task of changedTasks) { + const { + survey_id: surveyId, + assignee_id: assigneeId, + entity_id: entityId, + due_date: dueDate, + id, + } = task; + const survey = await models.survey.findById(surveyId); + + if (!survey) { + throw new Error(`Survey with id ${surveyId} not found`); + } + const assignee = await models.user.findById(assigneeId); + if (!assignee) { + throw new Error(`User with id ${assigneeId} not found`); + } + const entity = await models.entity.findById(entityId); + if (!entity) { + throw new Error(`Entity with id ${entityId} not found`); + } + + const datatrakURL = requireEnv('DATATRAK_FRONT_END_URL'); + + await sendEmail(assignee.email, { + subject: 'Tupaia DataTrak Task Assigned', + templateName: 'taskAssigned', + templateContext: { + title: 'You have been assigned a new task', + userName: assignee.first_name, + entityName: entity.name, + surveyName: survey.name, + cta: { + url: `${datatrakURL}/tasks/${id}`, + text: 'View task', + }, + }, + }); + } + const end = Date.now(); + winston.info(`Sending assignee emails completed, took: ${end - start}ms`); + } +} diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js new file mode 100644 index 0000000000..f022470daa --- /dev/null +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -0,0 +1,90 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { getUniqueEntries } from '@tupaia/utils'; +import { ChangeHandler } from './ChangeHandler'; +import { QUERY_CONJUNCTIONS } from '../TupaiaDatabase'; + +export class TaskCompletionHandler extends ChangeHandler { + constructor(models) { + super(models, 'task-completion-handler'); + + this.changeTranslators = { + surveyResponse: change => this.getNewSurveyResponses(change), + }; + } + + /** + * @private + * Only get the new survey responses that are created, as we only want to mark tasks as completed when a survey response is created, not when it is updated + */ + getNewSurveyResponses(changeDetails) { + const { type, new_record: newRecord, old_record: oldRecord } = changeDetails; + + // if the change is not a "create", we don't need to do anything. This is because once a task is marked as complete, it will never be undone + if (type !== 'update' || !!oldRecord) { + return []; + } + return [newRecord]; + } + + /** + * @private Fetches all tasks that have the same survey_id and entity_id as the survey responses, and have a created_at date that is less than or equal to the data_time of the survey response + */ + async fetchTasksForSurveyResponses(surveyResponses) { + const surveyIdAndEntityIdPairs = getUniqueEntries( + surveyResponses.map(surveyResponse => ({ + surveyId: surveyResponse.survey_id, + entityId: surveyResponse.entity_id, + endTime: surveyResponse.end_time, + })), + ); + return this.models.task.find({ + [QUERY_CONJUNCTIONS.AND]: { + status: 'to_do', + [QUERY_CONJUNCTIONS.OR]: { + status: { + comparator: 'IS', + comparisonValue: null, + }, + }, + }, + [QUERY_CONJUNCTIONS.RAW]: { + sql: `${surveyIdAndEntityIdPairs + .map(() => `(task.survey_id = ? AND task.entity_id = ? AND created_at <= ?)`) + .join(' OR ')}`, + parameters: surveyIdAndEntityIdPairs.flatMap(({ surveyId, entityId, endTime }) => [ + surveyId, + entityId, + endTime, + ]), + }, + }); + } + + async handleChanges(_transactingModels, changedResponses) { + // if there are no changed responses, we don't need to do anything + if (changedResponses.length === 0) return; + const tasksToComplete = await this.fetchTasksForSurveyResponses(changedResponses); + + // if there are no tasks to complete, we don't need to do anything + if (tasksToComplete.length === 0) return; + + for (const task of tasksToComplete) { + const { survey_id: surveyId, entity_id: entityId, created_at: createdAt } = task; + const matchingSurveyResponse = changedResponses.find( + surveyResponse => + surveyResponse.survey_id === surveyId && + surveyResponse.entity_id === entityId && + // Use the end time for the comparison so that backdated survey responses (which set data_time) will still trigger task completion + surveyResponse.end_time >= createdAt, + ); + + if (!matchingSurveyResponse) continue; + + await task.handleCompletion(matchingSurveyResponse.id, matchingSurveyResponse.user_id); + } + } +} diff --git a/packages/database/src/changeHandlers/TaskCreationHandler.js b/packages/database/src/changeHandlers/TaskCreationHandler.js new file mode 100644 index 0000000000..b6a4808daf --- /dev/null +++ b/packages/database/src/changeHandlers/TaskCreationHandler.js @@ -0,0 +1,136 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import keyBy from 'lodash.keyby'; +import { formatInTimeZone } from 'date-fns-tz'; +import { ChangeHandler } from './ChangeHandler'; + +const getAnswerWrapper = (config, answers) => { + const answersByQuestionId = keyBy(answers, 'question_id'); + return questionKey => { + const questionId = config[questionKey]?.questionId; + if (!questionId) { + return null; + } + const answer = answersByQuestionId[questionId]; + return answer?.text; + }; +}; + +const isPrimaryEntityQuestion = (config, questions) => { + const primaryEntityQuestion = questions.find(question => question.type === 'PrimaryEntity'); + const { questionId } = config.entityId; + return primaryEntityQuestion.id === questionId; +}; + +const getSurveyId = async (models, config) => { + const { surveyCode } = config; + const survey = await models.survey.findOne({ code: surveyCode }); + return survey.id; +}; + +const getQuestions = (models, surveyId) => { + return models.database.executeSql( + ` + SELECT q.*, ssc.config::json as config + FROM question q + JOIN survey_screen_component ssc ON ssc.question_id = q.id + JOIN survey_screen ss ON ss.id = ssc.screen_id + WHERE ss.survey_id = ?; + `, + [surveyId], + ); +}; + +export class TaskCreationHandler extends ChangeHandler { + constructor(models) { + super(models, 'task-creation-handler'); + + this.changeTranslators = { + surveyResponse: change => this.getNewSurveyResponses(change), + }; + } + + /** + * @private + * Only get the new survey responses that are created, as we only want to create new tasks when a survey response is created, not when it is updated + */ + getNewSurveyResponses(changeDetails) { + const { type, new_record: newRecord, old_record: oldRecord } = changeDetails; + + // if the change is not a create, we don't need to do anything. This is because once a task is marked as complete, it will never be undone + if (type !== 'update' || !!oldRecord) { + return []; + } + return [newRecord]; + } + + async handleChanges(models, changedResponses) { + // if there are no changed responses, we don't need to do anything + if (changedResponses.length === 0) return; + + for (const response of changedResponses) { + const sr = await models.surveyResponse.findById(response.id); + const { timezone, user_id: userId } = sr; + const questions = await getQuestions(models, response.survey_id); + + const taskQuestions = questions.filter(question => question.type === 'Task'); + + if (!taskQuestions) { + continue; + } + + const answers = await sr.getAnswers(); + + for (const taskQuestion of taskQuestions) { + const config = taskQuestion.config.task; + const getAnswer = getAnswerWrapper(config, answers); + + if ( + !config || + getAnswer('shouldCreateTask') === null || + getAnswer('shouldCreateTask') === 'No' + ) { + continue; + } + + // PrimaryEntity question is a special case, where the entity_id is saved against the survey + // response directly rather than the answers + const entityId = isPrimaryEntityQuestion(config, questions) + ? response.entity_id + : getAnswer('entityId'); + const surveyId = await getSurveyId(models, config); + + const dueDateAnswer = getAnswer('dueDate'); + + let dueDate = null; + if (dueDateAnswer) { + // Convert the due date to the timezone of the survey response and set the time to the last second of the day + const dateInTimezone = formatInTimeZone( + dueDateAnswer, + timezone, + "yyyy-MM-dd'T23:59:59'XXX", + ); + + // Convert the date to a timestamp + const timestamp = new Date(dateInTimezone).getTime(); + + dueDate = timestamp; + } + + await models.task.create( + { + initial_request_id: response.id, + survey_id: surveyId, + entity_id: entityId, + assignee_id: getAnswer('assignee'), + due_date: dueDate, + status: 'to_do', + }, + userId, + ); + } + } + } +} diff --git a/packages/database/src/changeHandlers/TaskUpdateHandler.js b/packages/database/src/changeHandlers/TaskUpdateHandler.js new file mode 100644 index 0000000000..99bcf87805 --- /dev/null +++ b/packages/database/src/changeHandlers/TaskUpdateHandler.js @@ -0,0 +1,52 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { ChangeHandler } from './ChangeHandler'; + +/** + * Handles updating a task entity_id when a survey response is updated with a new entity_id + */ +export class TaskUpdateHandler extends ChangeHandler { + constructor(models) { + super(models, 'task-update-handler'); + + this.changeTranslators = { + surveyResponse: change => this.getUpdatedSurveyResponses(change), + }; + } + + /** + * @private + * Only get the new survey responses that are created, as we only want to create new tasks when a survey response is created, not when it is updated + */ + getUpdatedSurveyResponses(changeDetails) { + const { type, new_record: newRecord, old_record: oldRecord } = changeDetails; + + // if the change is not a create, we don't need to do anything. This is because once a task is marked as complete, it will never be undone + if (type !== 'update' || !oldRecord || oldRecord.entity_id === newRecord.entity_id) { + return []; + } + return [newRecord]; + } + + async handleChanges(models, changedResponses) { + // if there are no changed responses, we don't need to do anything + if (changedResponses.length === 0) return; + + for (const response of changedResponses) { + const sr = await models.surveyResponse.findById(response.id); + const { entity_id: entityId, id } = sr; + + const task = await models.task.findOne({ initial_request_id: id }); + + // if there is no task, entity is the same as on the task, or the task is already completed, we don't need to do anything + if (!task || task.entity_id === entityId || task.status === 'completed') { + continue; + } + + // update the task with the new entity_id + await models.task.updateById(task.id, { entity_id: entityId }); + } + } +} diff --git a/packages/database/src/changeHandlers/index.js b/packages/database/src/changeHandlers/index.js index f364f0a0d9..a41b4cdd22 100644 --- a/packages/database/src/changeHandlers/index.js +++ b/packages/database/src/changeHandlers/index.js @@ -7,3 +7,7 @@ export { AnalyticsRefresher } from './AnalyticsRefresher'; export { ChangeHandler } from './ChangeHandler'; export { EntityHierarchyCacher } from './entityHierarchyCacher'; export { SurveyResponseOutdater } from './surveyResponseOutdater'; +export { TaskCompletionHandler } from './TaskCompletionHandler'; +export { TaskCreationHandler } from './TaskCreationHandler'; +export { TaskAssigneeEmailer } from './TaskAssigneeEmailer'; +export { TaskUpdateHandler } from './TaskUpdateHandler'; diff --git a/packages/database/src/configureEnv.js b/packages/database/src/configureEnv.js index 1dc2d2efae..8b654a66b9 100644 --- a/packages/database/src/configureEnv.js +++ b/packages/database/src/configureEnv.js @@ -11,6 +11,7 @@ export const configureEnv = () => { path: [ path.resolve(__dirname, '../../../env/db.env'), path.resolve(__dirname, '../../../env/pg.env'), + path.resolve(__dirname, '../../../env/platform.env'), ], }); }; diff --git a/packages/database/src/getDbMigrator.js b/packages/database/src/getDbMigrator.js index 2460309466..0e916fdd3c 100644 --- a/packages/database/src/getDbMigrator.js +++ b/packages/database/src/getDbMigrator.js @@ -35,6 +35,10 @@ const appCallback = async (migrator, internals, callback, error) => { const { driver } = migrator; await runPostMigration(driver); + // This needs to be called, otherwise the process will hang + if (callback) { + callback(); + } }; export const getDbMigrator = (forCli = false) => diff --git a/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js b/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js new file mode 100644 index 0000000000..d5d874547d --- /dev/null +++ b/packages/database/src/migrations/20240707213310-AddCreatedAtToTasks-modifies-schema.js @@ -0,0 +1,30 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db.runSql(` + ALTER TABLE task + ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now(); + `); +}; + +exports.down = function (db) { + return db.runSql('ALTER TABLE task DROP COLUMN created_at;'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240715234341-AddTaskSurveyResponseId-modifies-schema.js b/packages/database/src/migrations/20240715234341-AddTaskSurveyResponseId-modifies-schema.js new file mode 100644 index 0000000000..4360c0c9f8 --- /dev/null +++ b/packages/database/src/migrations/20240715234341-AddTaskSurveyResponseId-modifies-schema.js @@ -0,0 +1,43 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + await db.addColumn('task', 'survey_response_id', { + type: 'text', + foreignKey: { + name: 'task_survey_response_id_fk', + table: 'survey_response', + mapping: 'id', + // Don't cascade delete, as we want to keep the task even if the survey response is deleted, just set as null + rules: { + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + ifNotExists: true, + }); + return db.runSql(` + CREATE INDEX task_survey_response_id_idx ON task USING btree (survey_response_id); + `); +}; + +exports.down = function (db) { + return db.removeColumn('task', 'survey_response_id'); +}; + +exports._meta = { + version: 1, +}; 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/migrations/20240724001022-AddTaskAndUserQuestionTypes-modifies-schema.js b/packages/database/src/migrations/20240724001022-AddTaskAndUserQuestionTypes-modifies-schema.js new file mode 100644 index 0000000000..810488cf50 --- /dev/null +++ b/packages/database/src/migrations/20240724001022-AddTaskAndUserQuestionTypes-modifies-schema.js @@ -0,0 +1,30 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db.runSql(` + ALTER TYPE question_type ADD VALUE IF NOT EXISTS 'Task'; + ALTER TYPE question_type ADD VALUE IF NOT EXISTS 'User'; + `); +}; + +exports.down = function (db) { + return null; +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240730032928-AddUserEntityPermissionsToSyncQueue-modifies-data.js b/packages/database/src/migrations/20240730032928-AddUserEntityPermissionsToSyncQueue-modifies-data.js new file mode 100644 index 0000000000..9b987330cb --- /dev/null +++ b/packages/database/src/migrations/20240730032928-AddUserEntityPermissionsToSyncQueue-modifies-data.js @@ -0,0 +1,53 @@ +'use strict'; + +import { getSyncQueueChangeTime } from '@tupaia/tsutils'; +import { generateId } from '../utilities/generateId'; + +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; +}; + +// Get the IDs of all user entity permissions that are not already in the sync queue +const getAllUserEntityPermissionIds = async db => { + const result = await db.runSql(` + SELECT user_entity_permission.id FROM user_entity_permission + LEFT JOIN meditrak_sync_queue on meditrak_sync_queue.record_id = user_entity_permission.id + WHERE meditrak_sync_queue.id IS NULL + `); + return result.rows.map(row => row.id); +}; + +exports.up = async function (db) { + const userEntityPermissionIds = await getAllUserEntityPermissionIds(db); + await db.runSql(` + INSERT INTO meditrak_sync_queue (id, type, record_type, record_id, change_time) + VALUES ${userEntityPermissionIds + .map( + (id, i) => + // the timestamp is incremented by i to ensure that each record has a unique timestamp + `('${generateId()}', 'update', 'user_entity_permission', '${id}', ${getSyncQueueChangeTime( + i, + )})`, + ) + .join(',\n')}; + + `); +}; + +exports.down = function (db) { + return null; +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240730033844-AddUsersToSyncQueue-modifies-data.js b/packages/database/src/migrations/20240730033844-AddUsersToSyncQueue-modifies-data.js new file mode 100644 index 0000000000..11713eb169 --- /dev/null +++ b/packages/database/src/migrations/20240730033844-AddUsersToSyncQueue-modifies-data.js @@ -0,0 +1,54 @@ +'use strict'; + +import { getSyncQueueChangeTime } from '@tupaia/tsutils'; +import { generateId } from '../utilities/generateId'; + +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; +}; + +// Get the IDs of all user entity permissions that are not already in the sync queue +const getAllUserIds = async db => { + const result = await db.runSql(` + SELECT user_account.id FROM user_account + LEFT JOIN meditrak_sync_queue on meditrak_sync_queue.record_id = user_account.id + WHERE meditrak_sync_queue.id IS NULL + `); + return result.rows.map(row => row.id); +}; + +exports.up = async function (db) { + const userIds = await getAllUserIds(db); + if (userIds.length === 0) { + return; + } + await db.runSql(` + INSERT INTO meditrak_sync_queue (id, type, record_type, record_id, change_time) + VALUES ${userIds + .map( + (id, i) => + // the timestamp is incremented by i to ensure that each record has a unique timestamp + `('${generateId()}', 'update', 'user_account', '${id}', ${getSyncQueueChangeTime(i)})`, + ) + .join(',\n')}; + + `); +}; + +exports.down = function (db) { + return null; +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240806015831-AddTaskInitialRequestId-modifies-schema.js b/packages/database/src/migrations/20240806015831-AddTaskInitialRequestId-modifies-schema.js new file mode 100644 index 0000000000..51cf48ebd9 --- /dev/null +++ b/packages/database/src/migrations/20240806015831-AddTaskInitialRequestId-modifies-schema.js @@ -0,0 +1,43 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + await db.addColumn('task', 'initial_request_id', { + type: 'text', + foreignKey: { + name: 'task_initial_request_id_fk', + table: 'survey_response', + mapping: 'id', + // Don't cascade delete, as we want to keep the task even if the survey response is deleted, just set as null + rules: { + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + ifNotExists: true, + }); + return db.runSql(` + CREATE INDEX IF NOT EXISTS task_initial_request_id_fk ON task USING btree (survey_response_id); + `); +}; + +exports.down = function (db) { + return db.removeColumn('task', 'initial_request_id'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240809001011-ChangeTaskDueDateToUnix-modifies-schema.js b/packages/database/src/migrations/20240809001011-ChangeTaskDueDateToUnix-modifies-schema.js new file mode 100644 index 0000000000..6e06815620 --- /dev/null +++ b/packages/database/src/migrations/20240809001011-ChangeTaskDueDateToUnix-modifies-schema.js @@ -0,0 +1,51 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + return db.runSql(` + ALTER TABLE task + RENAME COLUMN due_date TO old_due_date; + + ALTER TABLE task + ADD COLUMN due_date DOUBLE PRECISION; + + UPDATE task + SET due_date = (EXTRACT(EPOCH FROM old_due_date) * 1000)::DOUBLE PRECISION; + + ALTER TABLE task + DROP COLUMN old_due_date; + `); +}; + +exports.down = function (db) { + return db.runSql(` + ALTER TABLE task + RENAME COLUMN due_date TO old_due_date; + + ALTER TABLE task + ADD COLUMN due_date TIMESTAMP WITH TIME ZONE; + + UPDATE task + SET due_date = to_timestamp(CAST(old_due_date AS DOUBLE PRECISION) / 1000); + + ALTER TABLE task + DROP COLUMN old_due_date; + `); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240813211928-AddParentTaskColumn-modifies-schema.js b/packages/database/src/migrations/20240813211928-AddParentTaskColumn-modifies-schema.js new file mode 100644 index 0000000000..66f77d6611 --- /dev/null +++ b/packages/database/src/migrations/20240813211928-AddParentTaskColumn-modifies-schema.js @@ -0,0 +1,45 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + await db.addColumn('task', 'parent_task_id', { + type: 'text', + foreignKey: { + name: 'task_parent_task_id_fk', + table: 'task', + mapping: 'id', + // Don't cascade delete, as we want to keep the task even if the parent task is deleted, just set as null + rules: { + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + ifNotExists: true, + }); + return db.runSql(` + CREATE INDEX IF NOT EXISTS task_parent_task_id_fk ON task USING btree (parent_task_id); + `); +}; + +exports.down = function (db) { + return db.removeColumn('task', 'parent_task_id', { + ifExists: true, + }); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240815035734-AddOverdueEmailSentColumn-modifies-schema.js b/packages/database/src/migrations/20240815035734-AddOverdueEmailSentColumn-modifies-schema.js new file mode 100644 index 0000000000..b82ca7d902 --- /dev/null +++ b/packages/database/src/migrations/20240815035734-AddOverdueEmailSentColumn-modifies-schema.js @@ -0,0 +1,20 @@ +'use strict'; +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.up = async function (db) { + await db.addColumn('task', 'overdue_email_sent', { + type: 'timestamp with time zone', + }); +}; + +exports.down = function (db) { + return db.removeColumn('task', 'overdue_email_sent', { + ifExists: true, + }); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20240821224328-AddTaskCommentTemplateVariableColumn-modifies-schema.js b/packages/database/src/migrations/20240821224328-AddTaskCommentTemplateVariableColumn-modifies-schema.js new file mode 100644 index 0000000000..c54b5954e7 --- /dev/null +++ b/packages/database/src/migrations/20240821224328-AddTaskCommentTemplateVariableColumn-modifies-schema.js @@ -0,0 +1,38 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + // Add a new column to the task_comment_template table + await db.addColumn('task_comment', 'template_variables', { + type: 'jsonb', + notNull: true, + defaultValue: '{}', + }); + + // Allow null values for message + return db.runSql(` + ALTER TABLE task_comment + ALTER COLUMN message DROP NOT NULL; + `); +}; + +exports.down = function (db) { + return db.removeColumn('task_comment', 'template_variables'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/Entity.js b/packages/database/src/modelClasses/Entity.js index 5363ecaf46..747cc7a125 100644 --- a/packages/database/src/modelClasses/Entity.js +++ b/packages/database/src/modelClasses/Entity.js @@ -183,8 +183,8 @@ export class EntityRecord extends DatabaseRecord { return this.model.getAncestorsOfEntities(hierarchyId, [this.id], criteria); } - async getDescendants(hierarchyId, criteria) { - return this.model.getDescendantsOfEntities(hierarchyId, [this.id], criteria); + async getDescendants(hierarchyId, criteria, options) { + return this.model.getDescendantsOfEntities(hierarchyId, [this.id], criteria, options); } async getRelatives(hierarchyId, criteria) { @@ -442,9 +442,10 @@ export class EntityModel extends MaterializedViewLogDatabaseModel { * @param {*} ancestorsOrDescendants * @param {*} entityIds * @param {*} criteria + * @param {*} options * @returns {Promise} */ - async getRelationsOfEntities(ancestorsOrDescendants, entityIds, criteria) { + async getRelationsOfEntities(ancestorsOrDescendants, entityIds, criteria, options) { const cacheKey = this.getCacheKey(this.getRelationsOfEntities.name, arguments); const [joinTablesOn, filterByEntityId] = ancestorsOrDescendants === ENTITY_RELATION_TYPE.ANCESTORS @@ -461,6 +462,7 @@ export class EntityModel extends MaterializedViewLogDatabaseModel { joinWith: RECORDS.ANCESTOR_DESCENDANT_RELATION, joinCondition: ['entity.id', joinTablesOn], sort: ['generational_distance ASC'], + ...options, }, ); const relationData = await Promise.all(relations.map(async r => r.getData())); @@ -477,11 +479,16 @@ export class EntityModel extends MaterializedViewLogDatabaseModel { }); } - async getDescendantsOfEntities(hierarchyId, entityIds, criteria) { - return this.getRelationsOfEntities(ENTITY_RELATION_TYPE.DESCENDANTS, entityIds, { - entity_hierarchy_id: hierarchyId, - ...criteria, - }); + async getDescendantsOfEntities(hierarchyId, entityIds, criteria, options) { + return this.getRelationsOfEntities( + ENTITY_RELATION_TYPE.DESCENDANTS, + entityIds, + { + entity_hierarchy_id: hierarchyId, + ...criteria, + }, + options, + ); } async getRelativesOfEntities(hierarchyId, entityIds, criteria) { diff --git a/packages/database/src/modelClasses/PermissionGroup.js b/packages/database/src/modelClasses/PermissionGroup.js index ae542f1588..033589a32f 100644 --- a/packages/database/src/modelClasses/PermissionGroup.js +++ b/packages/database/src/modelClasses/PermissionGroup.js @@ -34,6 +34,17 @@ export class PermissionGroupRecord extends DatabaseRecord { permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), ); } + + async getAncestors() { + const permissionGroupTree = await this.model.database.findWithParents( + this.constructor.databaseRecord, + this.id, + ); + + return Promise.all( + permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), + ); + } } export class PermissionGroupModel extends DatabaseModel { diff --git a/packages/database/src/modelClasses/SurveyResponse.js b/packages/database/src/modelClasses/SurveyResponse.js index da58cce8e6..4bffa63794 100644 --- a/packages/database/src/modelClasses/SurveyResponse.js +++ b/packages/database/src/modelClasses/SurveyResponse.js @@ -25,6 +25,10 @@ const INTERNAL_EMAIL = ['@beyondessential.com.au', '@bes.au']; export class SurveyResponseRecord extends DatabaseRecord { static databaseRecord = RECORDS.SURVEY_RESPONSE; + + async getAnswers(conditions = {}) { + return this.otherModels.answer.find({ survey_response_id: this.id, ...conditions }); + } } export class SurveyResponseModel extends MaterializedViewLogDatabaseModel { diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 394b261e68..2c6b0c69db 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -3,15 +3,43 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { format } from 'date-fns'; import { DatabaseModel } from '../DatabaseModel'; import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; -import { JOIN_TYPES } from '../TupaiaDatabase'; +import { JOIN_TYPES, QUERY_CONJUNCTIONS } from '../TupaiaDatabase'; + +const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; + +/** + * @description Format the value of a field for display in a comment. This is used to make the comment more human-readable, and handles special cases like formatting dates and assignee names + * + * @param {string} field + * @param {*} value + * @param {*} models + * @returns {Promise} + */ +const formatValue = async (field, value, models) => { + if (value === null || value === undefined) return null; + if (field === 'assignee_id') { + const assignee = await models.user.findById(value); + return assignee.full_name; + } + + if (field === 'repeat_schedule') { + return value?.freq ?? null; + } + return value; +}; export class TaskRecord extends DatabaseRecord { static databaseRecord = RECORDS.TASK; + statusTypes = { + ToDo: 'to_do', + Completed: 'completed', + Cancelled: 'cancelled', + }; + static joins = [ { joinWith: RECORDS.ENTITY, @@ -28,7 +56,13 @@ export class TaskRecord extends DatabaseRecord { { joinWith: RECORDS.SURVEY, joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`], - fields: { name: 'survey_name', code: 'survey_code' }, + fields: { name: 'survey_name', code: 'survey_code', project_id: 'project_id' }, + }, + { + joinWith: RECORDS.SURVEY_RESPONSE, + joinType: JOIN_TYPES.LEFT, + joinCondition: ['survey_response_id', `${RECORDS.SURVEY_RESPONSE}.id`], + fields: { data_time: 'data_time', timezone: 'timezone' }, }, ]; @@ -43,6 +77,209 @@ export class TaskRecord extends DatabaseRecord { async survey() { return this.otherModels.survey.findById(this.survey_id); } + + hasValidRepeatSchedule() { + const repeatSchedule = this.repeat_schedule; + return ( + repeatSchedule !== null && + typeof repeatSchedule === 'object' && + Object.keys(repeatSchedule).length > 0 + ); + } + + /** + * @description Handles the completion of a task. If the task is repeating, a new task is created with the same details as the current task and marked as completed + * If the task is not repeating, the current task is marked as completed. + * + * @param {string} surveyResponseId + * @param {string | null} userId + */ + async handleCompletion(surveyResponseId, userId) { + const { + survey_id: surveyId, + entity_id: entityId, + repeat_schedule: repeatSchedule, + assignee_id: assigneeId, + due_date: dueDate, + id, + } = this; + + let commentUserId = userId; + if (!userId) { + const user = await this.models.user.findPublicUser(); + commentUserId = user.id; + } + + if (this.hasValidRepeatSchedule()) { + // Create a new task with the same details as the current task and mark as completed. + const where = { + assignee_id: assigneeId, + survey_id: surveyId, + entity_id: entityId, + repeat_schedule: repeatSchedule, + status: this.statusTypes.Completed, + survey_response_id: surveyResponseId, + due_date: dueDate, + parent_task_id: id, + }; + + // Check for an existing task so that multiple tasks aren't created for the same survey response + const existingTask = await this.model.findOne(where); + + if (existingTask) return; + const newTask = await this.model.create(where); + await newTask.addCompletedComment(commentUserId); + await this.addCompletedComment(commentUserId); + return; + } + await this.model.updateById(id, { + status: 'completed', + survey_response_id: surveyResponseId, + }); + await this.addCompletedComment(commentUserId); + } + + /** + * @description Get all comments for the task + * @returns {Promise} + */ + + async comments() { + return this.otherModels.taskComment.find({ task_id: this.id }); + } + + /** + * @description Get all user comments for the task + * @returns {Promise} + */ + async userComments() { + return this.otherModels.taskComment.find({ + task_id: this.id, + type: this.otherModels.taskComment.types.User, + }); + } + + /** + * @description Get all system comments for the task + * @returns {Promise} + */ + async systemComments() { + return this.otherModels.taskComment.find({ + task_id: this.id, + type: this.otherModels.taskComment.types.System, + }); + } + + /** + * @description Add a comment to the task. Handles linking the comment to the task and user, and setting the comment type + * + * @param {string} message + * @param {string} userId + * @param {string} type + */ + async addComment({ message, userId, type, templateVariables }) { + const user = await this.otherModels.user.findById(userId); + return this.otherModels.taskComment.create({ + task_id: this.id, + type, + user_id: userId, + user_name: user?.full_name ?? null, + message, + template_variables: templateVariables, + }); + } + + /** + * + * @param {string} message + * @param {string} userId + * + * @description Add a user comment to the task + */ + async addUserComment(message, userId) { + return this.addComment({ + message, + userId, + type: this.otherModels.taskComment.types.User, + }); + } + + /** + * + * @param {object} templateVariables + * @param {string} userId + * + * @description Add a system comment to the task + */ + async addSystemComment(templateVariables, userId) { + return this.addComment({ + userId, + type: this.otherModels.taskComment.types.System, + templateVariables, + }); + } + + /** + * + * @param {string} userId + * @param {object} templateVariables + * + * @description Add a comment when a task is updated + */ + async addUpdatedComment(userId, templateVariables) { + return this.addSystemComment( + { + type: this.otherModels.taskComment.systemCommentTypes.Update, + ...templateVariables, + }, + userId, + ); + } + + /** + * + * @param {string} userId + * + * @description Add a comment when a task is created + */ + async addCreatedComment(userId) { + return this.addSystemComment( + { + type: this.otherModels.taskComment.systemCommentTypes.Create, + }, + userId, + ); + } + + /** + * + * @param {string} userId + * + * @description Add a comment when a task is completed + */ + async addCompletedComment(userId) { + return this.addSystemComment( + { + type: this.otherModels.taskComment.systemCommentTypes.Complete, + }, + userId, + ); + } + + /** + * + * @param {string} userId + * + * @description Add a comment when a task overdue email is sent + */ + async addOverdueComment(userId) { + return this.addSystemComment( + { + type: this.otherModels.taskComment.systemCommentTypes.Overdue, + }, + userId, + ); + } } export class TaskModel extends DatabaseModel { @@ -50,7 +287,146 @@ export class TaskModel extends DatabaseModel { return TaskRecord; } + async createAccessPolicyQueryClause(accessPolicy) { + const countryCodesByPermissionGroupId = await this.getCountryCodesByPermissionGroupId( + accessPolicy, + ); + + const params = Object.entries(countryCodesByPermissionGroupId).flat().flat(); // e.g. ['permissionGroupId', 'id1', 'id2', 'Admin', 'id3'] + + return { + sql: ` + ( + ${Object.entries(countryCodesByPermissionGroupId) + .map(([_, countryCodes]) => { + return ` + ( + survey.permission_group_id = ? AND + entity.country_code IN (${countryCodes.map(() => '?').join(', ')}) + ) + `; + }) + .join(' OR ')} + ) + `, + parameters: params, + }; + } + + async getCountryCodesByPermissionGroupId(accessPolicy) { + const allPermissionGroupsNames = accessPolicy.getPermissionGroups(); + const countryCodesByPermissionGroupId = {}; + const permissionGroupNameToId = await this.otherModels.permissionGroup.findIdByField( + 'name', + allPermissionGroupsNames, + ); + for (const [permissionGroupName, permissionGroupId] of Object.entries( + permissionGroupNameToId, + )) { + const countryCodes = accessPolicy.getEntitiesAllowed(permissionGroupName); + countryCodesByPermissionGroupId[permissionGroupId] = countryCodes; + } + return countryCodesByPermissionGroupId; + } + + /** + * @description Count tasks that the user has access to + * + * @param {AccessPolicy} accessPolicy + * @param {object} dbConditions + * @param {object} customQueryOptions + */ + async countTasksForAccessPolicy(accessPolicy, dbConditions, customQueryOptions) { + // Check if the user has BES Admin access + const hasBESAdminAccess = accessPolicy.allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); + const queryClause = await this.createAccessPolicyQueryClause(accessPolicy); + + // If the user has BES Admin access, return the count of all tasks that match the conditions, otherwise return the count of tasks that match the conditions and the access policy + const queryConditions = hasBESAdminAccess + ? dbConditions + : { + [QUERY_CONJUNCTIONS.RAW]: queryClause, + ...dbConditions, + }; + + return this.count(queryConditions, { + multiJoin: customQueryOptions.multiJoin, + }); + } + + /** + * + * @param {object} fields + * @param {string} [createdBy] + * @returns Task + */ + async create(fields, createdBy) { + const task = await super.create(fields); + if (createdBy) { + await task.addCreatedComment(createdBy); + } + return task; + } + + /** + * @description Add system comments for task updates. This is used to automatically add comments when certain fields are updated, e.g. due date, assignee, etc. + * + * @param {object} originalTask + * @param {object} updatedFields + * @param {string} userId + */ + async addSystemCommentsOnUpdate(originalTask, updatedFields, userId) { + const fieldsToCreateCommentsFor = ['due_date', 'repeat_schedule', 'status', 'assignee_id']; + const comments = []; + + // Loop through the updated fields and add a comment for each one that has changed + for (const [field, newValue] of Object.entries(updatedFields)) { + // Only create comments for certain fields + if (!fieldsToCreateCommentsFor.includes(field)) continue; + const originalValue = originalTask[field]; + // If the field hasn't actually changed, don't add a comment + // If the field hasn't actually changed, don't add a comment + if (originalValue === newValue) continue; + // Don't add a comment when repeat schedule is updated and the frequency is the same + if (field === 'repeat_schedule' && originalValue?.freq === newValue?.freq) continue; + // Don't add a comment when due date is updated for repeat schedule + if (field === 'due_date' && updatedFields.repeat_schedule) continue; + + const formattedOriginalValue = await formatValue(field, originalValue, this.otherModels); + const formattedNewValue = await formatValue(field, newValue, this.otherModels); + + comments.push({ + field, + originalValue: formattedOriginalValue, + newValue: formattedNewValue, + }); + } + + if (!comments.length) return; + + await Promise.all( + comments.map(templateVariables => originalTask.addUpdatedComment(userId, templateVariables)), + ); + } + + /** + * + * @param {string} id + * @param {object} updatedFields + * @param {string} [updatedBy] + * @returns Task + */ + async updateById(id, updatedFields, updatedBy) { + const originalTask = await this.findById(id); + const updatedTaskResult = await super.updateById(id, updatedFields); + if (updatedBy) { + await this.addSystemCommentsOnUpdate(originalTask, updatedFields, updatedBy); + } + return updatedTaskResult; + } + customColumnSelectors = { + task_due_date: () => `to_timestamp(due_date/1000)`, task_status: () => `CASE WHEN status = 'cancelled' then 'cancelled' @@ -59,14 +435,14 @@ export class TaskModel extends DatabaseModel { CASE WHEN repeat_schedule IS NOT NULL THEN 'repeating' WHEN due_date IS NULL THEN 'to_do' - WHEN due_date < '${format(new Date(), 'yyyy-MM-dd')}' THEN 'overdue' + WHEN due_date < ${new Date().getTime()} THEN 'overdue' ELSE 'to_do' END ELSE 'to_do' END`, assignee_name: () => `CASE - WHEN assignee_id IS NULL THEN NULL + WHEN assignee_id IS NULL THEN 'Unassigned' WHEN assignee.last_name IS NULL THEN assignee.first_name ELSE assignee.first_name || ' ' || assignee.last_name END`, diff --git a/packages/database/src/modelClasses/TaskComment.js b/packages/database/src/modelClasses/TaskComment.js new file mode 100644 index 0000000000..49e1e2d016 --- /dev/null +++ b/packages/database/src/modelClasses/TaskComment.js @@ -0,0 +1,30 @@ +/** + * 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; + } + + types = { + System: 'system', + User: 'user', + }; + + systemCommentTypes = { + Create: 'create', + Update: 'update', + Complete: 'complete', + Overdue: 'overdue', + }; +} diff --git a/packages/database/src/modelClasses/User.js b/packages/database/src/modelClasses/User.js index 79a61e0ba4..fd87496f81 100644 --- a/packages/database/src/modelClasses/User.js +++ b/packages/database/src/modelClasses/User.js @@ -53,6 +53,14 @@ export class UserModel extends DatabaseModel { return user; } + customColumnSelectors = { + full_name: () => + `CASE + WHEN last_name IS NULL THEN first_name + ELSE first_name || ' ' || last_name + END`, + }; + emailVerifiedStatuses = { UNVERIFIED: 'unverified', VERIFIED: 'verified', 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/database/src/runPostMigration.js b/packages/database/src/runPostMigration.js index 2d77aebf2b..5594512c12 100644 --- a/packages/database/src/runPostMigration.js +++ b/packages/database/src/runPostMigration.js @@ -30,6 +30,7 @@ const TABLES_REQUIRING_TRIGGER_CREATION = [ 'survey_screen', 'survey_screen_component', 'user_entity_permission', + 'task', ]; // tables that should only have records created and deleted, and will throw an error if an update is diff --git a/packages/datatrak-web-server/examples.http b/packages/datatrak-web-server/examples.http index a30fb2dc92..3031017e47 100644 --- a/packages/datatrak-web-server/examples.http +++ b/packages/datatrak-web-server/examples.http @@ -52,3 +52,8 @@ content-type: {{contentType}} ### Get survey GET {{host}}/surveys/TAR HTTP/1.1 content-type: {{contentType}} + + +### Get survey users +GET {{host}}/users/TAR HTTP/1.1 +content-type: {{contentType}} diff --git a/packages/datatrak-web-server/package.json b/packages/datatrak-web-server/package.json index c5e62eb60a..fa9504e2f9 100644 --- a/packages/datatrak-web-server/package.json +++ b/packages/datatrak-web-server/package.json @@ -33,6 +33,7 @@ "@tupaia/types": "workspace:*", "@tupaia/utils": "workspace:*", "camelcase-keys": "^6.2.2", + "cookie": "^0.6.0", "date-fns": "^2.29.2", "date-fns-tz": "^2.0.1", "express": "^4.19.2", diff --git a/packages/datatrak-web-server/src/__tests__/TasksRoute.test.ts b/packages/datatrak-web-server/src/__tests__/TasksRoute.test.ts new file mode 100644 index 0000000000..45879f201c --- /dev/null +++ b/packages/datatrak-web-server/src/__tests__/TasksRoute.test.ts @@ -0,0 +1,203 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import { NextFunction, Request } from 'express'; +import { TasksRoute } from '../routes'; + +const mockFunc = jest.fn(() => []); + +const makeMockRequest = (overwrites: any) => { + return { + headers: { + // defaults to make the tests simpler + cookie: 'show_completed_tasks=true;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + query: {}, + models: { + task: { + customColumnSelectors: {}, + DatabaseRecordClass: { joins: null }, + countTasksForAccessPolicy: jest.fn().mockResolvedValue(null), + }, + }, + ctx: { + services: { + central: { + fetchResources: mockFunc, + getUser: () => ({ id: 'test' }), + }, + }, + }, + ...overwrites, + }; +}; + +const mockResponse: any = { + json: jest.fn(), + status: jest.fn(), +}; + +const mockNext: NextFunction = jest.fn(); + +class TestableTaskRoute extends TasksRoute { + public constructor(params: any) { + const req = makeMockRequest(params); + // @ts-ignore + super(req, mockResponse, mockNext); + } +} + +describe('TaskRoute', () => { + describe('should format filters correctly', () => { + const testData = [ + [ + 'Default filter settings', + { + headers: { + cookie: 'show_completed_tasks=true;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + }, + { + filter: {}, + }, + ], + [ + 'Partial text filter', + { + query: { + filters: [ + { + id: 'survey.name', + value: 'a', + }, + ], + }, + }, + { + filter: { 'survey.name': { comparator: 'ilike', comparisonValue: 'a%' } }, + }, + ], + [ + 'Status filter', + { + query: { + filters: [ + { + id: 'task_status', + value: 'to_do', + }, + ], + }, + }, + { + filter: { + task_status: 'to_do', + }, + }, + ], + [ + 'All completed tasks setting false', + { + headers: { + cookie: 'show_completed_tasks=false;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + }, + { + filter: { + task_status: { comparator: 'NOT IN', comparisonValue: ['completed'] }, + }, + }, + ], + [ + 'All assignee filter setting false', + { + headers: { + cookie: 'show_completed_tasks=true;show_cancelled_tasks=true;all_assignees_tasks=false', + }, + }, + { + filter: { + assignee_id: 'test', + }, + }, + ], + [ + 'All completed tasks setting false and completed status filter', + { + headers: { + cookie: 'show_completed_tasks=false;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + query: { + filters: [ + { + id: 'task_status', + value: 'completed', + }, + ], + }, + }, + { + filter: { + task_status: 'completed', + }, + }, + ], + [ + 'All completed tasks setting false and to_do status filter', + { + headers: { + cookie: 'show_completed_tasks=false;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + query: { + filters: [ + { + id: 'task_status', + value: 'to_do', + }, + ], + }, + }, + { + filter: { + task_status: 'to_do', + }, + }, + ], + [ + 'Due date filter is between start and end of day', + { + headers: { + cookie: 'show_completed_tasks=true;show_cancelled_tasks=true;all_assignees_tasks=true', + }, + query: { + filters: [ + { + id: 'due_date', + value: '2021-01-01 23:59:59.000+12:00', + }, + ], + }, + }, + { + filter: { + due_date: { + comparator: 'BETWEEN', + comparisonValue: [ + new Date('2021-01-01T00:00:00.000+12:00').getTime(), + new Date('2021-01-01T23:59:59.000+12:00').getTime(), + ], + }, + }, + }, + ], + ]; + + // @ts-ignore + it.each(testData)('%s', async (_, filters, result) => { + const route = new TestableTaskRoute(filters); + await route.buildResponse(); + expect(mockFunc).toHaveBeenCalledWith('tasks', expect.objectContaining(result)); + }); + }); +}); diff --git a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts index 335adcd217..54e8bf6d43 100644 --- a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts +++ b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts @@ -86,10 +86,21 @@ describe('processSurveyResponse', () => { screenId: 'screen2', componentNumber: 2, }, + { + questionId: 'question3', + type: QuestionType.User, + text: 'question3', + screenId: 'screen3', + componentNumber: 3, + }, ], answers: { question1: 'answer1', question2: 'answer2', + question3: { + id: 'theUserId', + name: 'theUserName', + }, }, }); @@ -106,6 +117,11 @@ describe('processSurveyResponse', () => { type: QuestionType.Number, body: 'answer2', }, + { + question_id: 'question3', + type: QuestionType.User, + body: 'theUserId', + }, ], }); }); diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index e25b76bb3a..8a0fa54053 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -6,49 +6,66 @@ import { Request } from 'express'; import { TupaiaDatabase } from '@tupaia/database'; import { - OrchestratorApiBuilder, - handleWith, attachSessionIfAvailable, - SessionSwitchingAuthHandler, forwardRequest, + handleWith, + OrchestratorApiBuilder, + SessionSwitchingAuthHandler, } from '@tupaia/server-boilerplate'; import { getEnvVarOrDefault } from '@tupaia/utils'; import { DataTrakSessionModel } from '../models'; import { - UserRoute, - UserRequest, - SurveysRoute, - SurveysRequest, - SurveyResponsesRequest, - SurveyResponsesRoute, - ProjectsRoute, - ProjectsRequest, - SurveyRequest, - SurveyRoute, - SingleEntityRequest, - SingleEntityRoute, + ActivityFeedRequest, + ActivityFeedRoute, + CreateTaskRequest, + CreateTaskRoute, + EditTaskRequest, + EditTaskRoute, + EntitiesRequest, + EntitiesRoute, EntityDescendantsRequest, EntityDescendantsRoute, + EntityAncestorsRequest, + EntityAncestorsRoute, + GenerateLoginTokenRequest, + GenerateLoginTokenRoute, + LeaderboardRequest, + LeaderboardRoute, + PermissionGroupUsersRequest, + PermissionGroupUsersRoute, ProjectRequest, ProjectRoute, - SubmitSurveyResponseRoute, - SubmitSurveyResponseRequest, + ProjectsRequest, + ProjectsRoute, RecentSurveysRequest, RecentSurveysRoute, - LeaderboardRequest, - LeaderboardRoute, - ActivityFeedRequest, - ActivityFeedRoute, - SingleSurveyResponseRoute, + SingleEntityRequest, + SingleEntityRoute, SingleSurveyResponseRequest, - EntitiesRoute, - EntitiesRequest, - GenerateLoginTokenRoute, - GenerateLoginTokenRequest, + SingleSurveyResponseRoute, + SubmitSurveyResponseRequest, + SubmitSurveyResponseRoute, + SurveyRequest, + SurveyResponsesRequest, + SurveyResponsesRoute, + SurveyRoute, + SurveysRequest, + SurveysRoute, + SurveyUsersRequest, + SurveyUsersRoute, + TaskMetricsRequest, + TaskMetricsRoute, + TaskRequest, + TaskRoute, + TasksRequest, + TasksRoute, + UserRequest, + UserRoute, ResubmitSurveyResponseRequest, ResubmitSurveyResponseRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; +import { API_CLIENT_PERMISSIONS } from '../constants'; const authHandlerProvider = (req: Request) => new SessionSwitchingAuthHandler(req); @@ -63,18 +80,14 @@ export async function createApp() { .useAttachSession(attachSessionIfAvailable) .use('*', attachAccessPolicy) .attachApiClientToContext(authHandlerProvider) - .post( - 'submitSurveyResponse', - handleWith(SubmitSurveyResponseRoute), - ) - .post( - 'resubmitSurveyResponse/:originalSurveyResponseId', - handleWith(ResubmitSurveyResponseRoute), - ) - .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) + // Get Routes .get('getUser', handleWith(UserRoute)) .get('entity/:entityCode', handleWith(SingleEntityRoute)) .get('entityDescendants', handleWith(EntityDescendantsRoute)) + .get( + 'entityAncestors/:projectCode/:rootEntityCode', + handleWith(EntityAncestorsRoute), + ) .get('entities', handleWith(EntitiesRoute)) .get('surveys', handleWith(SurveysRoute)) .get('surveyResponses', handleWith(SurveyResponsesRoute)) @@ -84,7 +97,24 @@ export async function createApp() { .get('project/:projectCode', handleWith(ProjectRoute)) .get('recentSurveys', handleWith(RecentSurveysRoute)) .get('activityFeed', handleWith(ActivityFeedRoute)) + .get('taskMetrics/:projectId', handleWith(TaskMetricsRoute)) + .get('tasks', handleWith(TasksRoute)) + .get('tasks/:taskId', handleWith(TaskRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) + .get('users/:surveyCode/:countryCode', handleWith(SurveyUsersRoute)) + .get('users/:countryCode', handleWith(PermissionGroupUsersRoute)) + // Post Routes + .post('tasks', handleWith(CreateTaskRoute)) + .put('tasks/:taskId', handleWith(EditTaskRoute)) + .post( + 'submitSurveyResponse', + handleWith(SubmitSurveyResponseRoute), + ) + .post( + 'resubmitSurveyResponse/:originalSurveyResponseId', + handleWith(ResubmitSurveyResponseRoute), + ) + .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) @@ -92,24 +122,7 @@ export async function createApp() { // Forward everything else to central server .use('*', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })); - await builder.initialiseApiClient([ - { entityCode: 'DL', permissionGroupName: 'Public' }, // Demo Land - { entityCode: 'FJ', permissionGroupName: 'Public' }, // Fiji - { entityCode: 'CK', permissionGroupName: 'Public' }, // Cook Islands - { entityCode: 'PG', permissionGroupName: 'Public' }, // Papua New Guinea - { entityCode: 'SB', permissionGroupName: 'Public' }, // Solomon Islands - { entityCode: 'TK', permissionGroupName: 'Public' }, // Tokelau - { entityCode: 'VE', permissionGroupName: 'Public' }, // Venezuela - { entityCode: 'WS', permissionGroupName: 'Public' }, // Samoa - { entityCode: 'KI', permissionGroupName: 'Public' }, // Kiribati - { entityCode: 'TO', permissionGroupName: 'Public' }, // Tonga - { entityCode: 'NG', permissionGroupName: 'Public' }, // Nigeria - { entityCode: 'VU', permissionGroupName: 'Public' }, // Vanuatu - { entityCode: 'AU', permissionGroupName: 'Public' }, // Australia - { entityCode: 'PW', permissionGroupName: 'Public' }, // Palau - { entityCode: 'NU', permissionGroupName: 'Public' }, // Niue - { entityCode: 'TV', permissionGroupName: 'Public' }, // Tuvalu - ]); + await builder.initialiseApiClient(API_CLIENT_PERMISSIONS); const app = builder.build(); diff --git a/packages/datatrak-web-server/src/constants.ts b/packages/datatrak-web-server/src/constants.ts index fdc1e10866..1c771af585 100644 --- a/packages/datatrak-web-server/src/constants.ts +++ b/packages/datatrak-web-server/src/constants.ts @@ -3,3 +3,22 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ export const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; + +export const API_CLIENT_PERMISSIONS = [ + { entityCode: 'DL', permissionGroupName: 'Public' }, // Demo Land + { entityCode: 'FJ', permissionGroupName: 'Public' }, // Fiji + { entityCode: 'CK', permissionGroupName: 'Public' }, // Cook Islands + { entityCode: 'PG', permissionGroupName: 'Public' }, // Papua New Guinea + { entityCode: 'SB', permissionGroupName: 'Public' }, // Solomon Islands + { entityCode: 'TK', permissionGroupName: 'Public' }, // Tokelau + { entityCode: 'VE', permissionGroupName: 'Public' }, // Venezuela + { entityCode: 'WS', permissionGroupName: 'Public' }, // Samoa + { entityCode: 'KI', permissionGroupName: 'Public' }, // Kiribati + { entityCode: 'TO', permissionGroupName: 'Public' }, // Tonga + { entityCode: 'NG', permissionGroupName: 'Public' }, // Nigeria + { entityCode: 'VU', permissionGroupName: 'Public' }, // Vanuatu + { entityCode: 'AU', permissionGroupName: 'Public' }, // Australia + { entityCode: 'PW', permissionGroupName: 'Public' }, // Palau + { entityCode: 'NU', permissionGroupName: 'Public' }, // Niue + { entityCode: 'TV', permissionGroupName: 'Public' }, // Tuvalu +]; diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts new file mode 100644 index 0000000000..38b847f4bc --- /dev/null +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -0,0 +1,44 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebTaskChangeRequest, TaskStatus } from '@tupaia/types'; +import { formatTaskChanges } from '../utils'; + +export type CreateTaskRequest = Request< + DatatrakWebTaskChangeRequest.Params, + DatatrakWebTaskChangeRequest.ResBody, + DatatrakWebTaskChangeRequest.ReqBody, + DatatrakWebTaskChangeRequest.ReqQuery +>; + +export class CreateTaskRoute extends Route { + public async buildResponse() { + const { models, body, ctx } = this.req; + + const { survey_code: surveyCode } = body; + + const survey = await models.survey.findOne({ code: surveyCode }); + if (!survey) { + throw new Error('Survey not found'); + } + + const taskDetails = formatTaskChanges({ + ...body, + survey_id: survey.id, + }); + + if (taskDetails.due_date) { + taskDetails.status = TaskStatus.to_do; + } + + await ctx.services.central.createResource('tasks', {}, taskDetails); + + return { + message: 'Task created successfully', + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts new file mode 100644 index 0000000000..79d3170852 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -0,0 +1,33 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { formatTaskChanges } from '../utils'; +import { DatatrakWebTaskChangeRequest, TaskCommentType } from '@tupaia/types'; + +export type EditTaskRequest = Request< + { taskId: string }, + { message: string }, + Partial, + Record +>; + +export class EditTaskRoute extends Route { + public async buildResponse() { + const { body, ctx, params, models } = this.req; + + const { taskId } = params; + const originalTask = await models.task.findById(taskId); + + const taskDetails = formatTaskChanges(body, originalTask); + + return ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); + } +} diff --git a/packages/datatrak-web-server/src/routes/EntityAncestorsRoute.ts b/packages/datatrak-web-server/src/routes/EntityAncestorsRoute.ts new file mode 100644 index 0000000000..0bbebb2a15 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/EntityAncestorsRoute.ts @@ -0,0 +1,36 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { TupaiaWebEntitiesRequest, Entity } from '@tupaia/types'; +import { camelcaseKeys } from '@tupaia/tsutils'; + +export type EntityAncestorsRequest = Request< + TupaiaWebEntitiesRequest.Params, + TupaiaWebEntitiesRequest.ResBody, + TupaiaWebEntitiesRequest.ReqBody, + TupaiaWebEntitiesRequest.ReqQuery +>; + +const DEFAULT_FIELDS = ['id', 'parent_code', 'code', 'name', 'type']; + +export class EntityAncestorsRoute extends Route { + public async buildResponse() { + const { params, query, ctx } = this.req; + const { rootEntityCode, projectCode } = params; + + const entities: Entity[] = await ctx.services.entity.getAncestorsOfEntity( + projectCode, + rootEntityCode, + { + fields: DEFAULT_FIELDS, + }, + true, + ); + + return camelcaseKeys(entities, { deep: true }); + } +} diff --git a/packages/datatrak-web-server/src/routes/EntityDescendantsRoute.ts b/packages/datatrak-web-server/src/routes/EntityDescendantsRoute.ts index 3b0744815b..d4ad4ecd80 100644 --- a/packages/datatrak-web-server/src/routes/EntityDescendantsRoute.ts +++ b/packages/datatrak-web-server/src/routes/EntityDescendantsRoute.ts @@ -63,6 +63,7 @@ export class EntityDescendantsRoute extends Route { filter: { countryCode, projectCode, grandparentId, parentId, type, ...restOfFilter }, searchString, fields = DEFAULT_FIELDS, + pageSize, } = query; if (isLoggedIn) { @@ -108,6 +109,7 @@ export class EntityDescendantsRoute extends Route { { fields, filter, + pageSize, }, false, !isLoggedIn, diff --git a/packages/datatrak-web-server/src/routes/PermissionGroupUsersRoute.ts b/packages/datatrak-web-server/src/routes/PermissionGroupUsersRoute.ts new file mode 100644 index 0000000000..0dec291671 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/PermissionGroupUsersRoute.ts @@ -0,0 +1,43 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebUsersRequest } from '@tupaia/types'; +import { getFilteredUsers } from '../utils'; + +export type PermissionGroupUsersRequest = Request< + DatatrakWebUsersRequest.Params, + DatatrakWebUsersRequest.ResBody, + DatatrakWebUsersRequest.ReqBody, + DatatrakWebUsersRequest.ReqQuery +>; + +export class PermissionGroupUsersRoute extends Route { + public async buildResponse() { + const { models, params, query } = this.req; + const { countryCode } = params; + + const { searchTerm, permissionGroupId } = query; + + if (!permissionGroupId) { + throw new Error('Permission group id is required'); + } + + // get the permission group + const permissionGroup = await models.permissionGroup.findById(permissionGroupId); + + if (!permissionGroup) { + throw new Error(`Permission group with id '${permissionGroupId}' not found`); + } + + return getFilteredUsers(models, countryCode, permissionGroup, searchTerm); + } +} diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 53e273853c..edd423a90b 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -6,10 +6,11 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; import { AccessPolicy } from '@tupaia/access-policy'; import { TUPAIA_ADMIN_PANEL_PERMISSION_GROUP } from '../constants'; import { PermissionsError } from '@tupaia/utils'; +import { getParentEntityName } from '../utils'; export type SingleSurveyResponseRequest = Request< DatatrakWebSingleSurveyResponseRequest.Params, @@ -18,7 +19,7 @@ export type SingleSurveyResponseRequest = Request< DatatrakWebSingleSurveyResponseRequest.ReqQuery >; -const ANSWER_COLUMNS = ['text', 'question_id']; +const ANSWER_COLUMNS = ['text', 'question_id', 'type']; const DEFAULT_FIELDS = [ 'assessor_name', @@ -33,11 +34,13 @@ const DEFAULT_FIELDS = [ 'country.code', 'survey.permission_group_id', 'timezone', + 'survey.project_id', ]; +type AnswerT = string | number | boolean | null | undefined | { name: string; id: string }; + const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; -// If the user is not a BES admin or does not have access to the admin panel, they should not be able to view the survey response const assertCanViewSurveyResponse = ( accessPolicy: AccessPolicy, countryCode: string, @@ -48,14 +51,9 @@ const assertCanViewSurveyResponse = ( return true; } - const hasAdminPanelAccess = accessPolicy.allowsSome( - undefined, - TUPAIA_ADMIN_PANEL_PERMISSION_GROUP, - ); - + // The user must have access to the country with the survey permission group const hasAccessToCountry = accessPolicy.allows(countryCode, surveyPermissionGroupName); - // The user must have access to the admin panel AND the country with the survey permission group - if (!hasAdminPanelAccess && !hasAccessToCountry) { + if (!hasAccessToCountry) { throw new PermissionsError('You do not have access to view this survey response'); } @@ -85,6 +83,7 @@ export class SingleSurveyResponseRoute extends Route, answer: { question_id: string; text: string }) => ({ - ...output, - [answer.question_id]: answer.text, - }), - {}, - ); + + const answers: Record = {}; + for (const answer of answerList) { + const { question_id: questionId, type, text } = answer; + if (!text) continue; + if (type === QuestionType.User) { + const user = await models.user.findById(text); + if (!user) { + // Log the error but continue to the next answer. This is in case the user was deleted + console.error(`User with id ${text} not found`); + continue; + } + answers[questionId] = { id: user.id, name: user.full_name }; + continue; + } + answers[questionId] = text; + } // Don't return the answers in camel case because the keys are question IDs which we want in lowercase - return camelcaseKeys({ ...response, userId, answers }); + return camelcaseKeys({ ...response, countryCode, entityParentName, userId, answers }); } } diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/SubmitSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/SubmitSurveyResponseRoute.ts index 367670c0ec..8db4010e68 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/SubmitSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/SubmitSurveyResponseRoute.ts @@ -5,8 +5,9 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebSubmitSurveyResponseRequest as RequestT } from '@tupaia/types'; -import { processSurveyResponse } from './processSurveyResponse'; import { addRecentEntities } from '../../utils'; +import { processSurveyResponse } from './processSurveyResponse'; +import { handleTaskCompletion } from './handleTaskCompletion'; export type SubmitSurveyResponseRequest = Request< RequestT.Params, @@ -24,8 +25,8 @@ export class SubmitSurveyResponseRoute extends Route { + const { + id: surveyResponseId, + survey_id: surveyId, + entity_id: entityId, + data_time: dataTime, + user_id: userId, + } = surveyResponse; + const tasksToComplete = await models.task.find( + // @ts-ignore - TS doesn't like the nested query + { + [QUERY_CONJUNCTIONS.AND]: { + status: 'to_do', + [QUERY_CONJUNCTIONS.OR]: { + status: { + comparator: 'IS', + comparisonValue: null, + }, + }, + }, + [QUERY_CONJUNCTIONS.RAW]: { + sql: `(task.survey_id = ? AND task.entity_id = ? AND task.created_at <= ?)`, + parameters: [surveyId, entityId, dataTime], + }, + }, + ); + + // If the survey response was successfully created, complete any tasks that are due + if (tasksToComplete.length === 0) return; + + for (const task of tasksToComplete) { + await task.handleCompletion(surveyResponseId!, userId!); + } +}; diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts index f8da4959ca..de172b7fe3 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts @@ -27,6 +27,7 @@ type CentralServerSurveyResponseT = MeditrakSurveyResponseRequest & { }; type AnswerT = DatatrakWebSubmitSurveyResponseRequest.Answer; type FileUploadAnswerT = DatatrakWebSubmitSurveyResponseRequest.FileUploadAnswer; +type UserAnswerT = DatatrakWebSubmitSurveyResponseRequest.UserAnswer; export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) => { if (!config?.entity) { @@ -206,6 +207,13 @@ export const processSurveyResponse = async ( }); break; } + case QuestionType.User: { + answersToSubmit.push({ + ...answerObject, + body: (answer as UserAnswerT).id, + }); + break; + } default: answersToSubmit.push(answerObject); break; diff --git a/packages/datatrak-web-server/src/routes/SurveyRoute.ts b/packages/datatrak-web-server/src/routes/SurveyRoute.ts index dbc8ab9714..5b36808ac4 100644 --- a/packages/datatrak-web-server/src/routes/SurveyRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveyRoute.ts @@ -6,7 +6,12 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebSurveyRequest, WebServerProjectRequest } from '@tupaia/types'; +import { + DatatrakWebSurveyRequest, + WebServerProjectRequest, + Question, + QuestionType, +} from '@tupaia/types'; import { PermissionsError } from '@tupaia/utils'; export type SurveyRequest = Request< @@ -116,6 +121,9 @@ export class SurveyRoute extends Route { .sort((a: any, b: any) => a.componentNumber - b.componentNumber), }; }) + // Hide Task questions from the survey. They are not displayed in the web app and are + // just used to trigger new tasks in the TaskCreationHandler + .filter((question: Question) => question.type !== QuestionType.Task) .sort((a: any, b: any) => a.screenNumber - b.screenNumber); // renaming survey_questions to screens to make it make more representative of what it is, since questions is more representative of the component within the screen diff --git a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts new file mode 100644 index 0000000000..40b75589f0 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts @@ -0,0 +1,46 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebUsersRequest } from '@tupaia/types'; +import { getFilteredUsers } from '../utils'; + +export type SurveyUsersRequest = Request< + DatatrakWebUsersRequest.Params, + DatatrakWebUsersRequest.ResBody, + DatatrakWebUsersRequest.ReqBody, + DatatrakWebUsersRequest.ReqQuery +>; + +export class SurveyUsersRoute extends Route { + public async buildResponse() { + const { models, params, query } = this.req; + const { surveyCode, countryCode } = params; + + const { searchTerm } = query; + + const survey = await models.survey.findOne({ code: surveyCode }); + + if (!survey) { + throw new Error(`Survey with code ${surveyCode} not found`); + } + + const { permission_group_id: permissionGroupId } = survey; + + if (!permissionGroupId) { + return []; + } + + // get the permission group + const permissionGroup = await models.permissionGroup.findById(permissionGroupId); + + if (!permissionGroup) { + throw new Error(`Permission group with id ${permissionGroupId} not found`); + } + + return getFilteredUsers(models, countryCode, permissionGroup, searchTerm); + } +} diff --git a/packages/datatrak-web-server/src/routes/SurveysRoute.ts b/packages/datatrak-web-server/src/routes/SurveysRoute.ts index 4456b66a51..46fbd2937a 100644 --- a/packages/datatrak-web-server/src/routes/SurveysRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveysRoute.ts @@ -21,10 +21,11 @@ export type SurveysRequest = Request< export class SurveysRoute extends Route { public async buildResponse() { - const { ctx, query = {} } = this.req; - const { fields = [], projectId } = query; + const { ctx, query = {}, models } = this.req; + const { fields = [], projectId, countryCode } = query; + const country = await models.country.findOne({ code: countryCode }); - const surveys = await ctx.services.central.fetchResources('surveys', { + const surveys = await ctx.services.central.fetchResources(`countries/${country.id}/surveys`, { ...query, filter: { project_id: projectId, diff --git a/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts b/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts new file mode 100644 index 0000000000..26ca778c9e --- /dev/null +++ b/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts @@ -0,0 +1,90 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { getOffsetForTimezone } from '@tupaia/utils'; +import { DatatrakWebTaskMetricsRequest, TaskStatus } from '@tupaia/types'; +import { QUERY_CONJUNCTIONS, RECORDS } from '@tupaia/database'; + +export type TaskMetricsRequest = Request< + DatatrakWebTaskMetricsRequest.Params, + DatatrakWebTaskMetricsRequest.ResBody, + DatatrakWebTaskMetricsRequest.ReqBody, + DatatrakWebTaskMetricsRequest.ReqQuery +>; + +export class TaskMetricsRoute extends Route { + public async buildResponse() { + const { params, models } = this.req; + const { projectId } = params; + const baseQuery = { 'survey.project_id': projectId }; + const baseJoin = { joinWith: RECORDS.SURVEY, joinCondition: ['survey.id', 'task.survey_id'] }; + + const unassignedTasks = await models.task.count( + { + ...baseQuery, + status: { + comparator: 'NOT IN', + comparisonValue: [TaskStatus.completed, TaskStatus.cancelled], + }, + assignee_id: { + comparator: 'IS', + comparisonValue: null, + }, + }, + baseJoin, + ); + + const overdueTasks = await models.task.count( + { + ...baseQuery, + status: { + comparator: 'NOT IN', + comparisonValue: [TaskStatus.completed, TaskStatus.cancelled], + }, + due_date: { + comparator: '<=', + comparisonValue: new Date().getTime(), + }, + }, + baseJoin, + ); + + const completedTasks = await models.task.find( + // @ts-ignore + { + ...baseQuery, + status: TaskStatus.completed, + repeat_schedule: { + comparator: 'IS', + comparisonValue: null, + }, + }, + { + columns: ['due_date', 'data_time', 'timezone', 'project_id'], + }, + ); + + const onTimeCompletedTasks = completedTasks.filter(record => { + if (!record.due_date || !record.data_time) { + return false; + } + const { data_time: dataTime, timezone } = record; + const offset = getOffsetForTimezone(timezone, new Date(dataTime)); + const formattedDate = `${dataTime.toString().replace(' ', 'T')}${offset}`; + return new Date(formattedDate).getTime() <= record.due_date; + }); + + const onTimeCompletionRate = + Math.round((onTimeCompletedTasks.length / completedTasks.length) * 100) || 0; + + return { + unassignedTasks, + overdueTasks, + onTimeCompletionRate, + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/TaskRoute.ts b/packages/datatrak-web-server/src/routes/TaskRoute.ts new file mode 100644 index 0000000000..88564de10f --- /dev/null +++ b/packages/datatrak-web-server/src/routes/TaskRoute.ts @@ -0,0 +1,60 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +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, + DatatrakWebTaskRequest.ResBody, + DatatrakWebTaskRequest.ReqBody, + DatatrakWebTaskRequest.ReqQuery +>; + +const FIELDS = [ + 'id', + 'survey.name', + 'survey.code', + 'entity.name', + 'entity.country_code', + 'entity.code', + 'assignee_name', + 'assignee_id', + 'task_status', + 'task_due_date', + 'repeat_schedule', + 'survey_id', + 'entity_id', + 'survey_response_id', + 'initial_request_id', +]; + +export class TaskRoute extends Route { + public async buildResponse() { + const { ctx, params, models } = this.req; + const { taskId } = params; + + const task: TaskT = await ctx.services.central.fetchResources(`tasks/${taskId}`, { + columns: FIELDS, + }); + if (!task) { + throw new Error(`Task with id ${taskId} not found`); + } + + const comments = await ctx.services.central.fetchResources(`tasks/${taskId}/taskComments`, { + sort: ['created_at DESC'], + }); + + const formattedTask = await formatTaskResponse(models, task); + + return { + ...formattedTask, + 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 new file mode 100644 index 0000000000..b608121c05 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/TasksRoute.ts @@ -0,0 +1,207 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { sub } from 'date-fns'; +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { parse } from 'cookie'; +import { DatatrakWebTasksRequest, TaskCommentType, TaskStatus } from '@tupaia/types'; +import { TaskT, formatTaskResponse } from '../utils'; + +export type TasksRequest = Request< + DatatrakWebTasksRequest.Params, + DatatrakWebTasksRequest.ResBody, + DatatrakWebTasksRequest.ReqBody, + DatatrakWebTasksRequest.ReqQuery +>; + +const FIELDS = [ + 'id', + 'survey.name', + 'survey.code', + 'entity.country_code', + 'entity.name', + 'entity.code', + 'assignee_name', + 'assignee_id', + 'task_status', + 'task_due_date', + 'repeat_schedule', + 'survey_id', + 'entity_id', + 'initial_request_id', +]; + +const DEFAULT_PAGE_SIZE = 20; + +type FormattedFilters = Record; + +const EQUALITY_FILTERS = ['survey.project_id', 'task_status']; + +const getFilterSettings = (cookieString: string) => { + const cookies = parse(cookieString); + return { + allAssignees: cookies['all_assignees_tasks'] === 'true', + allCompleted: cookies['show_completed_tasks'] === 'true', + allCancelled: cookies['show_cancelled_tasks'] === 'true', + }; +}; + +export class TasksRoute extends Route { + private filters: FormattedFilters = {}; + private formatFilters() { + const { query } = this.req; + const { filters = [] } = query; + + filters.forEach(({ id, value }) => { + if (value === '' || value === undefined || value === null) return; + if (id === 'due_date') { + // set the time to the end of the day to get the full range of the day, and apply milliseconds to ensure the range is inclusive + const endDateObj = new Date(value); + // subtract 23 hours, 59 minutes, 59 seconds to get the start of the day. This is because the filters always send the end of the day, and we need a range to handle the values being saved in the database as unix timestamps based on the user's timezone. + const startDate = sub(endDateObj, { hours: 23, minutes: 59, seconds: 59 }).getTime(); + const endDate = endDateObj.getTime(); + this.filters[id] = { + comparator: 'BETWEEN', + comparisonValue: [startDate, endDate], + }; + + return; + } + + if (EQUALITY_FILTERS.includes(id)) { + this.filters[id] = value; + return; + } + + if (id === 'repeat_schedule') { + this.filters[`repeat_schedule->freq`] = value; + return; + } + this.filters[id] = { + comparator: 'ilike', + comparisonValue: `${value}%`, + }; + }); + } + private async processFilterSettings() { + const cookieString = this.req.headers.cookie; + if (!cookieString) { + return; + } + const cookies = getFilterSettings(cookieString); + + if (!cookies.allAssignees) { + const { id: userId } = await this.req.ctx.services.central.getUser(); + this.filters['assignee_id'] = userId; + } + + // If the task status filter is already present, don't need to worry about allCompleted and allCancelled filters + if ('task_status' in this.filters) { + return; + } + + if (!cookies.allCompleted) { + this.filters['task_status'] = { + comparator: 'NOT IN', + comparisonValue: [TaskStatus.completed], + }; + } + + if (!cookies.allCancelled) { + this.filters['task_status'] = { + comparator: 'NOT IN', + comparisonValue: [TaskStatus.cancelled], + }; + } + + if (!cookies.allCompleted && !cookies.allCancelled) { + this.filters['task_status'] = { + comparator: 'NOT IN', + comparisonValue: [TaskStatus.completed, TaskStatus.cancelled], + }; + } + } + + private async queryForCount() { + const { models, accessPolicy } = this.req; + const filtersWithColumnSelectors = { ...this.filters }; + + // use column selectors for custom columns being used in filters + for (const [key, value] of Object.entries(this.filters)) { + if (key in models.task.customColumnSelectors) { + const colKey = + models.task.customColumnSelectors[ + key as keyof typeof models.task.customColumnSelectors + ](); + filtersWithColumnSelectors[colKey] = value; + delete filtersWithColumnSelectors[key]; + } + } + + return models.task.countTasksForAccessPolicy(accessPolicy, filtersWithColumnSelectors, { + multiJoin: models.task.DatabaseRecordClass.joins, + }); + } + + public async buildResponse() { + const { ctx, query = {}, models } = this.req; + const { pageSize = DEFAULT_PAGE_SIZE, sort, page = 0, filters } = query; + + this.formatFilters(); + await this.processFilterSettings(); + + const nonProjectFilters = filters?.filter(({ id }) => id !== 'survey.project_id') ?? []; + + const params: { + filter: FormattedFilters; + columns: string[]; + pageSize: number; + page: number; + sort?: string[]; + rawSort?: string; + } = { + filter: this.filters, + columns: FIELDS, + pageSize, + page, + }; + if (sort) { + params.sort = sort; + } else if (!sort && nonProjectFilters.length === 0) { + // If no sort or search is provided, default to sorting completed and cancelled tasks to the bottom and by due date + params.rawSort = + "CASE status WHEN 'completed' THEN 1 WHEN 'cancelled' THEN 2 ELSE 0 END ASC, due_date ASC"; + } else { + params.rawSort = 'due_date ASC'; + } + + const tasks = await ctx.services.central.fetchResources('tasks', params); + + const formattedTasks = (await Promise.all( + tasks.map(async (task: TaskT) => { + const formattedTask = await formatTaskResponse(models, 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(); + const numberOfPages = Math.ceil(count / pageSize); + + return { + tasks: formattedTasks, + count, + numberOfPages, + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/UserRoute.ts b/packages/datatrak-web-server/src/routes/UserRoute.ts index 1b87da83fd..416e32cfb1 100644 --- a/packages/datatrak-web-server/src/routes/UserRoute.ts +++ b/packages/datatrak-web-server/src/routes/UserRoute.ts @@ -27,6 +27,7 @@ export class UserRoute extends Route { const { id, + full_name: fullName, first_name: firstName, last_name: lastName, email, @@ -56,7 +57,7 @@ export class UserRoute extends Route { } return { - userName: `${firstName} ${lastName}`, + fullName, firstName, lastName, email, diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index efad4a0641..062f070954 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -10,6 +10,7 @@ export { SurveyResponsesRequest, SurveyResponsesRoute } from './SurveyResponsesR export { ProjectsRequest, ProjectsRoute } from './ProjectsRoute'; export { SingleEntityRequest, SingleEntityRoute } from './SingleEntityRoute'; export { EntityDescendantsRequest, EntityDescendantsRoute } from './EntityDescendantsRoute'; +export { EntityAncestorsRequest, EntityAncestorsRoute } from './EntityAncestorsRoute'; export { ProjectRequest, ProjectRoute } from './ProjectRoute'; export { SubmitSurveyResponseRequest, @@ -26,3 +27,13 @@ export { LeaderboardRequest, LeaderboardRoute } from './LeaderboardRoute'; export { ActivityFeedRequest, ActivityFeedRoute } from './ActivityFeedRoute'; export { EntitiesRequest, EntitiesRoute } from './EntitiesRoute'; export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; +export { TaskMetricsRequest, TaskMetricsRoute } from './TaskMetricsRoute'; +export { TasksRequest, TasksRoute } from './TasksRoute'; +export { TaskRequest, TaskRoute } from './TaskRoute'; +export { SurveyUsersRequest, SurveyUsersRoute } from './SurveyUsersRoute'; +export { CreateTaskRequest, CreateTaskRoute } from './CreateTaskRoute'; +export { EditTaskRequest, EditTaskRoute } from './EditTaskRoute'; +export { + PermissionGroupUsersRequest, + PermissionGroupUsersRoute, +} from './PermissionGroupUsersRoute'; diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index 6c974ba1a6..197e8922d5 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -10,8 +10,12 @@ import { OneTimeLoginModel, OptionModel, PermissionGroupModel, + ProjectModel, SurveyModel, SurveyResponseModel, + TaskCommentModel, + TaskModel, + UserEntityPermissionModel, UserModel, } from '@tupaia/server-boilerplate'; @@ -24,5 +28,9 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly surveyResponse: SurveyResponseModel; readonly oneTimeLogin: OneTimeLoginModel; readonly option: OptionModel; + readonly task: TaskModel; + readonly userEntityPermission: UserEntityPermissionModel; + readonly taskComment: TaskCommentModel; + readonly project: ProjectModel; readonly permissionGroup: PermissionGroupModel; } diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts new file mode 100644 index 0000000000..27b2130505 --- /dev/null +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -0,0 +1,59 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { isNotNullish } from '@tupaia/tsutils'; +import { DatatrakWebTaskChangeRequest, Task } from '@tupaia/types'; +import { generateRRule } from '@tupaia/utils'; + +type Input = Partial & + Partial>; + +type Output = Partial & { + comment?: string; +}; + +export const formatTaskChanges = (task: Input, originalTask?: Task) => { + const { due_date: dueDate, repeat_frequency: frequency, assignee, ...restOfTask } = task; + + const taskDetails: Output = restOfTask; + + if ( + isNotNullish(frequency) || + (originalTask?.repeat_schedule && frequency === undefined && dueDate) + ) { + // if there is no due date to use, use the original task's due date (this will be the case when editing a task's repeat schedule without changing the due date) + const dueDateToUse = dueDate || originalTask?.due_date; + + // if there is no due date to use, throw an error - this should never happen but is a safety check + if (!dueDateToUse) { + throw new Error('Must have a due date'); + } + + // if frequency is explicitly set, use that, otherwise use the original task's frequency. This is for when editing a repeating task's due date, because we will want to update the 'dtstart' of the rrule + const frequencyToUse = frequency ?? originalTask?.repeat_schedule?.freq; + // if task is repeating, generate rrule + const rrule = generateRRule(dueDateToUse, frequencyToUse); + // set repeat_schedule to the original options object so we can use it to generate next occurrences and display the schedule + taskDetails.repeat_schedule = rrule.origOptions; + } + + // if frequency is explicitly set to null, set repeat_schedule to null + if (frequency === null) { + taskDetails.repeat_schedule = null; + } + + // if there is a due date, convert it to unix + if (dueDate) { + const unix = new Date(dueDate).getTime(); + + taskDetails.due_date = unix; + } + + if (assignee !== undefined) { + taskDetails.assignee_id = assignee?.value ?? null; + } + + return taskDetails; +}; diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts new file mode 100644 index 0000000000..58c1d12297 --- /dev/null +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -0,0 +1,75 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Country, DatatrakWebTasksRequest, Entity, Survey, Task, TaskStatus } from '@tupaia/types'; +import camelcaseKeys from 'camelcase-keys'; +import { DatatrakWebServerModelRegistry } from '../types'; +import { getParentEntityName } from './getParentEntityName'; + +export type TaskT = Omit & { + 'entity.name': Entity['name']; + 'entity.code': Entity['code']; + 'entity.country_code': Country['code']; + 'survey.code': Survey['code']; + 'survey.name': Survey['name']; + task_status: TaskStatus | 'overdue' | 'repeating'; + repeat_schedule?: Record | null; + task_due_date: Date | null; + assignee_name?: string | null; +}; + +type FormattedTask = DatatrakWebTasksRequest.TaskResponse; + +export const formatTaskResponse = async ( + models: DatatrakWebServerModelRegistry, + task: TaskT, +): Promise => { + const { + entity_id: entityId, + 'entity.name': entityName, + 'entity.code': entityCode, + 'entity.country_code': entityCountryCode, + 'survey.code': surveyCode, + survey_id: surveyId, + 'survey.name': surveyName, + task_status: taskStatus, + repeat_schedule: repeatSchedule, + assignee_id: assigneeId, + assignee_name: assigneeName, + ...rest + } = task; + + const { survey_id } = task; + + const { project_id } = await models.survey.findById(survey_id); + + const parentName = await getParentEntityName(models, project_id, entityId); + + const formattedTask = { + ...rest, + assignee: { + id: assigneeId, + name: assigneeName, + }, + entity: { + id: entityId, + name: entityName, + code: entityCode, + countryCode: entityCountryCode, + parentName, + }, + survey: { + id: surveyId, + name: surveyName, + code: surveyCode, + }, + taskStatus, + repeatSchedule, + }; + + return camelcaseKeys(formattedTask, { + deep: true, + }); +}; diff --git a/packages/datatrak-web-server/src/utils/getFilteredUsers.ts b/packages/datatrak-web-server/src/utils/getFilteredUsers.ts new file mode 100644 index 0000000000..53d086c84a --- /dev/null +++ b/packages/datatrak-web-server/src/utils/getFilteredUsers.ts @@ -0,0 +1,80 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { QUERY_CONJUNCTIONS } from '@tupaia/database'; +import { Country, EntityTypeEnum } from '@tupaia/types'; +import { DatatrakWebServerModelRegistry } from '../types'; +import { PermissionGroupRecord } from '@tupaia/server-boilerplate'; +import { API_CLIENT_PERMISSIONS } from '../constants'; + +const USERS_EXCLUDED_FROM_LIST = [ + 'edmofro@gmail.com', // Edwin + 'kahlinda.mahoney@gmail.com', // Kahlinda + 'lparish1980@gmail.com', // Lewis + 'sus.lake@gmail.com', // Susie + 'michaelnunan@hotmail.com', // Michael + 'vanbeekandrew@gmail.com', // Andrew + 'gerardckelly@gmail.com', // Gerry K + 'geoffreyfisher@hotmail.com', // Geoff F + 'josh@sussol.net', // mSupply API Client + 'unicef.laos.edu@gmail.com', // Laos Schools Data Collector + 'tamanu-server@tupaia.org', // Tamanu Server + 'public@tupaia.org', // Public User +]; + +const DEFAULT_PAGE_SIZE = 100; + +export const getFilteredUsers = async ( + models: DatatrakWebServerModelRegistry, + countryCode: Country['code'], + permissionGroup: PermissionGroupRecord, + searchTerm?: string, +) => { + // get the ancestors of the permission group + const permissionGroupWithAncestors = await permissionGroup.getAncestors(); + const entity = await models.entity.findOne({ + country_code: countryCode, + type: EntityTypeEnum.country, + }); + + const usersFilter = { + email: { comparator: 'not in', comparisonValue: USERS_EXCLUDED_FROM_LIST }, + [QUERY_CONJUNCTIONS.RAW]: { + // exclude E2E users and any internal users + sql: `(email NOT LIKE '%tupaia.org' AND email NOT LIKE '%beyondessential.com.au' AND email NOT LIKE '%@bes.au')`, + }, + } as Record; + + if (searchTerm) { + usersFilter.full_name = { comparator: 'ilike', comparisonValue: `${searchTerm}%` }; + } + + // if the permission group is a public permission group that every user has access to because of the api client permissions, then everyone has access to the survey, so return all non-internal users + if ( + !API_CLIENT_PERMISSIONS.find( + ({ entityCode, permissionGroupName }) => + entityCode === countryCode && permissionGroupName === permissionGroup.name, + ) + ) { + // get the user entity permissions for the permission group and its ancestors + const userEntityPermissions = await models.userEntityPermission.find({ + permission_group_id: permissionGroupWithAncestors.map(p => p.id), + entity_id: entity.id, + }); + + usersFilter.id = userEntityPermissions.map(uep => uep.user_id); + } + + const users = await models.user.find(usersFilter); + + // manually sort the users by full name, so that names beginning with special characters are first to match Meditrak + return users + .sort((a, b) => a.full_name.toLowerCase().localeCompare(b.full_name.toLowerCase())) + .map(user => ({ + id: user.id, + name: user.full_name, + })) + .slice(0, DEFAULT_PAGE_SIZE); +}; diff --git a/packages/datatrak-web-server/src/utils/getParentEntityName.ts b/packages/datatrak-web-server/src/utils/getParentEntityName.ts new file mode 100644 index 0000000000..8f16ec6fd6 --- /dev/null +++ b/packages/datatrak-web-server/src/utils/getParentEntityName.ts @@ -0,0 +1,32 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { DatatrakWebServerModelRegistry } from '../types'; + +export const getParentEntityName = async ( + models: DatatrakWebServerModelRegistry, + projectId: string, + entityId: string, +) => { + const entity = await models.entity.findById(entityId); + + if (!entity) { + throw new Error(`Entity with id ${entityId} not found`); + } + + const project = await models.project.findById(projectId); + + if (!project) { + throw new Error(`Project with id ${projectId} not found`); + } + + const entityAncestors = + project.entity_hierarchy_id && entity.type !== 'country' + ? await entity.getAncestors(project.entity_hierarchy_id, { + generational_distance: 1, + }) + : []; + return entityAncestors[0]?.name; +}; diff --git a/packages/datatrak-web-server/src/utils/index.ts b/packages/datatrak-web-server/src/utils/index.ts index 4e357e0f88..dde486e59d 100644 --- a/packages/datatrak-web-server/src/utils/index.ts +++ b/packages/datatrak-web-server/src/utils/index.ts @@ -5,3 +5,7 @@ export { sortSearchResults } from './sortSearchResults'; export { addRecentEntities } from './addRecentEntities'; +export * from './formatTaskResponse'; +export * from './formatTaskChanges'; +export { getFilteredUsers } from './getFilteredUsers'; +export { getParentEntityName } from './getParentEntityName'; diff --git a/packages/datatrak-web/package.json b/packages/datatrak-web/package.json index 04af35655d..58aa78f775 100644 --- a/packages/datatrak-web/package.json +++ b/packages/datatrak-web/package.json @@ -14,6 +14,7 @@ "@material-ui/core": "^4.9.11", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", + "@material-ui/pickers": "^3.2.10", "@material-ui/styles": "^4.9.10", "@tanstack/react-query": "4.36.1", "@testing-library/react-hooks": "^8.0.1", @@ -24,7 +25,9 @@ "@tupaia/utils": "workspace:*", "axios": "^1.6.8", "bson-objectid": "^1.2.2", + "date-fns": "^2.29.2", "downloadjs": "1.4.7", + "js-cookie": "^3.0.5", "leaflet": "^1.7.1", "lodash.throttle": "^4.1.1", "markdown-to-jsx": "^6.4.1", @@ -36,6 +39,7 @@ "react-leaflet": "^3.2.1", "react-router": "6.3.0", "react-router-dom": "6.3.0", + "react-table": "^7.8.0", "styled-components": "^5.1.0", "yup": "^1.3.2" }, @@ -47,6 +51,7 @@ "@types/leaflet": "^1.7.1", "@types/material-ui": "^0.21.12", "@types/react": "16.8.6", + "@types/react-table": "^7.7.14", "@vitejs/plugin-react": "^4.0.0", "msw": "^1.3.1", "npm-run-all": "^4.1.5", diff --git a/packages/datatrak-web/public/.well-known/apple-app-site-association b/packages/datatrak-web/public/.well-known/apple-app-site-association index 2893a058af..5018ab66ea 100644 --- a/packages/datatrak-web/public/.well-known/apple-app-site-association +++ b/packages/datatrak-web/public/.well-known/apple-app-site-association @@ -11,6 +11,7 @@ { "/": "/reset-password/*", "exclude": true }, { "/": "/verify-email/*", "exclude": true }, { "/": "/verify-email-resend/*", "exclude": true }, + { "/": "/tasks/*", "exclude": true }, { "/": "/*" } ] } diff --git a/packages/datatrak-web/public/tupaia-pin.svg b/packages/datatrak-web/public/tupaia-pin.svg index 17cc70a7fb..1cafdf59de 100644 --- a/packages/datatrak-web/public/tupaia-pin.svg +++ b/packages/datatrak-web/public/tupaia-pin.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/datatrak-web/src/AppProviders.tsx b/packages/datatrak-web/src/AppProviders.tsx index fc98ee1f4f..747815b0cd 100755 --- a/packages/datatrak-web/src/AppProviders.tsx +++ b/packages/datatrak-web/src/AppProviders.tsx @@ -15,7 +15,7 @@ import { CurrentUserContextProvider } from './api'; import { REDIRECT_ERROR_PARAM } from './constants'; const handleError = (error: any, query: any) => { - if (error.responseData.redirectClient) { + if (error.responseData?.redirectClient) { // Redirect the browser to the specified URL and display the error window.location.href = `${error.responseData.redirectClient}?${REDIRECT_ERROR_PARAM}=${error.message}`; } diff --git a/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx b/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx index 85fa77fa48..55648037a3 100644 --- a/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx +++ b/packages/datatrak-web/src/__tests__/features/Questions/EntityQuestion.test.tsx @@ -15,6 +15,11 @@ jest.mock('../../../features/Survey/SurveyContext/SurveyContext.tsx', () => ({ useSurveyForm: () => ({ getAnswerByQuestionId: () => 'blue', surveyProjectCode: 'explore', + countryCode: 'DL', + formData: { + theParentQuestionId: 'blue', + theCodeQuestionId: 'blue', + }, }), })); @@ -26,14 +31,6 @@ jest.mock('react-hook-form', () => { }; }); -jest.mock('react-router', () => { - const actual = jest.requireActual('react-router'); - return { - ...actual, - useParams: jest.fn().mockReturnValue({ countryCode: 'DL' }), - }; -}); - const entitiesData = [ { id: '5d3f8844bf6b4031bfff591b', diff --git a/packages/datatrak-web/src/__tests__/features/Questions/UserQuestion.test.tsx b/packages/datatrak-web/src/__tests__/features/Questions/UserQuestion.test.tsx new file mode 100644 index 0000000000..0888e46779 --- /dev/null +++ b/packages/datatrak-web/src/__tests__/features/Questions/UserQuestion.test.tsx @@ -0,0 +1,110 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { Ref } from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { renderComponent } from '../../helpers/render'; +import { UserQuestion } from '../../../features/Questions'; + +jest.mock('../../../features/Survey/SurveyContext/SurveyContext.tsx', () => ({ + useSurveyForm: () => ({ + countryCode: 'DL', + }), +})); + +jest.mock('../../../api/queries/useUser', () => { + return { + useUser: jest.fn().mockReturnValue({}), + }; +}); + +const users = [ + { + name: 'Teddy Bear', + id: '1', + }, + { + name: 'Grizzly Bear', + id: '2', + }, + { + name: 'Panda Bear', + id: '3', + }, + { + name: 'Koala Bear', + id: '4', + }, + { + name: 'Polar Bear', + id: '5', + }, +]; +const server = setupServer( + rest.get('*/v1/users/DL', (_, res, ctx) => { + return res(ctx.status(200), ctx.json(users)); + }), +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('User Question', () => { + const onChange = jest.fn(); + const props = { + id: 'theId', + label: 'Who should the task be assigned to?', + name: 'assignee', + config: { + user: { + permissionGroup: 'Public', + }, + }, + controllerProps: { + value: null, + onChange, + ref: { + current: {}, + } as Ref, + }, + }; + + it('renders the user question component without crashing', () => { + renderComponent(); + }); + + it('renders all the options', async () => { + renderComponent(); + + const openButton = screen.getByTitle('Open'); + openButton.click(); + + const displayOptions = await screen.findAllByRole('option'); + expect(displayOptions.length).toBe(users.length); + displayOptions.forEach((option, index) => { + const text = users[index].name; + expect(option).toHaveTextContent(text); + }); + }); + + it('Calls the onChange method with the option value', async () => { + renderComponent(); + + const openButton = screen.getByTitle('Open'); + openButton.click(); + + const displayOption = await screen.findByRole('option', { name: users[0].name }); + userEvent.click(displayOption); + + expect(onChange).toHaveBeenCalledWith({ + ...users[0], + label: users[0].name, + value: users[0].id, + }); + }); +}); diff --git a/packages/datatrak-web/src/__tests__/features/Survey/usePrimaryEntityQuestionAutoFill.test.ts b/packages/datatrak-web/src/__tests__/features/Survey/usePrimaryEntityQuestionAutoFill.test.ts new file mode 100644 index 0000000000..550ce0760f --- /dev/null +++ b/packages/datatrak-web/src/__tests__/features/Survey/usePrimaryEntityQuestionAutoFill.test.ts @@ -0,0 +1,90 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { getEntityQuestionAncestorAnswers } from '../../../features/Survey/utils/usePrimaryEntityQuestionAutoFill'; + +describe('getEntityQuestionAncestorAnswers', () => { + it('should return an empty object if the answer is not found', () => { + const question = { + config: { + entity: { + filter: { + type: ['facility'], + }, + }, + }, + }; + const questionsById = {}; + const ancestorsByType = {}; + // @ts-ignore + expect(getEntityQuestionAncestorAnswers(question, questionsById, ancestorsByType)).toEqual({}); + }); + + it('should return the answer id if there is no parent question', () => { + const question = { + id: 'questionId', + config: { + entity: { + filter: { + type: ['facility'], + }, + }, + }, + }; + const questionsById = {}; + const ancestorsByType = { + facility: { + id: 'facilityId', + }, + }; + // @ts-ignore + expect(getEntityQuestionAncestorAnswers(question, questionsById, ancestorsByType)).toEqual({ + questionId: 'facilityId', + }); + }); + + it('should return the ancestor id and the parent question ancestor ids', () => { + const question = { + id: 'questionId', + config: { + entity: { + filter: { + parentId: { questionId: 'parentQuestionId' }, + type: ['facility'], + }, + }, + }, + }; + const parentQuestion = { + id: 'parentQuestionId', + config: { + entity: { + filter: { + type: ['district'], + }, + }, + }, + }; + const questionsById = { + questionId: question, + parentQuestionId: parentQuestion, + }; + const ancestorsByType = { + facility: { + id: 'facilityId', + }, + district: { + id: 'districtId', + }, + }; + + expect( + // @ts-ignore + getEntityQuestionAncestorAnswers(question, questionsById, ancestorsByType), + ).toEqual({ + questionId: 'facilityId', + parentQuestionId: 'districtId', + }); + }); +}); diff --git a/packages/datatrak-web/src/api/CurrentUserContext.tsx b/packages/datatrak-web/src/api/CurrentUserContext.tsx index 6c425b671c..47c6e9707d 100644 --- a/packages/datatrak-web/src/api/CurrentUserContext.tsx +++ b/packages/datatrak-web/src/api/CurrentUserContext.tsx @@ -22,7 +22,7 @@ export const useCurrentUserContext = (): CurrentUserContextType => { export const CurrentUserContextProvider = ({ children }: { children: React.ReactNode }) => { const currentUserQuery = useUser(); - if (currentUserQuery.isLoading || currentUserQuery.isFetching) { + if (currentUserQuery.isInitialLoading) { return ; } diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 4825767561..f80409918e 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -7,13 +7,16 @@ export { useLogin } from './useLogin'; export { useLogout } from './useLogout'; export { useRegister } from './useRegister'; export { useEditUser } from './useEditUser'; +export { useEditTask } from './useEditTask'; export { useResendVerificationEmail } from './useResendVerificationEmail'; export { useRequestProjectAccess } from './useRequestProjectAccess'; export { useSubmitSurveyResponse } from './useSubmitSurveyResponse'; +export { useResubmitSurveyResponse } from './useResubmitSurveyResponse'; export { useRequestResetPassword } from './useRequestResetPassword'; export * from './useResetPassword'; export { useRequestDeleteAccount } from './useRequestDeleteAccount'; export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; -export { useResubmitSurveyResponse } from './useResubmitSurveyResponse'; +export { useCreateTask } from './useCreateTask'; +export { useCreateTaskComment } from './useCreateTaskComment'; diff --git a/packages/datatrak-web/src/api/mutations/useCreateTask.ts b/packages/datatrak-web/src/api/mutations/useCreateTask.ts new file mode 100644 index 0000000000..e12ef29d70 --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useCreateTask.ts @@ -0,0 +1,30 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { DatatrakWebTaskChangeRequest } from '@tupaia/types'; +import { post } from '../api'; +import { successToast } from '../../utils'; +import { useCurrentUserContext } from '../CurrentUserContext'; + +export const useCreateTask = (onSuccess?: () => void) => { + const queryClient = useQueryClient(); + const { projectId } = useCurrentUserContext(); + return useMutation( + (data: DatatrakWebTaskChangeRequest.ReqBody) => { + return post('tasks', { + data, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['tasks']); + queryClient.invalidateQueries(['taskMetric', projectId]); + successToast('Task successfully created'); + if (onSuccess) onSuccess(); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/mutations/useCreateTaskComment.ts b/packages/datatrak-web/src/api/mutations/useCreateTaskComment.ts new file mode 100644 index 0000000000..79e1b37d72 --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useCreateTaskComment.ts @@ -0,0 +1,33 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Task, TaskCommentType } from '@tupaia/types'; +import { post } from '../api'; +import { successToast } from '../../utils'; + +export const useCreateTaskComment = (taskId?: Task['id'], onSuccess?: () => void) => { + const queryClient = useQueryClient(); + return useMutation( + (comment: string) => { + return post(`tasks/${taskId}/taskComments`, { + data: { + message: comment, + type: TaskCommentType.user, + }, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['tasks']); + queryClient.invalidateQueries(['tasks', taskId]); + successToast('Comment added successfully'); + if (onSuccess) { + onSuccess(); + } + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/mutations/useEditTask.ts b/packages/datatrak-web/src/api/mutations/useEditTask.ts new file mode 100644 index 0000000000..aa1a5ec7dc --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useEditTask.ts @@ -0,0 +1,33 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Task } from '@tupaia/types'; +import { put } from '../api'; +import { successToast } from '../../utils'; +import { useCurrentUserContext } from '../CurrentUserContext'; + +type PartialTask = Partial; + +export const useEditTask = (taskId?: Task['id'], onSuccess?: () => void) => { + const queryClient = useQueryClient(); + const { projectId } = useCurrentUserContext(); + return useMutation( + (task: PartialTask) => { + return put(`tasks/${taskId}`, { + data: task, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['tasks']); + queryClient.invalidateQueries(['tasks', taskId]); + queryClient.invalidateQueries(['taskMetric', projectId]); + successToast('Task updated successfully'); + if (onSuccess) onSuccess(); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/mutations/useEditUser.ts b/packages/datatrak-web/src/api/mutations/useEditUser.ts index 8740c9d26f..b0fadd604e 100644 --- a/packages/datatrak-web/src/api/mutations/useEditUser.ts +++ b/packages/datatrak-web/src/api/mutations/useEditUser.ts @@ -45,6 +45,7 @@ export const useEditUser = (onSuccess?: () => void) => { // If the user changes their project, we need to invalidate the entity descendants query so that recent entities are updated if they change back to the previous project without refreshing the page if (variables.projectId) { queryClient.invalidateQueries(['entityDescendants']); + queryClient.invalidateQueries(['tasks']); } if (onSuccess) onSuccess(); }, diff --git a/packages/datatrak-web/src/api/mutations/useLogout.ts b/packages/datatrak-web/src/api/mutations/useLogout.ts index 8db3bb0e27..29a350a353 100644 --- a/packages/datatrak-web/src/api/mutations/useLogout.ts +++ b/packages/datatrak-web/src/api/mutations/useLogout.ts @@ -5,13 +5,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { post } from '../api'; +import { removeTaskFilterSetting } from '../../utils'; export const useLogout = () => { const queryClient = useQueryClient(); return useMutation(['logout'], () => post('logout'), { - onSuccess: () => { - queryClient.invalidateQueries(); + onSuccess: async () => { + await queryClient.resetQueries(); + removeTaskFilterSetting('all_assignees_tasks'); + removeTaskFilterSetting('show_completed_tasks'); + removeTaskFilterSetting('show_cancelled_tasks'); }, }); }; diff --git a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts index 13f7193f4c..3578c69c2b 100644 --- a/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts +++ b/packages/datatrak-web/src/api/mutations/useSubmitSurveyResponse.ts @@ -35,14 +35,13 @@ export const useSurveyResponseData = () => { }; }; -export const useSubmitSurveyResponse = () => { +export const useSubmitSurveyResponse = (from: string | undefined) => { const queryClient = useQueryClient(); const navigate = useNavigate(); const params = useParams(); const { resetForm } = useSurveyForm(); const user = useCurrentUserContext(); const { data: survey } = useSurvey(params.surveyCode); - const surveyResponseData = useSurveyResponseData(); return useMutation( @@ -69,6 +68,8 @@ export const useSubmitSurveyResponse = () => { queryClient.invalidateQueries(['rewards']); queryClient.invalidateQueries(['leaderboard']); queryClient.invalidateQueries(['entityDescendants']); // Refresh recent entities + queryClient.invalidateQueries(['tasks']); + queryClient.invalidateQueries(['taskMetric', user.projectId]); const createNewAutocompleteQuestions = surveyResponseData?.questions?.filter( question => question?.config?.autocomplete?.createNew, @@ -86,6 +87,7 @@ export const useSubmitSurveyResponse = () => { // include the survey response data in the location state, so that we can use it to generate QR codes navigate(generatePath(ROUTES.SURVEY_SUCCESS, params), { state: { + ...(from && { from }), surveyResponse: JSON.stringify(data), }, }); diff --git a/packages/datatrak-web/src/api/queries/index.ts b/packages/datatrak-web/src/api/queries/index.ts index 6c94a7a840..ec212f9a3a 100644 --- a/packages/datatrak-web/src/api/queries/index.ts +++ b/packages/datatrak-web/src/api/queries/index.ts @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ +export { useEntityAncestors } from './useEntityAncestors'; export { useUser } from './useUser'; export { useProjects } from './useProjects'; export { useSurveys } from './useSurveys'; @@ -20,3 +21,8 @@ export { useUserRewards } from './useUserRewards'; export { useActivityFeed, useCurrentProjectActivityFeed } from './useActivityFeed'; export { useProjectSurveys } from './useProjectSurveys'; export { useEntities } from './useEntities'; +export { useTaskMetrics } from './useTaskMetrics'; +export { useTasks } from './useTasks'; +export { useTask } from './useTask'; +export { useSurveyUsers } from './useSurveyUsers'; +export { usePermissionGroupUsers } from './usePermissionGroupUsers'; diff --git a/packages/datatrak-web/src/api/queries/useEntityAncestors.ts b/packages/datatrak-web/src/api/queries/useEntityAncestors.ts new file mode 100644 index 0000000000..73b150b16c --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useEntityAncestors.ts @@ -0,0 +1,19 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { Project } from '@tupaia/types'; +import { Entity } from '../../types'; +import { get } from '../api'; + +export const useEntityAncestors = (projectCode?: Project['code'], entityCode?: Entity['code']) => { + return useQuery( + ['entityAncestors', projectCode, entityCode], + (): Promise => get(`entityAncestors/${projectCode}/${entityCode}`), + { + enabled: !!projectCode && !!entityCode, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/usePermissionGroupUsers.ts b/packages/datatrak-web/src/api/queries/usePermissionGroupUsers.ts new file mode 100644 index 0000000000..a577043e33 --- /dev/null +++ b/packages/datatrak-web/src/api/queries/usePermissionGroupUsers.ts @@ -0,0 +1,28 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { Country, DatatrakWebUsersRequest, PermissionGroup } from '@tupaia/types'; +import { get } from '../api'; + +export const usePermissionGroupUsers = ( + countryCode?: Country['code'], + permissionGroupId?: PermissionGroup['id'], + searchTerm?: string, +) => { + return useQuery( + ['users', permissionGroupId, countryCode, searchTerm], + (): Promise => + get(`users/${countryCode}`, { + params: { + searchTerm, + permissionGroupId, + }, + }), + { + enabled: !!permissionGroupId && !!countryCode, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/useProjectEntities.ts b/packages/datatrak-web/src/api/queries/useProjectEntities.ts index be556a3d56..0cbeea02d0 100644 --- a/packages/datatrak-web/src/api/queries/useProjectEntities.ts +++ b/packages/datatrak-web/src/api/queries/useProjectEntities.ts @@ -9,6 +9,8 @@ import { get } from '../api'; export const useProjectEntities = ( projectCode?: string, params?: DatatrakWebEntityDescendantsRequest.ReqBody, + enabled = true, + options?: { onError?: (error: any) => void }, ) => { return useQuery( ['entityDescendants', projectCode, params], @@ -17,6 +19,6 @@ export const useProjectEntities = ( params: { ...params, filter: { ...params?.filter, projectCode } }, }); }, - { enabled: !!projectCode }, + { enabled: !!projectCode && enabled, onError: options?.onError ?? undefined }, ); }; diff --git a/packages/datatrak-web/src/api/queries/useProjectSurveys.ts b/packages/datatrak-web/src/api/queries/useProjectSurveys.ts index cc668e034d..2701d73e42 100644 --- a/packages/datatrak-web/src/api/queries/useProjectSurveys.ts +++ b/packages/datatrak-web/src/api/queries/useProjectSurveys.ts @@ -10,22 +10,20 @@ import { Entity } from '../../types'; export const useProjectSurveys = ( projectId?: Project['id'], - selectedCountryName?: Entity['name'], + selectedCountryCode?: Entity['code'], ) => { return useQuery( - ['surveys', projectId], + ['surveys', projectId, selectedCountryCode], (): Promise => get('surveys', { params: { - fields: ['name', 'code', 'id', 'survey_group.name', 'countryNames'], + fields: ['name', 'code', 'id', 'survey_group.name'], projectId, + countryCode: selectedCountryCode, }, }), { - select: data => { - return data.filter(survey => survey.countryNames?.includes(selectedCountryName!)); - }, - enabled: !!projectId, + enabled: !!projectId && !!selectedCountryCode, }, ); }; diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index ea8b444535..7b98f7948b 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -3,82 +3,20 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { useQuery } from '@tanstack/react-query'; -import { useNavigate } from 'react-router'; -import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; +import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; import { get } from '../api'; -import { ROUTES } from '../../constants'; -import { errorToast } from '../../utils'; -import { getAllSurveyComponents, useSurveyForm } from '../../features'; -import { stripTimezoneFromDate } from '@tupaia/utils'; - -export const useSurveyResponse = (surveyResponseId?: string) => { - const { setFormData, surveyScreens } = useSurveyForm(); - const navigate = useNavigate(); - - const flattenedScreenComponents = getAllSurveyComponents(surveyScreens); +export const useSurveyResponse = ( + surveyResponseId?: string | null, + options?: Record & { enabled?: boolean }, +) => { return useQuery( ['surveyResponse', surveyResponseId], (): Promise => get(`surveyResponse/${surveyResponseId}`), { - enabled: !!surveyResponseId, - meta: { - applyCustomErrorHandling: true, - }, - onError(error: any) { - if (error.code === 403) - return navigate(ROUTES.NOT_AUTHORISED, { state: { errorMessage: error.message } }); - errorToast(error.message); - }, - onSuccess: data => { - const primaryEntityQuestion = flattenedScreenComponents.find( - component => component.type === QuestionType.PrimaryEntity, - ); - - const dateOfDataQuestion = flattenedScreenComponents.find( - component => - component.type === QuestionType.DateOfData || - component.type === QuestionType.SubmissionDate, - ); - // handle updating answers here - if this is done in the component, the answers get reset on every re-render - const formattedAnswers = Object.entries(data.answers).reduce((acc, [key, value]) => { - // If the value is a stringified object, parse it - const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); - const question = flattenedScreenComponents.find( - component => component.questionId === key, - ); - if (!question) return acc; - if (question.type === QuestionType.File && value) { - // If the value is a file, split the value to get the file name - const withoutPrefix = value.split('files/'); - const fileNameParts = withoutPrefix[withoutPrefix.length - 1].split('_'); - // remove first element of the array as it is the file id - const fileName = fileNameParts.slice(1).join('_'); - return { ...acc, [key]: { name: fileName, value } }; - } - - if ( - (question.type === QuestionType.Date || question.type === QuestionType.DateTime) && - value - ) { - // strip timezone from date so that it gets displayed the same no matter the user's timezone - return { ...acc, [key]: stripTimezoneFromDate(value) }; - } - - return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; - }, {}); - - if (primaryEntityQuestion && data.entityId) { - formattedAnswers[primaryEntityQuestion.questionId] = data.entityId; - } - - if (dateOfDataQuestion && data.dataTime) { - formattedAnswers[dateOfDataQuestion.questionId] = data.dataTime; - } - - setFormData(formattedAnswers); - }, + enabled: !!surveyResponseId && options?.enabled !== false, + ...options, }, ); }; diff --git a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts new file mode 100644 index 0000000000..ec43aa7c24 --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts @@ -0,0 +1,28 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { Country, DatatrakWebUsersRequest } from '@tupaia/types'; +import { get } from '../api'; +import { Survey } from '../../types'; + +export const useSurveyUsers = ( + surveyCode?: Survey['code'], + countryCode?: Country['code'], + searchTerm?: string, +) => { + return useQuery( + ['surveyUsers', surveyCode, countryCode, searchTerm], + (): Promise => + get(`users/${surveyCode}/${countryCode}`, { + params: { + searchTerm, + }, + }), + { + enabled: !!surveyCode && !!countryCode, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/useTask.ts b/packages/datatrak-web/src/api/queries/useTask.ts new file mode 100644 index 0000000000..8c8ef5db7a --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useTask.ts @@ -0,0 +1,18 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { DatatrakWebTaskRequest } from '@tupaia/types'; +import { get } from '../api'; + +export const useTask = (taskId?: string) => { + return useQuery( + ['tasks', taskId], + (): Promise => get(`tasks/${taskId}`), + { + enabled: !!taskId, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/useTaskMetrics.ts b/packages/datatrak-web/src/api/queries/useTaskMetrics.ts new file mode 100644 index 0000000000..37a59f897f --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useTaskMetrics.ts @@ -0,0 +1,18 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { DatatrakWebTaskMetricsRequest } from '@tupaia/types'; +import { get } from '../api'; + +export const useTaskMetrics = (projectId?: string) => { + return useQuery( + ['taskMetric', projectId], + (): Promise => get(`taskMetrics/${projectId}`), + { + enabled: !!projectId, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/useTasks.ts b/packages/datatrak-web/src/api/queries/useTasks.ts new file mode 100644 index 0000000000..7202d872e4 --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useTasks.ts @@ -0,0 +1,52 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from '@tanstack/react-query'; +import { DatatrakWebTasksRequest } from '@tupaia/types'; +import { get } from '../api'; + +type Filter = { + id: string; + value: string | object; +}; + +type SortBy = { + id: string; + desc: boolean; +}; + +interface UseTasksOptions { + projectId?: string; + pageSize?: number; + page?: number; + filters?: Filter[]; + sortBy?: SortBy[]; +} + +export const useTasks = ({ projectId, pageSize, page, filters = [], sortBy }: UseTasksOptions) => { + return useQuery( + ['tasks', projectId, pageSize, page, filters, sortBy], + (): Promise => + get('tasks', { + params: { + pageSize, + page, + filters: [ + ...filters, + { + id: 'survey.project_id', + value: projectId, + }, + ], + sort: sortBy?.map(({ id, desc }) => `${id} ${desc ? 'DESC' : 'ASC'}`) ?? [], + }, + }), + { + enabled: !!projectId, + // This needs to be true so that when changing the page number, the total number of records is not reset + keepPreviousData: true, + }, + ); +}; diff --git a/packages/datatrak-web/src/components/Autocomplete.tsx b/packages/datatrak-web/src/components/Autocomplete.tsx index db45ece660..4c28deee25 100644 --- a/packages/datatrak-web/src/components/Autocomplete.tsx +++ b/packages/datatrak-web/src/components/Autocomplete.tsx @@ -8,6 +8,8 @@ import styled from 'styled-components'; import { Check } from '@material-ui/icons'; import { Autocomplete as BaseAutocomplete } from '@tupaia/ui-components'; import { Paper } from '@material-ui/core'; +import { MOBILE_BREAKPOINT } from '../constants'; +import { InputHelperText } from './InputHelperText'; const OptionWrapper = styled.div` width: 100%; @@ -103,3 +105,52 @@ export const Autocomplete = styled(BaseAutocomplete).attrs(props => ({ box-shadow: none; } `; + +export const QuestionAutocomplete = styled(Autocomplete).attrs({ + textFieldProps: { + FormHelperTextProps: { + component: InputHelperText, + }, + }, + placeholder: 'Search...', +})` + .MuiFormControl-root { + margin-bottom: 0; + } + + .MuiFormLabel-root { + font-size: 0.875rem; + line-height: 1.2; + @media (min-width: ${MOBILE_BREAKPOINT}) { + font-size: 1rem; + } + } + .MuiOutlinedInput-notchedOutline { + border: none; + } + + .MuiInputBase-root { + max-width: 25rem; + border-bottom: 1px solid ${({ theme }) => theme.palette.text.secondary}; + border-radius: 0; + order: 2; // make the helper text appear above the input + &.Mui-focused { + border-bottom-color: ${({ theme }) => theme.palette.primary.main}; + } + } + + .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border: none; + } + .MuiInputBase-input.MuiAutocomplete-input.MuiInputBase-inputAdornedEnd { + padding: 0.6rem 0; + font-size: 0.875rem; + } + + .MuiAutocomplete-inputRoot .MuiAutocomplete-endAdornment { + right: 0; + } + .MuiIconButton-root { + color: ${({ theme }) => theme.palette.text.secondary}; + } +`; diff --git a/packages/datatrak-web/src/components/Button.tsx b/packages/datatrak-web/src/components/Button.tsx index bb3fca08ea..cccb522805 100644 --- a/packages/datatrak-web/src/components/Button.tsx +++ b/packages/datatrak-web/src/components/Button.tsx @@ -8,9 +8,14 @@ import { Link as RouterLink, To } from 'react-router-dom'; import { Button as UIButton, Tooltip } from '@tupaia/ui-components'; import styled from 'styled-components'; -const StyledButton = styled(UIButton)` +const StyledButton = styled(UIButton)<{ + $enabledDisabledHoverEvents: boolean; +}>` &.Mui-disabled { - pointer-events: auto; // this is to allow the hover effect of a tooltip to work + pointer-events: ${({ $enabledDisabledHoverEvents }) => + $enabledDisabledHoverEvents + ? 'auto' + : 'none'}; // this is to allow the hover effect of a tooltip to work } `; @@ -44,7 +49,12 @@ const ButtonWrapper = ({ export const Button = ({ tooltip, children, to, ...restOfProps }: ButtonProps) => { return ( - + {children} diff --git a/packages/datatrak-web/src/components/Icons/ArrowLeftIcon.tsx b/packages/datatrak-web/src/components/Icons/ArrowLeftIcon.tsx new file mode 100644 index 0000000000..511288c7fa --- /dev/null +++ b/packages/datatrak-web/src/components/Icons/ArrowLeftIcon.tsx @@ -0,0 +1,24 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const ArrowLeftIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; 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/TaskIcon.tsx b/packages/datatrak-web/src/components/Icons/TaskIcon.tsx new file mode 100644 index 0000000000..b8b0d14c9d --- /dev/null +++ b/packages/datatrak-web/src/components/Icons/TaskIcon.tsx @@ -0,0 +1,29 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const TaskIcon = (props: SvgIconProps) => { + return ( + + + + + + ); +}; diff --git a/packages/datatrak-web/src/components/Icons/index.ts b/packages/datatrak-web/src/components/Icons/index.ts index d6ca00afcf..3841949d04 100644 --- a/packages/datatrak-web/src/components/Icons/index.ts +++ b/packages/datatrak-web/src/components/Icons/index.ts @@ -12,3 +12,6 @@ export { RadioIcon } from './RadioIcon'; export { PinIcon } from './PinIcon'; export { ReportsIcon } from './ReportsIcon'; export { CopyIcon } from './CopyIcon'; +export { TaskIcon } from './TaskIcon'; +export { CommentIcon } from './CommentIcon'; +export { ArrowLeftIcon } from './ArrowLeftIcon'; diff --git a/packages/datatrak-web/src/components/SelectList/ListItem.tsx b/packages/datatrak-web/src/components/SelectList/ListItem.tsx index 40bc0e629d..eb90aeee69 100644 --- a/packages/datatrak-web/src/components/SelectList/ListItem.tsx +++ b/packages/datatrak-web/src/components/SelectList/ListItem.tsx @@ -30,8 +30,8 @@ export const BaseListItem = styled(MuiListItem)` &.MuiButtonBase-root { &:hover, &.Mui-selected:hover, - &:focus, - &.Mui-selected:focus { + &:focus-visible, + &.Mui-selected:focus-visible { background-color: ${({ theme }) => theme.palette.primary.main}33; } } diff --git a/packages/datatrak-web/src/components/SelectList/SelectList.tsx b/packages/datatrak-web/src/components/SelectList/SelectList.tsx index 4c5232c16a..83c310e359 100644 --- a/packages/datatrak-web/src/components/SelectList/SelectList.tsx +++ b/packages/datatrak-web/src/components/SelectList/SelectList.tsx @@ -5,7 +5,7 @@ import React from 'react'; import styled, { css } from 'styled-components'; -import { FormLabel, Typography } from '@material-ui/core'; +import { FormLabel, FormLabelProps, Typography } from '@material-ui/core'; import { ListItemType } from './ListItem'; import { List } from './List'; @@ -46,13 +46,13 @@ const NoResultsMessage = styled(Typography)` color: ${({ theme }) => theme.palette.text.secondary}; `; -const Label = styled(FormLabel).attrs({ - component: 'h2', -})` +const Label = styled(FormLabel)<{ + component: React.ElementType; +}>` margin-bottom: 1rem; font-size: 0.875rem; - color: ${({ theme }) => theme.palette.text.secondary}; font-weight: 400; + color: ${({ theme, color }) => theme.palette.text[color!]}; `; interface SelectListProps { items?: ListItemType[]; @@ -60,6 +60,9 @@ interface SelectListProps { label?: string; ListItem?: React.ElementType; variant?: 'fullPage' | 'inline'; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; } export const SelectList = ({ @@ -68,11 +71,16 @@ export const SelectList = ({ label, ListItem, variant = 'inline', + labelProps = {}, }: SelectListProps) => { return ( - {label && } - + {label && ( + + )} + {items.length === 0 ? ( No items to display ) : ( diff --git a/packages/datatrak-web/src/components/SmallModal.tsx b/packages/datatrak-web/src/components/SmallModal.tsx index 72e5978ba7..e537b78009 100644 --- a/packages/datatrak-web/src/components/SmallModal.tsx +++ b/packages/datatrak-web/src/components/SmallModal.tsx @@ -12,6 +12,7 @@ import { Button, Modal } from '.'; const Wrapper = styled.div` width: 25rem; padding: 0 2rem 1rem 2rem; + text-wrap: initial; `; const ButtonWrapper = styled.div` @@ -54,6 +55,7 @@ interface ModalProps { primaryButton?: ButtonProps | null; secondaryButton?: ButtonProps | null; children?: ReactNode; + isLoading?: boolean; } export const SmallModal = ({ @@ -63,6 +65,7 @@ export const SmallModal = ({ primaryButton, secondaryButton, children, + isLoading = false, }: ModalProps) => { return ( @@ -80,7 +83,9 @@ export const SmallModal = ({ )} {primaryButton && ( - {primaryButton.label} + + {primaryButton.label} + )} diff --git a/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx b/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx new file mode 100644 index 0000000000..9b0c5ce415 --- /dev/null +++ b/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx @@ -0,0 +1,52 @@ +import { SpinningLoader } from '@tupaia/ui-components'; +import React from 'react'; +import styled from 'styled-components'; + +const MetricWrapper = styled.div` + display: flex; + border: 1px solid #3f5539; + border-radius: 3px; + margin-inline: 0.5rem; + margin-block-end: auto; + min-width: 28%; + ${({ theme }) => theme.breakpoints.down('xs')} { + width: inherit; + margin-block-start: 0.5rem; + margin-inline: 0; + } +`; + +const MetricNumber = styled.p` + font-size: 0.875rem; + line-height: 1.75; + padding: 0.5rem 1.75rem; + border-right: 1px solid ${({ theme }) => theme.palette.divider}; + min-width: 3rem; + padding-inline: 0.9rem; + align-content: center; + text-align: center; + font-weight: 500; + margin: 0; +`; + +const MetricText = styled.p` + line-height: 1.75; + letter-spacing: 0; + padding: 0.5rem 1.75rem; + padding-inline-end: 1.2rem; + padding-inline-start: 0.9rem; + font-weight: 500; + margin: 0; + ${({ theme }) => theme.breakpoints.up('lg')} { + min-width: 16rem; + } +`; + +export const TaskMetric = ({ number, text, isLoading }) => { + return ( + + {isLoading ? : number} + {text} + + ); +}; diff --git a/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx b/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx new file mode 100644 index 0000000000..24fc074e0d --- /dev/null +++ b/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styled from 'styled-components'; +import { TaskMetric } from './TaskMetric'; +import { useCurrentUserContext, useTaskMetrics } from '../../api'; + +const TaskMetricsContainer = styled.div` + margin-block-end: 0; + gap: 0.2rem; + flex: 1; + ${({ theme }) => theme.breakpoints.up('xs')} { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-left: 1rem; + } + ${({ theme }) => theme.breakpoints.down('xs')} { + width: inherit; + } +`; + +export const TaskMetrics = () => { + const { projectId } = useCurrentUserContext(); + const { data: metrics, isLoading } = useTaskMetrics(projectId); + return ( + + + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts b/packages/datatrak-web/src/components/TaskMetrics/index.ts similarity index 61% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts rename to packages/datatrak-web/src/components/TaskMetrics/index.ts index 68fced4698..5de07b5d3d 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/index.ts +++ b/packages/datatrak-web/src/components/TaskMetrics/index.ts @@ -3,4 +3,4 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -export { EntityQuestion } from './EntityQuestion'; +export { TaskMetrics } from './TaskMetrics'; diff --git a/packages/datatrak-web/src/components/Tile.tsx b/packages/datatrak-web/src/components/Tile.tsx index 0ad8bce8c7..92170d8f1b 100644 --- a/packages/datatrak-web/src/components/Tile.tsx +++ b/packages/datatrak-web/src/components/Tile.tsx @@ -30,6 +30,7 @@ const ButtonWrapper = styled(Wrapper).attrs({ flex-direction: row; position: relative; justify-content: flex-start; + align-items: flex-start; svg { margin-right: 0.4rem; diff --git a/packages/datatrak-web/src/components/index.ts b/packages/datatrak-web/src/components/index.ts index fe7d66d3f4..f4ae7fc9f5 100644 --- a/packages/datatrak-web/src/components/index.ts +++ b/packages/datatrak-web/src/components/index.ts @@ -6,7 +6,7 @@ export { PageContainer } from './PageContainer'; export * from './Icons'; export * from './SelectList'; -export { Autocomplete } from './Autocomplete'; +export { Autocomplete, QuestionAutocomplete } from './Autocomplete'; export { Button } from './Button'; export { ButtonLink } from './ButtonLink'; export { CancelConfirmModal } from './CancelConfirmModal'; @@ -20,3 +20,4 @@ export { TextInput } from './TextInput'; export { Tile, LoadingTile } from './Tile'; export { Toast } from './Toast'; export { TopProgressBar } from './TopProgressBar'; +export { TaskMetrics } from './TaskMetrics'; diff --git a/packages/datatrak-web/src/constants/url.ts b/packages/datatrak-web/src/constants/url.ts index 21873ebd5e..d0a16b3bc7 100644 --- a/packages/datatrak-web/src/constants/url.ts +++ b/packages/datatrak-web/src/constants/url.ts @@ -30,10 +30,13 @@ export const ROUTES = { VERIFY_EMAIL: '/verify-email', VERIFY_EMAIL_RESEND: '/verify-email-resend', REPORTS: '/reports', + TASKS: '/tasks', + TASK_DETAILS: '/tasks/:taskId', NOT_AUTHORISED: '/not-authorised', }; export const PASSWORD_RESET_TOKEN_PARAM = 'passwordResetToken'; +export const PRIMARY_ENTITY_CODE_PARAM = 'primaryEntityCode'; export const ADMIN_ONLY_ROUTES = [ ROUTES.REPORTS, diff --git a/packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx b/packages/datatrak-web/src/features/CountrySelector/CountrySelector.tsx similarity index 90% rename from packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx rename to packages/datatrak-web/src/features/CountrySelector/CountrySelector.tsx index 951bbd9be7..ee486e38b8 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/SurveyCountrySelector.tsx +++ b/packages/datatrak-web/src/features/CountrySelector/CountrySelector.tsx @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; import styled from 'styled-components'; @@ -42,17 +42,17 @@ const CountrySelectWrapper = styled.div` align-items: center; `; -interface SurveyCountrySelectorProps { +interface CountrySelectorProps { countries: Entity[]; selectedCountry?: Country | null; onChangeCountry: (country: Entity | null) => void; } -export const SurveyCountrySelector = ({ +export const CountrySelector = ({ countries, selectedCountry, onChangeCountry, -}: SurveyCountrySelectorProps) => { +}: CountrySelectorProps) => { const updateSelectedCountry = (e: React.ChangeEvent) => { onChangeCountry(countries.find(country => country.code === e.target.value) || null); }; diff --git a/packages/datatrak-web/src/features/CountrySelector/index.ts b/packages/datatrak-web/src/features/CountrySelector/index.ts new file mode 100644 index 0000000000..98527f6ffc --- /dev/null +++ b/packages/datatrak-web/src/features/CountrySelector/index.ts @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { useUserCountries } from './useUserCountries'; +export { CountrySelector } from './CountrySelector'; diff --git a/packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts b/packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts similarity index 83% rename from packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts rename to packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts index ee8441ca9a..b158961a45 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/useUserCountries.ts +++ b/packages/datatrak-web/src/features/CountrySelector/useUserCountries.ts @@ -1,19 +1,25 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { useState } from 'react'; import { useProjectEntities, useCurrentUserContext } from '../../api'; import { Entity } from '../../types'; -export const useUserCountries = () => { +export const useUserCountries = (onError?: (error: any) => void) => { const user = useCurrentUserContext(); const [newSelectedCountry, setSelectedCountry] = useState(null); - const { data: countries, isLoading: isLoadingCountries } = useProjectEntities( + const { + data: countries, + isLoading: isLoadingCountries, + isError, + } = useProjectEntities( user.project?.code, { filter: { type: 'country' }, }, + undefined, + { onError }, ); // sort the countries alphabetically so they are in a consistent order for the user @@ -44,7 +50,7 @@ export const useUserCountries = () => { const selectedCountry = getSelectedCountry(); return { - isLoading: isLoadingCountries || !countries, + isLoading: isLoadingCountries || (!countries && !isError), countries: alphabetisedCountries, selectedCountry, updateSelectedCountry: setSelectedCountry, diff --git a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx new file mode 100644 index 0000000000..2584ec2e4e --- /dev/null +++ b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx @@ -0,0 +1,174 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useFormContext } from 'react-hook-form'; +import { FormHelperText, FormLabel, FormLabelProps, TypographyProps } from '@material-ui/core'; +import { Country, SurveyScreenComponentConfig } from '@tupaia/types'; +import { SpinningLoader, useDebounce } from '@tupaia/ui-components'; +import { useEntityById, useProjectEntities } from '../../api'; +import { ResultsList } from './ResultsList'; +import { SearchField } from './SearchField'; +import { useEntityBaseFilters } from './useEntityBaseFilters'; + +const Container = styled.div` + width: 100%; + z-index: 0; + + fieldset:disabled & { + pointer-events: none; + } +`; + +const Label = styled(FormLabel)` + font-size: 1rem; + cursor: pointer; +`; + +const useSearchResults = (searchValue, filter, projectCode, disableSearch = false) => { + const debouncedSearch = useDebounce(searchValue!, 200); + return useProjectEntities( + projectCode, + { + fields: ['id', 'parent_name', 'code', 'name', 'type'], + filter, + searchString: debouncedSearch, + pageSize: 100, + }, + !disableSearch, + ); +}; + +interface EntitySelectorProps { + id: string; + label?: string | null; + detailLabel?: string | null; + name?: string | null; + required?: boolean | null; + controllerProps: { + onChange: (value: string) => void; + value: string; + ref: any; + invalid?: boolean; + }; + showLegend: boolean; + projectCode?: string; + showRecentEntities?: boolean; + config?: SurveyScreenComponentConfig | null; + data?: Record; + countryCode?: Country['code']; + disableSearch?: boolean; + isLoading?: boolean; + showSearchInput?: boolean; + legend?: string | null; + legendProps?: FormLabelProps & { + component?: React.ElementType; + variant?: TypographyProps['variant']; + }; +} + +export const EntitySelector = ({ + id, + label, + detailLabel, + name, + required, + controllerProps: { onChange, value, ref, invalid }, + projectCode, + showLegend, + showRecentEntities, + config, + data, + countryCode, + disableSearch, + isLoading, + showSearchInput, + legend, + legendProps, +}: EntitySelectorProps) => { + const { errors } = useFormContext(); + const [isDirty, setIsDirty] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + // Display a previously selected value + useEntityById(value, { + staleTime: 0, // Needs to be 0 to make sure the entity is fetched on first render + enabled: !!value && !searchValue, + onSuccess: entityData => { + if (!isDirty) { + setSearchValue(entityData.name); + } + }, + }); + const onChangeSearch = newValue => { + setIsDirty(true); + setSearchValue(newValue); + }; + + const onSelect = entity => { + setIsDirty(true); + onChange(entity.value); + }; + + const filters = useEntityBaseFilters(config, data, countryCode); + + const { + data: searchResults, + isLoading: isLoadingSearchResults, + isFetched, + } = useSearchResults(searchValue, filters, projectCode, disableSearch); + + const displayResults = searchResults?.filter(({ name: entityName }) => { + if (isDirty || !value) { + return true; + } + return entityName === searchValue; + }); + + const showLoader = isLoading || ((isLoadingSearchResults || !isFetched) && !disableSearch); + + return ( + <> + + {showLegend && ( + + )} +
+ {showSearchInput && ( + + )} + {showLoader ? ( + + ) : ( + + )} +
+ + {errors && errors[name!] && {errors[name!].message}} + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx similarity index 58% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx rename to packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx index 2e824d9abd..952af1a30a 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/ResultsList.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/ResultsList.tsx @@ -7,13 +7,19 @@ import React from 'react'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; import RoomIcon from '@material-ui/icons/Room'; -import { SelectList } from '../../../components'; +import { DatatrakWebEntityDescendantsRequest } from '@tupaia/types'; +import { ListItemType, SelectList } from '../../components'; + +const DARK_BLUE = '#004975'; const ListWrapper = styled.div` display: flex; flex-direction: column; overflow: auto; margin-top: 0.9rem; + li .MuiSvgIcon-root { + color: ${DARK_BLUE}; + } `; const SubListWrapper = styled.div` @@ -37,21 +43,36 @@ export const ResultItem = ({ name, parentName }) => { ); }; -export const ResultsList = ({ value, searchResults, onSelect }) => { +type SearchResults = DatatrakWebEntityDescendantsRequest.ResBody; +interface ResultsListProps { + value: string; + searchResults?: SearchResults; + onSelect: (value: ListItemType) => void; + showRecentEntities?: boolean; +} + +export const ResultsList = ({ + value, + searchResults, + onSelect, + showRecentEntities, +}: ResultsListProps) => { const getEntitiesList = (returnRecentEntities?: boolean) => { const entities = searchResults?.filter(({ isRecent }) => returnRecentEntities ? isRecent : !isRecent, ); - return entities?.map(({ name, parentName, code, id }) => ({ - content: , - value: id, - code, - selected: id === value, - icon: , - button: true, - })); + return ( + entities?.map(({ name, parentName, code, id }) => ({ + content: , + value: id, + code, + selected: id === value, + icon: , + button: true, + })) ?? [] + ); }; - const recentEntities = getEntitiesList(true); + const recentEntities = showRecentEntities ? getEntitiesList(true) : []; const displayResults = getEntitiesList(false); return ( @@ -63,7 +84,7 @@ export const ResultsList = ({ value, searchResults, onSelect }) => { )} - All entities + {showRecentEntities && All entities} diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx b/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx similarity index 88% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx rename to packages/datatrak-web/src/features/EntitySelector/SearchField.tsx index 71d53551fe..65a7153fe2 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/SearchField.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; @@ -8,7 +8,7 @@ import { TextField } from '@tupaia/ui-components'; import styled from 'styled-components'; import { Search, Clear } from '@material-ui/icons'; import { InputAdornment, IconButton, TextFieldProps } from '@material-ui/core'; -import { InputHelperText } from '../../../components'; +import { InputHelperText } from '../../components'; const StyledField = styled(TextField)` margin-bottom: 0; @@ -64,8 +64,18 @@ type SearchFieldProps = TextFieldProps & { }; export const SearchField = React.forwardRef((props, ref) => { - const { name, label, id, searchValue, onChangeSearch, isDirty, invalid, detailLabel, required } = - props; + const { + name, + label, + id, + searchValue, + onChangeSearch, + isDirty, + invalid, + detailLabel, + required, + inputProps, + } = props; const displayValue = isDirty ? searchValue : ''; @@ -109,6 +119,7 @@ export const SearchField = React.forwardRef((p ) : null, }} + inputProps={inputProps} /> ); }); diff --git a/packages/datatrak-web/src/features/EntitySelector/index.ts b/packages/datatrak-web/src/features/EntitySelector/index.ts new file mode 100644 index 0000000000..92d0ab3cf8 --- /dev/null +++ b/packages/datatrak-web/src/features/EntitySelector/index.ts @@ -0,0 +1,5 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +export { EntitySelector } from './EntitySelector'; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts b/packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts similarity index 50% rename from packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts rename to packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts index 3ec4631a61..aaecfd1c0c 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/utils.ts +++ b/packages/datatrak-web/src/features/EntitySelector/useEntityBaseFilters.ts @@ -2,16 +2,17 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { useParams } from 'react-router-dom'; import { SurveyScreenComponentConfig } from '@tupaia/types'; -import { useSurveyForm } from '../../Survey'; - -export const useEntityBaseFilters = (config: SurveyScreenComponentConfig) => { - const { getAnswerByQuestionId } = useSurveyForm(); - const { countryCode } = useParams(); +export const useEntityBaseFilters = ( + config?: SurveyScreenComponentConfig | null, + answers?: Record, + countryCode?: string, +) => { const filters = { countryCode } as Record; + if (!config) return filters; + const filter = config?.entity?.filter; if (!filter) { return filters; @@ -23,15 +24,18 @@ export const useEntityBaseFilters = (config: SurveyScreenComponentConfig) => { filters.type = Array.isArray(type) ? type.join(',') : type; } - if (parentId && parentId.questionId) { - filters['parentId'] = getAnswerByQuestionId(parentId.questionId); + if (!answers) return filters; + + if (parentId && parentId.questionId && answers?.[parentId.questionId]) { + filters.parentId = answers[parentId.questionId]; } - if (grandparentId && grandparentId.questionId) { - filters['grandparentId'] = getAnswerByQuestionId(grandparentId.questionId); + if (grandparentId && grandparentId.questionId && answers?.[grandparentId.questionId]) { + filters.grandparentId = answers[grandparentId.questionId]; } if (attributes) { Object.entries(attributes).forEach(([key, attrConfig]) => { - const filterValue = getAnswerByQuestionId(attrConfig.questionId); + if (answers?.[attrConfig.questionId] === undefined) return; + const filterValue = answers?.[attrConfig.questionId]; filters[`attributes->>${key}`] = filterValue; }); } diff --git a/packages/datatrak-web/src/features/GroupedSurveyList.tsx b/packages/datatrak-web/src/features/GroupedSurveyList.tsx new file mode 100644 index 0000000000..222457548a --- /dev/null +++ b/packages/datatrak-web/src/features/GroupedSurveyList.tsx @@ -0,0 +1,114 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { FormHelperText, FormLabelProps } from '@material-ui/core'; +import { Country } from '@tupaia/types'; +import { ListItemType, SelectList, SurveyFolderIcon, SurveyIcon } from '../components'; +import { Survey } from '../types'; +import { useCurrentUserContext, useProjectSurveys } from '../api'; + +const ListWrapper = styled.div` + max-height: 35rem; + display: flex; + flex-direction: column; + overflow: auto; + flex: 1; + ${({ theme }) => theme.breakpoints.down('sm')} { + max-height: 100%; + } +`; + +const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { + return (a.content as string).trim()?.localeCompare((b.content as string).trim(), 'en', { + numeric: true, + }); +}; + +interface GroupedSurveyListProps { + setSelectedSurvey: (surveyCode: Survey['code'] | null) => void; + selectedSurvey: Survey['code'] | null; + selectedCountry?: Country | null; + label?: string; + labelProps?: FormLabelProps & { + component?: React.ElementType; + }; + error?: string; +} + +export const GroupedSurveyList = ({ + setSelectedSurvey, + selectedSurvey, + selectedCountry, + label, + labelProps, + error, +}: GroupedSurveyListProps) => { + const user = useCurrentUserContext(); + const { data: surveys } = useProjectSurveys(user?.projectId, selectedCountry?.code); + const groupedSurveys = + surveys + ?.reduce((acc: ListItemType[], survey: Survey) => { + const { surveyGroupName, name, code } = survey; + const formattedSurvey = { + content: name, + value: code, + selected: selectedSurvey === code, + icon: , + }; + // if there is no surveyGroupName, add the survey to the list as a top level item + if (!surveyGroupName) { + return [...acc, formattedSurvey]; + } + const group = acc.find(({ content }) => content === surveyGroupName); + // if the surveyGroupName doesn't exist in the list, add it as a top level item + if (!group) { + return [ + ...acc, + { + content: surveyGroupName, + icon: , + value: surveyGroupName, + children: [formattedSurvey], + }, + ]; + } + // if the surveyGroupName exists in the list, add the survey to the children + return acc.map(item => { + if (item.content === surveyGroupName) { + return { + ...item, + // sort the folder items alphanumerically + children: [...(item.children || []), formattedSurvey].sort(sortAlphanumerically), + }; + } + return item; + }); + }, []) + ?.sort(sortAlphanumerically) ?? []; + + useEffect(() => { + // when the surveys change, check if the selected survey is still in the list. If not, clear the selection + if (selectedSurvey && !surveys?.find(survey => survey.code === selectedSurvey)) { + setSelectedSurvey(null); + } + }, [JSON.stringify(surveys)]); + + const onSelectSurvey = (listItem: ListItemType | null) => { + if (!listItem) return setSelectedSurvey(null); + setSelectedSurvey(listItem?.value as Survey['code']); + }; + return ( + + + {error && {error}} + + ); +}; diff --git a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx index c5db50f901..5b846f5e1c 100644 --- a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx +++ b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx @@ -120,7 +120,7 @@ export const LeaderboardTable = ({ - {user?.userName} + {user?.fullName} {userRewards?.coconuts} diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx new file mode 100644 index 0000000000..8e599008ed --- /dev/null +++ b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx @@ -0,0 +1,52 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { Typography } from '@material-ui/core'; +import { SurveyQuestionInputProps } from '../../types'; +import { useSurveyForm } from '..'; +import { EntitySelector } from '../EntitySelector'; + +export const EntityQuestion = ({ + id, + label, + detailLabel, + name, + required, + controllerProps: { onChange, value, ref, invalid }, + config, +}: SurveyQuestionInputProps) => { + const { isReviewScreen, isResponseScreen, formData, countryCode } = useSurveyForm(); + + const { surveyProjectCode } = useSurveyForm(); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx b/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx deleted file mode 100644 index 865ba90be0..0000000000 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion/EntityQuestion.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { useFormContext } from 'react-hook-form'; -import { FormHelperText, Typography } from '@material-ui/core'; -import { SpinningLoader } from '@tupaia/ui-components'; -import { SurveyQuestionInputProps } from '../../../types'; -import { useProjectEntities, useEntityById } from '../../../api'; -import { useDebounce } from '../../../utils'; -import { useSurveyForm } from '../..'; -import { ResultsList } from './ResultsList'; -import { SearchField } from './SearchField'; -import { useEntityBaseFilters } from './utils'; - -const Container = styled.div` - width: 100%; - z-index: 0; - - fieldset:disabled & { - pointer-events: none; - } -`; - -const Label = styled(Typography).attrs({ - variant: 'h4', -})` - font-size: 1rem; - cursor: pointer; -`; - -const useSearchResults = (searchValue, config) => { - const filter = useEntityBaseFilters(config); - const { surveyProjectCode } = useSurveyForm(); - - const debouncedSearch = useDebounce(searchValue!, 200); - return useProjectEntities(surveyProjectCode, { - fields: ['id', 'parent_name', 'code', 'name', 'type'], - filter, - searchString: debouncedSearch, - }); -}; - -export const EntityQuestion = ({ - id, - label, - detailLabel, - name, - required, - controllerProps: { onChange, value, ref, invalid }, - config, -}: SurveyQuestionInputProps) => { - const { isReviewScreen, isResponseScreen } = useSurveyForm(); - const { errors } = useFormContext(); - const [isDirty, setIsDirty] = useState(false); - const [searchValue, setSearchValue] = useState(''); - - // Display a previously selected value - useEntityById(value, { - staleTime: 0, // Needs to be 0 to make sure the entity is fetched on first render - enabled: !!value && !searchValue, - onSuccess: entityData => { - if (!isDirty) { - setSearchValue(entityData.name); - } - }, - }); - const onChangeSearch = newValue => { - setIsDirty(true); - setSearchValue(newValue); - }; - - const onSelect = entity => { - setIsDirty(true); - onChange(entity.value); - }; - - const { data: searchResults, isLoading, isFetched } = useSearchResults(searchValue, config); - - const displayResults = searchResults?.filter(({ name: entityName }) => { - if (isDirty || !value) { - return true; - } - return entityName === searchValue; - }); - - return ( - - {isReviewScreen || isResponseScreen ? ( - - ) : ( - - )} - {errors && errors[name!] && *{errors[name!].message}} - {!isFetched || isLoading ? ( - - ) : ( - - )} - - ); -}; diff --git a/packages/datatrak-web/src/features/Questions/UserQuestion.tsx b/packages/datatrak-web/src/features/Questions/UserQuestion.tsx new file mode 100644 index 0000000000..726fb5fa97 --- /dev/null +++ b/packages/datatrak-web/src/features/Questions/UserQuestion.tsx @@ -0,0 +1,72 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect, useState } from 'react'; +import throttle from 'lodash.throttle'; +import { SurveyQuestionInputProps } from '../../types'; +import { usePermissionGroupUsers } from '../../api'; +import { useSurveyForm } from '../Survey'; +import { InputHelperText, QuestionAutocomplete } from '../../components'; + +export const UserQuestion = ({ + id, + label, + detailLabel, + name, + required, + controllerProps: { onChange, value, ref, invalid }, + config, +}: SurveyQuestionInputProps) => { + const [searchValue, setSearchValue] = useState(''); + const { countryCode } = useSurveyForm(); + const { + data: users, + isLoading, + isFetched, + isError, + error, + } = usePermissionGroupUsers(countryCode, config?.user?.permissionGroup, searchValue); + + const options = + users?.map(user => ({ + ...user, + label: user.name, + value: user.id, + })) ?? []; + + //If we programmatically set the value of the input, we need to update the search value + useEffect(() => { + // if the selection is the same as the search value, do not update the search value + if (value?.name === searchValue) return; + + setSearchValue(value?.name ?? ''); + }, [JSON.stringify(value)]); + + return ( + <> + onChange(newSelectedOption ?? null)} + onInputChange={throttle((e, newValue) => { + if (newValue === searchValue || !e || !e.target) return; + setSearchValue(newValue); + }, 200)} + inputValue={searchValue} + inputRef={ref} + error={isError || invalid} + getOptionLabel={option => option.label} + loading={isLoading || !isFetched} + getOptionSelected={(option, selected) => option.value === selected?.value} + /> + {error && {(error as Error).message}} + + ); +}; diff --git a/packages/datatrak-web/src/features/Questions/index.ts b/packages/datatrak-web/src/features/Questions/index.ts index 77eff62dd5..245c8c2e87 100644 --- a/packages/datatrak-web/src/features/Questions/index.ts +++ b/packages/datatrak-web/src/features/Questions/index.ts @@ -15,3 +15,4 @@ export { AutocompleteQuestion } from './AutocompleteQuestion'; export { ReadOnlyQuestion } from './ReadOnlyQuestion'; export { PhotoQuestion } from './PhotoQuestion'; export { FileQuestion } from './FileQuestion'; +export { UserQuestion } from './UserQuestion'; diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx index 2ad7edefe0..234d7254ab 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx @@ -21,6 +21,7 @@ import { ReadOnlyQuestion, PhotoQuestion, FileQuestion, + UserQuestion, } from '../../Questions'; import { SurveyQuestionFieldProps } from '../../../types'; import { useSurveyForm } from '..'; @@ -60,6 +61,7 @@ export enum QUESTION_TYPES { Arithmetic = ReadOnlyQuestion, Condition = ReadOnlyQuestion, File = FileQuestion, + User = UserQuestion, } /** diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyReviewSection.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyReviewSection.tsx index 7f6145027b..a77aae0d50 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyReviewSection.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyReviewSection.tsx @@ -4,30 +4,11 @@ */ import React from 'react'; -import { QuestionType } from '@tupaia/types'; import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; +import { getAllSurveyComponents } from '../utils'; import { useSurveyForm } from '../SurveyContext'; import { SurveyQuestionGroup } from './SurveyQuestionGroup'; -const Section = styled.section` - padding: 1rem 0; - &:first-child { - padding-top: 0; - } -`; - -const SectionHeader = styled(Typography).attrs({ - variant: 'h3', -})` - font-size: 1rem; - font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; - margin-bottom: 1rem; - ${({ theme }) => theme.breakpoints.up('sm')} { - font-size: 1.125rem; - } -`; - const Fieldset = styled.fieldset.attrs({ disabled: true, })` @@ -45,30 +26,10 @@ export const SurveyReviewSection = () => { if (!visibleScreens || !visibleScreens.length) { return null; } - - // split the questions into sections by screen so it's easier to read the long form - const questionSections = visibleScreens.map(screen => { - const { surveyScreenComponents } = screen; - const heading = surveyScreenComponents[0].text; - const firstQuestionIsInstruction = surveyScreenComponents[0].type === QuestionType.Instruction; - - // if the first question is an instruction, don't display it, because it will be displayed as the heading - const questionsToDisplay = firstQuestionIsInstruction - ? surveyScreenComponents.slice(1) - : surveyScreenComponents; - return { - heading, - questions: questionsToDisplay, - }; - }); + const questions = getAllSurveyComponents(visibleScreens); return (
- {questionSections.map(({ heading, questions }, index) => ( -
- {heading} - -
- ))} +
); }; diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx index cb4b3a6081..0422ca8017 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx @@ -7,11 +7,11 @@ import styled from 'styled-components'; import { To, Link as RouterLink } from 'react-router-dom'; import { useFormContext } from 'react-hook-form'; import { Drawer as BaseDrawer, ListItem, List, ButtonProps } from '@material-ui/core'; -import { useSurveyForm } from '../../SurveyContext'; -import { useIsMobile } from '../../../../utils'; +import { useFromLocation, useIsMobile } from '../../../../utils'; import { getSurveyScreenNumber } from '../../utils'; import { useSurveyRouting } from '../../useSurveyRouting'; import { SideMenuButton } from './SideMenuButton'; +import { useSurveyForm } from '../../SurveyContext'; export const SIDE_MENU_WIDTH = '20rem'; @@ -51,6 +51,9 @@ const SurveyMenuItem = styled(ListItem).attrs({ to: To; $active?: boolean; $isInstructionOnly?: boolean; + state: { + from?: string | undefined; + }; } >` padding: 0.5rem; @@ -95,6 +98,7 @@ const Header = styled.div` export const SurveySideMenu = () => { const { getValues } = useFormContext(); + const from = useFromLocation(); const isMobile = useIsMobile(); const { sideMenuOpen, @@ -142,6 +146,9 @@ export const SurveySideMenu = () => { return (
  • ` margin-block-end: 1rem; diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx index c468bd01a2..8a35d33b70 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx @@ -10,6 +10,7 @@ import { Button as BaseButton } from '../../../components'; import { useSurveyForm } from '../SurveyContext'; import { ROUTES } from '../../../constants'; import { useSurvey } from '../../../api/queries'; +import { useFromLocation } from '../../../utils'; import { SurveySuccess } from '../Components'; const ButtonGroup = styled.div` @@ -23,12 +24,24 @@ const Button = styled(BaseButton)` } `; +const ReturnButton = () => { + const from = useFromLocation(); + return from === ROUTES.TASKS ? ( + + ) : ( + + ); +}; + export const SurveySuccessScreen = () => { const params = useParams(); const navigate = useNavigate(); const { resetForm } = useSurveyForm(); const { data: survey } = useSurvey(params.surveyCode); - const repeatSurvey = () => { resetForm(); const path = generatePath(ROUTES.SURVEY_SCREEN, { @@ -56,9 +69,7 @@ export const SurveySuccessScreen = () => { Repeat Survey )} - + ); diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx index b864cc5922..35f94da023 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx @@ -2,13 +2,13 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - -import React, { createContext, Dispatch, useContext, useEffect, useReducer } from 'react'; -import { useMatch, useParams } from 'react-router-dom'; -import { ROUTES } from '../../../constants'; +import React, { createContext, Dispatch, useContext, useReducer, useState, useMemo } from 'react'; +import { useMatch, useParams, useSearchParams } from 'react-router-dom'; +import { QuestionType } from '@tupaia/types'; +import { PRIMARY_ENTITY_CODE_PARAM, ROUTES } from '../../../constants'; import { SurveyParams } from '../../../types'; import { useSurvey } from '../../../api'; -import { getAllSurveyComponents } from '../utils'; +import { getAllSurveyComponents, getPrimaryEntityParentQuestionIds } from '../utils'; import { generateCodeForCodeGeneratorQuestions, getDisplayQuestions, @@ -17,12 +17,15 @@ import { } from './utils'; import { SurveyFormContextType, surveyReducer } from './reducer'; import { ACTION_TYPES, SurveyFormAction } from './actions'; +import { usePrimaryEntityQuestionAutoFill } from '../utils/usePrimaryEntityQuestionAutoFill'; const defaultContext = { startTime: new Date().toISOString(), formData: {}, activeScreen: [], isLast: false, + isReviewScreen: false, + isResponseScreen: false, numberOfScreens: 0, screenNumber: 1, screenHeader: '', @@ -31,58 +34,95 @@ const defaultContext = { displayQuestions: [], sideMenuOpen: false, cancelModalOpen: false, + countryCode: '', + primaryEntityQuestion: null, } as SurveyFormContextType; const SurveyFormContext = createContext(defaultContext); export const SurveyFormDispatchContext = createContext | null>(null); -export const SurveyContext = ({ children }) => { +export const SurveyContext = ({ children, surveyCode, countryCode }) => { + const [urlSearchParams] = useSearchParams(); + const [prevSurveyCode, setPrevSurveyCode] = useState(null); + const primaryEntityCodeParam = urlSearchParams.get(PRIMARY_ENTITY_CODE_PARAM) || undefined; + const [primaryEntityCode] = useState(primaryEntityCodeParam); const [state, dispatch] = useReducer(surveyReducer, defaultContext); - const { surveyCode, ...params } = useParams(); + const params = useParams(); const screenNumber = params.screenNumber ? parseInt(params.screenNumber!, 10) : null; const { data: survey } = useSurvey(surveyCode); - const isResponseScreen = !!useMatch(ROUTES.SURVEY_RESPONSE); + const isResponseScreen = !!urlSearchParams.get('responseId'); + const isReviewScreen = !!useMatch(ROUTES.SURVEY_REVIEW); - const { formData } = state; + let { formData } = state; const surveyScreens = survey?.screens || []; const flattenedScreenComponents = getAllSurveyComponents(surveyScreens); + const primaryEntityQuestion = flattenedScreenComponents.find( + question => question.type === QuestionType.PrimaryEntity, + ); + const { data: autoFillAnswers } = usePrimaryEntityQuestionAutoFill( + primaryEntityQuestion, + flattenedScreenComponents, + primaryEntityCode, + ); + + if (primaryEntityCode) { + formData = { ...formData, ...autoFillAnswers }; + } + + // Get the list of parent question ids for the primary entity question + const primaryEntityParentQuestionIds = useMemo( + () => getPrimaryEntityParentQuestionIds(primaryEntityQuestion, flattenedScreenComponents), + [primaryEntityQuestion, flattenedScreenComponents], + ); // filter out screens that have no visible questions, and the components that are not visible. This is so that the titles of the screens are not using questions that are not visible const visibleScreens = surveyScreens .map(screen => { return { ...screen, - surveyScreenComponents: screen.surveyScreenComponents.filter(question => - getIsQuestionVisible(question, formData), - ), + surveyScreenComponents: screen.surveyScreenComponents.filter(question => { + // If a primary entity code is pre-set for the survey, hide the primary entity question and its ancestor questions + if (primaryEntityCode && !isReviewScreen) { + if ( + question.type === QuestionType.PrimaryEntity || + primaryEntityParentQuestionIds.includes(question.id) + ) { + return false; + } + } + return getIsQuestionVisible(question, formData); + }), }; }) .filter(screen => screen.surveyScreenComponents.length > 0); const activeScreen = visibleScreens?.[screenNumber! - 1]?.surveyScreenComponents || []; - useEffect(() => { - const initialiseFormData = () => { - if (!surveyCode || isResponseScreen) return; - // if we are on the response screen, we don't want to initialise the form data, because we want to show the user's saved answers - const initialFormData = generateCodeForCodeGeneratorQuestions( - flattenedScreenComponents, - formData, - ); - dispatch({ type: ACTION_TYPES.SET_FORM_DATA, payload: initialFormData }); - // update the start time when a survey is started, so that it can be passed on when submitting the survey - - const currentDate = new Date(); - dispatch({ - type: ACTION_TYPES.SET_SURVEY_START_TIME, - payload: currentDate.toISOString(), - }); - }; + const initialiseFormData = () => { + if (!surveyCode || isResponseScreen) return; + // if we are on the response screen, we don't want to initialise the form data, because we want to show the user's saved answers + const initialFormData = generateCodeForCodeGeneratorQuestions( + flattenedScreenComponents, + formData, + ); + + dispatch({ type: ACTION_TYPES.SET_FORM_DATA, payload: initialFormData }); + // update the start time when a survey is started, so that it can be passed on when submitting the survey + + const currentDate = new Date(); + dispatch({ + type: ACTION_TYPES.SET_SURVEY_START_TIME, + payload: currentDate.toISOString(), + }); + }; + // @see https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + if (surveyCode !== prevSurveyCode) { + setPrevSurveyCode(surveyCode as string); initialiseFormData(); - }, [surveyCode]); + } const displayQuestions = getDisplayQuestions(activeScreen, flattenedScreenComponents); const screenHeader = activeScreen?.[0]?.text; @@ -92,14 +132,20 @@ export const SurveyContext = ({ children }) => { @@ -118,9 +164,6 @@ export const useSurveyForm = () => { const numberOfScreens = visibleScreens?.length || 0; const isLast = screenNumber === numberOfScreens; const isSuccessScreen = !!useMatch(ROUTES.SURVEY_SUCCESS); - const isReviewScreen = !!useMatch(ROUTES.SURVEY_REVIEW); - - const isResponseScreen = !!useMatch(ROUTES.SURVEY_RESPONSE); const isResubmitScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN); const isResubmitReviewScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); const isResubmit = @@ -160,8 +203,6 @@ export const useSurveyForm = () => { ...surveyFormContext, isLast, isSuccessScreen, - isReviewScreen, - isResponseScreen, numberOfScreens, toggleSideMenu, updateFormData, diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/reducer.ts b/packages/datatrak-web/src/features/Survey/SurveyContext/reducer.ts index 97fe85aa48..fe2be9d8a6 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/reducer.ts +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/reducer.ts @@ -19,12 +19,15 @@ export type SurveyFormContextType = { screenDetail?: string | null; displayQuestions: SurveyScreenComponent[]; sideMenuOpen?: boolean; - isReviewScreen?: boolean; + isReviewScreen: boolean; + isResponseScreen: boolean; surveyScreens?: SurveyScreen[]; visibleScreens?: SurveyScreen[]; surveyStartTime?: string; isSuccessScreen?: boolean; cancelModalOpen: boolean; + countryCode: string; + primaryEntityQuestion?: SurveyScreenComponent | null; }; export const surveyReducer = ( diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index d4af35cb97..2fab8f6c14 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -4,6 +4,7 @@ */ import React from 'react'; +import { useIsFetching } from '@tanstack/react-query'; import { Outlet, generatePath, useNavigate, useParams } from 'react-router'; import { useFormContext } from 'react-hook-form'; import styled from 'styled-components'; @@ -12,6 +13,7 @@ import { SpinningLoader } from '@tupaia/ui-components'; import { ROUTES } from '../../constants'; import { useResubmitSurveyResponse, useSubmitSurveyResponse } from '../../api/mutations'; import { SurveyParams } from '../../types'; +import { useFromLocation } from '../../utils'; import { useSurveyForm } from './SurveyContext'; import { SIDE_MENU_WIDTH, SurveySideMenu } from './Components'; import { getErrorsByScreen } from './utils'; @@ -72,7 +74,10 @@ const LoadingContainer = styled.div` */ export const SurveyLayout = () => { const navigate = useNavigate(); + const from = useFromLocation(); const params = useParams(); + const isFetchingEntities = useIsFetching({ queryKey: ['entityAncestors'] }); + const { updateFormData, formData, @@ -85,16 +90,21 @@ export const SurveyLayout = () => { visibleScreens, isResubmitReviewScreen, } = useSurveyForm(); + const { handleSubmit, getValues } = useFormContext(); const { mutate: submitSurveyResponse, isLoading: isSubmittingSurveyResponse } = - useSubmitSurveyResponse(); + useSubmitSurveyResponse(from); const { mutate: resubmitSurveyResponse, isLoading: isResubmittingSurveyResponse } = useResubmitSurveyResponse(); const { back, next } = useSurveyRouting(numberOfScreens); const handleStep = (path, data) => { updateFormData({ ...formData, ...data }); - navigate(path); + navigate(path, { + state: { + ...(from && { from }), + }, + }); }; const onStepPrevious = () => { @@ -119,6 +129,7 @@ export const SurveyLayout = () => { if (!surveyScreenToSnapTo) return; // we have to serialize the errors for the location state as per https://github.com/remix-run/react-router/issues/8792. We can't just set the errors manually in the form because when we navigate to the screen, the form errors will reset const stringifiedErrors = JSON.stringify(screenErrors); + navigate( generatePath(ROUTES.SURVEY_SCREEN, { ...params, @@ -126,6 +137,7 @@ export const SurveyLayout = () => { }), { state: { + ...(from && { from }), errors: stringifiedErrors, }, }, @@ -134,13 +146,14 @@ export const SurveyLayout = () => { const onSubmit = data => { const submitAction = isResubmitReviewScreen ? resubmitSurveyResponse : submitSurveyResponse; - if (isReviewScreen || isResubmitReviewScreen) return submitAction(data); + if (isReviewScreen || isResubmitReviewScreen) return submitAction({ ...formData, ...data }); return navigateNext(data); }; const handleClickSubmit = handleSubmit(onSubmit, onError); - const showLoader = isSubmittingSurveyResponse || isResubmittingSurveyResponse; + const showLoader = + isSubmittingSurveyResponse || isResubmittingSurveyResponse || !!isFetchingEntities; return ( <> diff --git a/packages/datatrak-web/src/features/Survey/index.ts b/packages/datatrak-web/src/features/Survey/index.ts index 34e2820546..75084e7ebb 100644 --- a/packages/datatrak-web/src/features/Survey/index.ts +++ b/packages/datatrak-web/src/features/Survey/index.ts @@ -6,6 +6,6 @@ export * from './Screens'; export { SurveyContext, useSurveyForm, getArithmeticDisplayAnswer } from './SurveyContext'; export { SurveyLayout } from './SurveyLayout'; -export { SurveyToolbar, SurveySideMenu } from './Components'; -export { getAllSurveyComponents } from './utils'; +export { SurveyToolbar, SurveySideMenu, SurveyReviewSection } from './Components'; +export * from './utils'; export { useValidationResolver } from './useValidationResolver'; diff --git a/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts b/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts index 31768cdc77..fc3c67c8e8 100644 --- a/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts +++ b/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts @@ -2,10 +2,11 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { generatePath, useParams, useMatch } from 'react-router'; +import { generatePath, useParams, useMatch, useLocation } from 'react-router'; import { ROUTES } from '../../constants'; export const useSurveyRouting = numberOfScreens => { + const location = useLocation(); const isResubmitReview = useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); const isResubmit = useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN) || isResubmitReview; const isReview = useMatch(ROUTES.SURVEY_REVIEW) || isResubmitReview; @@ -13,22 +14,31 @@ export const useSurveyRouting = numberOfScreens => { const getScreenPath = (screenNumber: number) => { if (isResubmit) { - return generatePath(ROUTES.SURVEY_RESUBMIT_SCREEN, { + return { + ...location, + pathname: generatePath(ROUTES.SURVEY_RESUBMIT_SCREEN, { + ...params, + screenNumber: String(screenNumber), + }), + }; + } + return { + ...location, + pathname: generatePath(ROUTES.SURVEY_SCREEN, { ...params, screenNumber: String(screenNumber), - }); - } - return generatePath(ROUTES.SURVEY_SCREEN, { - ...params, - screenNumber: String(screenNumber), - }); + }), + }; }; const getNextPath = () => { if (isReview) return null; if (params.screenNumber && parseInt(params.screenNumber) === numberOfScreens) { const REVIEW_PATH = isResubmit ? ROUTES.SURVEY_RESUBMIT_REVIEW : ROUTES.SURVEY_REVIEW; - return generatePath(REVIEW_PATH, params); + return { + ...location, + pathname: generatePath(REVIEW_PATH, params), + }; } return getScreenPath(parseInt(params.screenNumber ?? '1') + 1); }; @@ -36,7 +46,12 @@ export const useSurveyRouting = numberOfScreens => { const getPreviousPath = () => { if (isReview) return getScreenPath(numberOfScreens); if (!params.screenNumber || params.screenNumber === '1') - return isResubmit ? null : generatePath(ROUTES.SURVEY_SELECT); + return isResubmit + ? null + : { + ...location, + pathname: generatePath(ROUTES.SURVEY_SELECT), + }; return getScreenPath(parseInt(params.screenNumber) - 1); }; diff --git a/packages/datatrak-web/src/features/Survey/useValidationResolver.ts b/packages/datatrak-web/src/features/Survey/useValidationResolver.ts index 0990e768c6..958056bf5e 100644 --- a/packages/datatrak-web/src/features/Survey/useValidationResolver.ts +++ b/packages/datatrak-web/src/features/Survey/useValidationResolver.ts @@ -51,6 +51,15 @@ const getBaseSchema = (type: QuestionType) => { }) .nullable() .default(() => ({})); + case QuestionType.User: + return yup + .object() + .shape({ + id: yup.string(), + name: yup.string(), + }) + .nullable() + .default(null); // Allow this value to be empty to stop a typeError. The mandatory validation will handle this instead default: return yup.string(); } diff --git a/packages/datatrak-web/src/features/Survey/utils/index.ts b/packages/datatrak-web/src/features/Survey/utils/index.ts new file mode 100644 index 0000000000..257b91203f --- /dev/null +++ b/packages/datatrak-web/src/features/Survey/utils/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +export { useSurveyResponseWithForm } from './useSurveyResponseWithForm'; +export * from './utils'; diff --git a/packages/datatrak-web/src/features/Survey/utils/usePrimaryEntityQuestionAutoFill.ts b/packages/datatrak-web/src/features/Survey/utils/usePrimaryEntityQuestionAutoFill.ts new file mode 100644 index 0000000000..5f489b62f7 --- /dev/null +++ b/packages/datatrak-web/src/features/Survey/utils/usePrimaryEntityQuestionAutoFill.ts @@ -0,0 +1,54 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import keyBy from 'lodash.keyby'; +import { useCurrentUserContext, useEntityAncestors } from '../../../api'; +import { getParentQuestionId } from './utils'; +import { SurveyScreenComponent, Entity } from '../../../types'; + +// Get the parent question ancestors recursively for the primary entity question +export const getEntityQuestionAncestorAnswers = ( + question: SurveyScreenComponent, + questionsById: Record, + ancestorsByType: Record, +): Record => { + const answer = ancestorsByType[question?.config?.entity?.filter?.type?.[0] ?? '']; + if (!answer) return {}; + + const parentQuestionId = getParentQuestionId(question); + const parentQuestion = parentQuestionId ? questionsById[parentQuestionId] : null; + + const record = { [question.id as string]: answer.id }; + if (!parentQuestion) return record; + + return { + ...record, + ...getEntityQuestionAncestorAnswers(parentQuestion, questionsById, ancestorsByType), + }; +}; + +/** + * Gets the answers for the primary entity question and its ancestors if the primary entity is pre-set for a survey + */ +export const usePrimaryEntityQuestionAutoFill = ( + primaryEntityQuestion: SurveyScreenComponent, + questions: SurveyScreenComponent[], + primaryEntityCode?: Entity['code'], +) => { + const user = useCurrentUserContext(); + const { data = {}, ...query } = useEntityAncestors(user.project?.code, primaryEntityCode); + + const ancestors = data + ? getEntityQuestionAncestorAnswers( + primaryEntityQuestion as SurveyScreenComponent, + keyBy(questions, 'id'), + keyBy(data, 'type'), + ) + : {}; + + return { + ...query, + data: ancestors, + }; +}; diff --git a/packages/datatrak-web/src/features/Survey/utils/useSurveyResponseWithForm.ts b/packages/datatrak-web/src/features/Survey/utils/useSurveyResponseWithForm.ts new file mode 100644 index 0000000000..1971836ea1 --- /dev/null +++ b/packages/datatrak-web/src/features/Survey/utils/useSurveyResponseWithForm.ts @@ -0,0 +1,86 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; +import { useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { stripTimezoneFromDate } from '@tupaia/utils'; +import { useSurvey } from '../../../api'; +import { useSurveyForm } from '../SurveyContext'; +import { getAllSurveyComponents } from '../utils'; + +/** + * Utility hook to process survey response data and populate the form with it + */ +export const useSurveyResponseWithForm = ( + surveyResponse?: DatatrakWebSingleSurveyResponseRequest.ResBody, +) => { + const { setFormData, surveyScreens, surveyCode, formData } = useSurveyForm(); + + const { isLoading, isFetched, isSuccess } = useSurvey(surveyCode); + const surveyLoading = isLoading || !isFetched; + const formContext = useFormContext(); + + const flattenedScreenComponents = getAllSurveyComponents(surveyScreens); + + // Populate the form with the survey response data - this is not in in the onSuccess hook because it doesn't get called if the response has previously been fetched and is in the cache + useEffect(() => { + if (!surveyResponse?.id || !isSuccess || !flattenedScreenComponents?.length) return; + const primaryEntityQuestion = flattenedScreenComponents.find( + component => component.type === QuestionType.PrimaryEntity, + ); + + const dateOfDataQuestion = flattenedScreenComponents.find( + component => + component.type === QuestionType.DateOfData || + component.type === QuestionType.SubmissionDate, + ); + // handle updating answers here - if this is done in the component, the answers get reset on every re-render + const formattedAnswers = Object.entries(surveyResponse.answers).reduce((acc, [key, value]) => { + // If the value is a stringified object, parse it + const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); + const question = flattenedScreenComponents.find(component => component.questionId === key); + if (!question) return acc; + if (question.type === QuestionType.File && value) { + // If the value is a file, split the value to get the file name + const withoutPrefix = value.split('files/'); + const fileNameParts = withoutPrefix[withoutPrefix.length - 1].split('_'); + // remove first element of the array as it is the file id + const fileName = fileNameParts.slice(1).join('_'); + return { ...acc, [key]: { name: fileName, value } }; + } + + if ( + (question.type === QuestionType.Date || question.type === QuestionType.DateTime) && + value + ) { + // strip timezone from date so that it gets displayed the same no matter the user's timezone + return { ...acc, [key]: stripTimezoneFromDate(value) }; + } + + return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; + }, {}); + + // Add the primary entity and date of data to the form data + if (primaryEntityQuestion && surveyResponse.entityId) { + formattedAnswers[primaryEntityQuestion.questionId] = surveyResponse.entityId; + } + + if (dateOfDataQuestion && surveyResponse.dataTime) { + formattedAnswers[dateOfDataQuestion.questionId] = surveyResponse.dataTime; + } + + // combine this so that formData always takes precedence + const newData = { + ...formattedAnswers, + ...formData, + }; + + setFormData(newData); + // Reset the form context with the new answers, to trigger re-render of the form + formContext.reset(newData); + }, [surveyResponse?.id, isSuccess, flattenedScreenComponents?.length]); + + return { surveyLoading }; +}; diff --git a/packages/datatrak-web/src/features/Survey/utils.ts b/packages/datatrak-web/src/features/Survey/utils/utils.ts similarity index 73% rename from packages/datatrak-web/src/features/Survey/utils.ts rename to packages/datatrak-web/src/features/Survey/utils/utils.ts index 3d8896aeb2..1edab0177c 100644 --- a/packages/datatrak-web/src/features/Survey/utils.ts +++ b/packages/datatrak-web/src/features/Survey/utils/utils.ts @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ import { QuestionType } from '@tupaia/types'; -import { SurveyScreen } from '../../types'; +import { SurveyScreen, SurveyScreenComponent } from '../../../types'; const validateSurveyComponent = component => { if (component.type === QuestionType.PrimaryEntity && !component.config?.entity?.createNew) { @@ -67,3 +67,21 @@ export const getErrorsByScreen = ( }, {}) ?? {} ); }; + +export const getParentQuestionId = (question: SurveyScreenComponent) => { + return question?.config?.entity?.filter?.parentId?.questionId; +}; + +// Get the parent question ids recursively for the primary entity question +export const getPrimaryEntityParentQuestionIds = ( + entityQuestion: SurveyScreenComponent, + questions: SurveyScreenComponent[], +) => { + const parentQuestionId = getParentQuestionId(entityQuestion); + const parentQuestion = + parentQuestionId && questions.find(question => question.id === parentQuestionId); + + return parentQuestion + ? [parentQuestionId, ...getPrimaryEntityParentQuestionIds(parentQuestion, questions)] + : []; +}; diff --git a/packages/datatrak-web/src/features/SurveyResponseModal.tsx b/packages/datatrak-web/src/features/SurveyResponseModal.tsx new file mode 100644 index 0000000000..ad074ebb3c --- /dev/null +++ b/packages/datatrak-web/src/features/SurveyResponseModal.tsx @@ -0,0 +1,160 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { useSearchParams } from 'react-router-dom'; +import styled from 'styled-components'; +import { Dialog, Typography } from '@material-ui/core'; +import { + ModalContentProvider, + ModalFooter, + ModalHeader, + SpinningLoader, +} from '@tupaia/ui-components'; +import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { useSurveyResponse } from '../api/queries'; +import { Button, SurveyTickIcon } from '../components'; +import { displayDate } from '../utils'; +import { SurveyReviewSection, useSurveyResponseWithForm } from './Survey'; +import { SurveyContext } from '.'; + +const Header = styled.div` + display: flex; + align-items: center; + padding: 0.5rem; + width: 100%; + + .MuiSvgIcon-root { + font-size: 2.5em; + margin-right: 0.35em; + } +`; + +const Heading = styled(Typography).attrs({ + variant: 'h2', +})` + font-size: 1.5rem; + color: ${({ theme }) => theme.palette.text.primary}; + font-weight: 600; + margin-bottom: 0.2rem; + ${({ theme }) => theme.breakpoints.down('sm')} { + font-size: 1rem; + } +`; + +const SubHeading = styled(Typography)` + color: ${({ theme }) => theme.palette.text.secondary}; + font-weight: 400; + font-size: 1rem; + ${({ theme }) => theme.breakpoints.down('sm')} { + font-size: 0.875rem; + } +`; + +const Loader = styled(SpinningLoader)` + width: 25rem; + max-width: 100%; +`; + +const Content = styled.div` + width: 62rem; + max-width: 100%; +`; + +const getSubHeadingText = surveyResponse => { + if (!surveyResponse) { + return null; + } + const date = displayDate(surveyResponse.dataTime); + const { entityName, entityParentName } = surveyResponse; + const location = [entityName, entityParentName].filter(Boolean).join(' | '); + return `${location} ${date}`; +}; +interface SurveyResponseModalContentProps { + onClose: () => void; + surveyResponse?: DatatrakWebSingleSurveyResponseRequest.ResBody; + error?: Error; + isLoading?: boolean; +} + +// Needs to be wrapped in a context provider to provide the form data to the form +const SurveyResponseModalContent = ({ + onClose, + isLoading, + surveyResponse, + error, +}: SurveyResponseModalContentProps) => { + const { surveyLoading } = useSurveyResponseWithForm(surveyResponse); + const subHeading = getSubHeadingText(surveyResponse); + const showLoading = isLoading || surveyLoading; + + return ( + <> + + {!showLoading && !error && ( +
    + +
    + {surveyResponse?.surveyName} + {subHeading} +
    +
    + )} +
    + + + {showLoading && } + {!showLoading && !error && } + + + + + + + ); +}; + +export const SurveyResponseModal = () => { + const formContext = useForm(); + const [urlSearchParams, setUrlSearchParams] = useSearchParams(); + + const surveyResponseId = urlSearchParams.get('responseId'); + + const { + data: surveyResponse, + isLoading, + error, + isFetched, + } = useSurveyResponse(surveyResponseId, { meta: { applyCustomErrorHandling: true } }); + + const isLoadingSurveyResponse = isLoading || !isFetched; + + const onClose = () => { + // Redirect to the previous page by removing all the query params + urlSearchParams.delete('responseId'); + setUrlSearchParams(urlSearchParams); + }; + + if (!surveyResponseId) return null; + + return ( + + + + + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx new file mode 100644 index 0000000000..27e72aef09 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx @@ -0,0 +1,81 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect, useState } from 'react'; +import throttle from 'lodash.throttle'; +import { Country, DatatrakWebUsersRequest } from '@tupaia/types'; +import { Autocomplete } from '../../components'; +import { useSurveyUsers } from '../../api'; +import { Survey } from '../../types'; + +type User = DatatrakWebUsersRequest.ResBody[0]; + +interface AssigneeInputProps { + value: User | null; + onChange: (value: User | null) => void; + inputRef?: React.Ref; + countryCode?: Country['code']; + surveyCode?: Survey['code']; + required?: boolean; + name?: string; + error?: boolean; + disabled?: boolean; +} + +export const AssigneeInput = ({ + value: selectedValue, + onChange, + inputRef, + countryCode, + surveyCode, + required, + error, + disabled, +}: AssigneeInputProps) => { + const [searchValue, setSearchValue] = useState(''); + + const { data: users = [], isLoading } = useSurveyUsers(surveyCode, countryCode, searchValue); + + const onChangeAssignee = (_e, newSelection: User | null) => { + onChange(newSelection ?? null); + }; + + const options = + users?.map(user => ({ + ...user, + value: user.id, + label: user.name, + })) ?? []; + + //If we programmatically set the value of the input, we need to update the search value + useEffect(() => { + // if the selection is the same as the search value, do not update the search value + if (selectedValue?.name === searchValue || isLoading) return; + + setSearchValue(selectedValue?.name ?? ''); + }, [JSON.stringify(selectedValue)]); + + return ( + { + if (!e) return; + setSearchValue(newValue); + }, 100)} + inputValue={selectedValue?.name ?? searchValue} + getOptionLabel={option => option.label} + getOptionSelected={(option, selected) => option.id === selected?.id} + placeholder="Search..." + loading={isLoading} + required={required} + error={error} + disabled={disabled} + /> + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CancelTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CancelTaskModal.tsx new file mode 100644 index 0000000000..aa95e083b3 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CancelTaskModal.tsx @@ -0,0 +1,62 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { Typography } from '@material-ui/core'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; +import styled from 'styled-components'; +import { TaskSummary } from './TaskSummary'; +import { SingleTaskResponse } from '../../types'; + +const Container = styled(ModalCenteredContent)` + width: 27rem; + max-width: 100%; + margin: 0 auto; + padding-block: 2rem; +`; + +interface CancelTaskModalProps { + task: SingleTaskResponse; + isOpen: boolean; + onClose: () => void; + onCancelTask: () => void; + isLoading: boolean; +} +export const CancelTaskModal = ({ + task, + isOpen, + onClose, + onCancelTask, + isLoading, +}: CancelTaskModalProps) => ( + + + + + Are you sure you would like to cancel this task? This cannot be undone. + + + +); 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..539e97afe3 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CommentsCount.tsx @@ -0,0 +1,37 @@ +/** + * 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 { Tooltip } from '@tupaia/ui-components'; +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 new file mode 100644 index 0000000000..27f2852ca3 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -0,0 +1,298 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { LoadingContainer, Modal, TextField } from '@tupaia/ui-components'; +import { ButtonProps } from '@material-ui/core'; +import { useNavigate } from 'react-router'; +import { ROUTES } from '../../../constants'; +import { useCreateTask, useEditUser, useUser } from '../../../api'; +import { CountrySelector, useUserCountries } from '../../CountrySelector'; +import { GroupedSurveyList } from '../../GroupedSurveyList'; +import { DueDatePicker } from '../DueDatePicker'; +import { AssigneeInput } from '../AssigneeInput'; +import { TaskForm } from '../TaskForm'; +import { RepeatScheduleInput } from '../RepeatScheduleInput'; +import { EntityInput } from './EntityInput'; + +const CountrySelectorWrapper = styled.div` + display: flex; + justify-content: flex-end; + .MuiInputBase-input.MuiSelect-selectMenu { + font-size: 0.75rem; + } +`; + +const ListSelectWrapper = styled.div` + margin-block-end: 1.8rem; + .list-wrapper { + height: 15rem; + max-height: 15rem; + padding: 1rem; + } + + .entity-selector-content { + padding-block: 1rem; + border: 1px solid ${({ theme }) => theme.palette.divider}; + border-radius: 3px; + .MuiFormControl-root { + width: auto; + margin-inline: 1rem; + padding-block-end: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } + .list-wrapper { + border-top: 0; + margin-block-start: 0; + padding-block-start: 0; + } + } +`; + +const InputRow = styled.div` + display: flex; + justify-content: space-between; + margin-block-end: 1.2rem; + > div { + width: 48%; + margin-block-end: 0; + } +`; + +const CommentsInput = styled(TextField).attrs({ + multiline: true, + variant: 'outlined', + fullWidth: true, + rows: 4, +})` + .MuiOutlinedInput-inputMultiline { + padding-inline: 1rem; + } +`; + +const Wrapper = styled.div` + .loading-screen { + border: none; + background-color: ${({ theme }) => theme.palette.background.paper}; + .MuiTypography-h5 { + font-size: 1.125rem; + } + } +`; + +interface CreateTaskModalProps { + onClose: () => void; +} + +export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { + const navigate = useNavigate(); + const navigateToProjectScreen = () => { + navigate(ROUTES.PROJECT_SELECT); + }; + const { mutate: editUser } = useEditUser(navigateToProjectScreen); + + const generateDefaultDueDate = () => { + const now = new Date(); + now.setHours(23, 59, 59); + return new Date(now); + }; + + const defaultDueDate = generateDefaultDueDate(); + const defaultValues = { + survey_code: null, + entity_id: null, + due_date: defaultDueDate, + repeat_frequency: null, + repeat_schedule: null, + assignee: null, + }; + const formContext = useForm({ + mode: 'onChange', + defaultValues, + }); + const { + handleSubmit, + control, + setValue, + watch, + register, + formState: { isValid, dirtyFields }, + } = formContext; + + const handleCountriesError = async (error: any) => { + if (error?.code !== 403) return; + // in this case it is a permissions error, so the user needs to be redirected to the project screen after the user's project is updated + editUser({ projectId: null }); + }; + + const { + countries, + selectedCountry, + updateSelectedCountry, + isLoading: isLoadingCountries, + } = useUserCountries(handleCountriesError); + const { isLoading: isLoadingUser, isFetching: isFetchingUser } = useUser(); + + const isLoadingData = isLoadingCountries || isLoadingUser || isFetchingUser; + const { mutate: createTask, isLoading: isSaving } = useCreateTask(onClose); + + const buttons: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; // typing here because simply giving 'outlined' as default value is causing a type mismatch error + id: string; + disabled?: boolean; + }[] = [ + { + text: 'Cancel', + onClick: onClose, + variant: 'outlined', + id: 'cancel', + disabled: isSaving, + }, + { + text: 'Save', + onClick: handleSubmit(createTask), + id: 'save', + disabled: !isValid || isSaving || isLoadingData, + }, + ]; + + useEffect(() => { + if (!selectedCountry?.code) return; + const { survey_code: surveyCode, entity_id: entityId } = dirtyFields; + // reset surveyCode and entityId when country changes, if they are dirty + if (surveyCode) { + setValue('survey_code', null, { shouldValidate: true }); + } + if (entityId) { + setValue('entity_id', null, { shouldValidate: true }); + } + }, [selectedCountry?.code]); + + const surveyCode = watch('survey_code'); + const dueDate = watch('due_date'); + + return ( + + + + + + + + + ( + + + + )} + /> + { + return ( + + + + ); + }} + /> + + { + return ( + + ); + }} + /> + ( + + )} + /> + + + + ( + + )} + /> + + + + + + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx new file mode 100644 index 0000000000..9b46775ee4 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx @@ -0,0 +1,78 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { Country, EntityTypeEnum, QuestionType } from '@tupaia/types'; +import { EntitySelector } from '../../EntitySelector'; +import { useCurrentUserContext, useSurvey } from '../../../api'; +import { getAllSurveyComponents } from '../../Survey'; + +interface EntityInputProps { + onChange: (value: string) => void; + value: string; + selectedCountry?: Country | null; + inputRef?: React.Ref; + name: string; + invalid?: boolean; + surveyCode?: string; +} + +export const EntityInput = ({ + onChange, + value, + selectedCountry, + inputRef, + name, + invalid, + surveyCode, +}: EntityInputProps) => { + const user = useCurrentUserContext(); + const { data: survey, isLoading: isLoadingSurvey } = useSurvey(surveyCode); + const getPrimaryEntityQuestionConfig = () => { + if (!survey) return null; + const flattenedQuestions = getAllSurveyComponents(survey.screens ?? []); + const primaryEntityQuestion = flattenedQuestions.find( + question => question.type === QuestionType.PrimaryEntity, + ); + if (primaryEntityQuestion?.config?.entity?.filter) return primaryEntityQuestion.config; + // default to country filter if no primary entity question is found or it doesn't have an entity filter + return { + entity: { + filter: { + type: EntityTypeEnum.country, + }, + }, + }; + }; + + const primaryEntityQuestionConfig = getPrimaryEntityQuestionConfig(); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts new file mode 100644 index 0000000000..1c6b767975 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { CreateTaskModal } from './CreateTaskModal'; diff --git a/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx new file mode 100644 index 0000000000..124b77f9a0 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx @@ -0,0 +1,135 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect, useState } from 'react'; +import { format, isValid } from 'date-fns'; +import styled from 'styled-components'; +import { DatePicker } from '@tupaia/ui-components'; + +const Wrapper = styled.div` + .MuiFormControl-root { + margin-block-end: 0; + } + .MuiButtonBase-root.MuiIconButton-root { + color: ${props => props.theme.palette.primary.main}; + } + .MuiInputBase-input { + padding-inline-end: 0; + padding-inline-start: 1rem; + font-size: inherit; + line-height: normal; + color: inherit; + } + .MuiInputAdornment-positionEnd { + margin-inline-start: 0; + } + .MuiOutlinedInput-adornedEnd { + padding-inline-end: 0; + padding-inline-start: 0; + } + .MuiFormLabel-root { + margin-block-end: 0.25rem; + line-height: 1.2; + } + .MuiSvgIcon-root { + font-size: 1rem; + } +`; + +interface DueDatePickerProps { + value?: string | null; + onChange: (value: string | null) => void; + disablePast?: boolean; + fullWidth?: boolean; + required?: boolean; + label?: string; + inputRef?: React.Ref; + invalid?: boolean; + helperText?: string; + disabled?: boolean; +} + +export const DueDatePicker = ({ + value, + onChange, + label, + disablePast, + fullWidth, + required, + inputRef, + invalid, + helperText, + disabled, +}: DueDatePickerProps) => { + const [date, setDate] = useState(value ?? null); + + // update in local state to be the end of the selected date + // this is also to handle invalid dates, so the filter doesn't get updated until a valid date is selected/entered + const updateSelectedDate = (newValue: string | null) => { + if (!newValue) return setDate(''); + if (!isValid(new Date(newValue))) return setDate(''); + const endOfDay = new Date(new Date(newValue).setHours(23, 59, 59, 999)); + + // format the date to include timezone + const newDate = format(endOfDay, `yyyy-MM-dd'T'HH:mm:ss.SSSXXX`); + + setDate(newDate); + }; + + // if the date is updated, update the value + useEffect(() => { + if (date === value) return; + onChange(date); + }, [date]); + + // if the value is updated, update the local state. This is to handle, for example, dates that are updated from the URL params + useEffect(() => { + if (value === date) return; + + setDate(value ?? ''); + }, [value]); + + const getLocaleDateFormat = () => { + const localeCode = window.navigator.language; + const parts = new Intl.DateTimeFormat(localeCode).formatToParts(); + return parts + .map(({ type, value: partValue }) => { + switch (type) { + case 'year': + return 'yyyy'; + case 'month': + return 'mm'; + case 'day': + return 'dd'; + default: + return partValue; + } + }) + .join(''); + }; + + const placeholder = getLocaleDateFormat(); + + return ( + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/NoTasksSection.tsx b/packages/datatrak-web/src/features/Tasks/NoTasksSection.tsx new file mode 100644 index 0000000000..c7b33a3e85 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/NoTasksSection.tsx @@ -0,0 +1,94 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import styled from 'styled-components'; +import { Typography } from '@material-ui/core'; +import { Button as UIButton } from '@tupaia/ui-components'; +import { Link } from 'react-router-dom'; +import { ROUTES } from '../../constants'; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + + ${({ theme }) => theme.breakpoints.down('sm')} { + display: none; + } +`; + +const Image = styled.img.attrs({ + src: '/tupaia-high-five.svg', + alt: 'Illustration of two hands giving a high five', +})` + flex: 1; + height: auto; + min-height: 5rem; + width: auto; + margin: 0 auto 1rem; +`; + +const Text = styled(Typography)` + text-align: center; + font-size: 0.9rem; + line-height: 1.5; + margin-block-end: 0.5rem; +`; + +const Button = styled(UIButton)` + padding: 0.25rem 1rem; + margin-block-end: 0.5rem; + + .MuiButton-label { + font-size: 0.75rem; + } +`; + +const Desktop = () => ( + + + + Congratulations, you have no tasks to complete! You can view all other tasks for your project + using the button below. + + + +); + +const MobileContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + p { + flex: 1; + text-align: left; + margin-inline-end: 1rem; + margin-block-end: 0; + } + + a.MuiButtonBase-root { + display: inline-block; + } + + ${({ theme }) => theme.breakpoints.up('sm')} { + display: none; + } +`; +const Mobile = () => ( + + You have no tasks to complete. + +); + +export const NoTasksSection = () => ( + <> + + + +); diff --git a/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx new file mode 100644 index 0000000000..786e3f6065 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx @@ -0,0 +1,57 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useEffect } from 'react'; +import { FormControl } from '@material-ui/core'; +import { Autocomplete } from '../../components'; +import { getRepeatScheduleOptions } from './utils'; + +interface RepeatScheduleInputProps { + value: string | null; + onChange: ( + value: React.ChangeEvent<{ + name?: string | undefined; + value: string | null; + }> | null, + ) => void; + disabled?: boolean; + dueDate?: string | null; +} + +export const RepeatScheduleInput = ({ + value = null, + onChange, + disabled, + dueDate, +}: RepeatScheduleInputProps) => { + const repeatScheduleOptions = getRepeatScheduleOptions(dueDate); + + useEffect(() => { + if (!dueDate) { + onChange(null); + } + }, [dueDate]); + + const selectedOption = + repeatScheduleOptions.find(option => option.value === value) ?? repeatScheduleOptions[0]; + + return ( + + { + return onChange(newValue?.value ?? null); + }} + disabled={!dueDate || disabled} + options={repeatScheduleOptions} + getOptionLabel={option => option.label} + label="Repeating task" + muiProps={{ + disableClearable: !value, + }} + /> + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/StatusPill.tsx b/packages/datatrak-web/src/features/Tasks/StatusPill.tsx new file mode 100644 index 0000000000..c2044f51af --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/StatusPill.tsx @@ -0,0 +1,60 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { TaskStatus } from '@tupaia/types'; +import { theme } from '../../theme'; +import { TaskStatusType } from '../../types'; + +const Pill = styled.span<{ + $color: string; +}>` + background-color: ${({ $color }) => `${$color}22`}; + color: ${({ $color }) => $color}; + font-size: 0.625rem; + padding-inline: 0.7rem; + padding-block: 0.2rem; + border-radius: 20px; + .cell-content > div:has(&) { + overflow: visible; + } +`; + +export const STATUS_VALUES = { + [TaskStatus.to_do]: { + label: 'To do', + color: '#1172D1', + }, + [TaskStatus.completed]: { + label: 'Complete', + color: '#19934E', + }, + [TaskStatus.cancelled]: { + label: 'Cancelled', + color: theme.palette.text.secondary, + }, + overdue: { + label: 'Overdue', + color: theme.palette.error.main, + }, + repeating: { + label: 'Repeating', + color: '#4101C9', + }, +}; + +export const StatusPill = ({ status }: { status: TaskStatusType }) => { + if (!status) { + return null; + } + const statusInfo = STATUS_VALUES[status]; + // If the status is not found, return null. This should not happen in practice, but it's a good idea to handle it. + if (!statusInfo) { + return null; + } + const { label, color } = statusInfo; + return {label}; +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx new file mode 100644 index 0000000000..80c66631ca --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskActionsMenu.tsx @@ -0,0 +1,56 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import { IconButton } from '@material-ui/core'; +import { TaskStatus } from '@tupaia/types'; +import { ActionsMenu } from '@tupaia/ui-components'; +import styled from 'styled-components'; +import { SingleTaskResponse } from '../../types'; +import { useEditTask } from '../../api'; +import { CancelTaskModal } from './CancelTaskModal'; + +const MenuButton = styled(IconButton)` + &.MuiIconButton-root { + padding: 0.4rem; + margin-left: 0; + } +`; + +export const TaskActionsMenu = ({ task }: { task: SingleTaskResponse }) => { + const [isOpen, setIsOpen] = useState(false); + const onOpen = () => setIsOpen(true); + const onClose = () => setIsOpen(false); + + const { mutate, isLoading } = useEditTask(task.id, onClose); + + const onCancelTask = () => { + mutate({ status: TaskStatus.cancelled }); + }; + + const actions = [ + { + label: 'Cancel task', + action: onOpen, + }, + ]; + + if (task.taskStatus === TaskStatus.cancelled || task.taskStatus === TaskStatus.completed) { + return null; + } + + return ( + <> + + + + ); +}; 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..fda1510174 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskComments.tsx @@ -0,0 +1,226 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { format } from 'date-fns'; +import styled from 'styled-components'; +import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router'; +import { Typography } from '@material-ui/core'; +import { + TaskCommentType, + SystemCommentSubType, + TaskCommentTemplateVariables, + TaskComment, +} from '@tupaia/types'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; +import { TextField } from '@tupaia/ui-components'; +import { displayDateTime } from '../../../utils'; +import { SingleTaskResponse } from '../../../types'; +import { TaskForm } from '../TaskForm'; +import { Button } from '../../../components'; +import { useCreateTaskComment } from '../../../api'; +import { capsToSentenceCase } from '../utils'; + +const TaskCommentsDisplayContainer = styled.div` + width: 100%; + border: 1px solid ${({ theme }) => theme.palette.divider}; + background-color: ${({ theme }) => theme.palette.background.default}; + padding: 1rem; + border-radius: 4px; + overflow-y: auto; + flex: 1; + max-height: 18rem; +`; + +const CommentContainer = styled.div` + padding-block: 0.4rem; + &:not(:last-child) { + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } +`; + +const CommentsInput = styled(TextField).attrs({ + multiline: true, + variant: 'outlined', + fullWidth: true, + rows: 5, +})` + margin-block: 1.2rem; + height: 9.5rem; + .MuiOutlinedInput-inputMultiline.MuiInputBase-input { + padding-inline: 1rem; + padding-block: 1rem; + } +`; + +const Message = styled(Typography).attrs({ + variant: 'body2', +})` + margin-block-start: 0.2rem; +`; + +const UserMessage = styled(Message).attrs({ + color: 'textPrimary', +})` + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const Form = styled(TaskForm)` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + align-items: flex-end; +`; + +const CommentDetails = styled(Typography).attrs({ + variant: 'body2', +})` + color: ${({ theme }) => theme.palette.grey[400]}; +`; + +type Comments = SingleTaskResponse['comments']; + +const getFriendlyFieldName = field => { + if (field === 'assignee_id') { + return 'assignee'; + } + if (field === 'repeat_schedule') { + return 'recurring task'; + } + + // Default to replacing underscores with spaces + return field.replace(/_/g, ' '); +}; + +const formatValue = (field, value) => { + switch (field) { + case 'assignee_id': { + return value ?? 'Unassigned'; + } + case 'repeat_schedule': { + if (value === null || value === undefined) { + return "Doesn't repeat"; + } + + const frequency = Object.keys(RRULE_FREQUENCIES).find( + key => RRULE_FREQUENCIES[key] === value, + ); + + if (!frequency) { + return "Doesn't repeat"; + } + + // format the frequency to be more human-readable + return capsToSentenceCase(frequency); + } + case 'due_date': { + return value ? format(new Date(value), 'do MMMM yy') : 'No due date'; + } + default: { + if (!value) return 'No value'; + // Default to capitalizing the value's first character, and replacing underscores with spaces + const words = value.replace(/_/g, ' '); + return `${words.charAt(0).toUpperCase()}${words.slice(1)}`; + } + } +}; + +const generateSystemComment = templateVariables => { + const { type } = templateVariables; + if (type === SystemCommentSubType.complete) { + return 'Completed this task'; + } + if (type === SystemCommentSubType.create) { + return 'Created this task'; + } + if (type === SystemCommentSubType.overdue) { + return 'Overdue reminder email sent'; + } + + const { originalValue, newValue, field } = templateVariables; + const friendlyFieldName = getFriendlyFieldName(field); + const formattedOriginalValue = formatValue(field, originalValue); + const formattedNewValue = formatValue(field, newValue); + // generate a comment for the change + return `Changed ${friendlyFieldName} from ${formattedOriginalValue} to ${formattedNewValue}`; +}; + +const SystemComment = ({ + templateVariables, + message, +}: { + templateVariables: TaskCommentTemplateVariables; + message?: TaskComment['message']; +}) => { + // Handle the case where the message is provided, for backwards compatibility + const messageText = message ?? generateSystemComment(templateVariables); + return {messageText}; +}; + +const UserComment = ({ message }: { message: Comments[0]['message'] }) => { + if (!message) return null; + return ( + <> + {message.split('\n').map(line => ( + {line} + ))} + + ); +}; + +const SingleComment = ({ comment }: { comment: Comments[0] }) => { + const { createdAt, type, userName, message, templateVariables, userId } = comment; + + return ( + + + {displayDateTime(createdAt)} - {userName} {!userId ? '(user deleted)' : ''} + + + {type === TaskCommentType.system ? ( + + ) : ( + + )} + + ); +}; + +export const TaskComments = ({ comments }: { comments: Comments }) => { + const { taskId } = useParams(); + + const { + register, + handleSubmit, + reset, + formState: { isDirty }, + } = useForm({ + defaultValues: { + comment: '', + }, + }); + + const { mutate: createTaskComment, isLoading: isSaving } = useCreateTaskComment(taskId, reset); + + const onSubmit = data => { + createTaskComment(data.comment); + }; + + 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 new file mode 100644 index 0000000000..11a16bba74 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -0,0 +1,277 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import styled from 'styled-components'; +import { Paper, Typography } from '@material-ui/core'; +import { TaskStatus } from '@tupaia/types'; +import { LoadingContainer } from '@tupaia/ui-components'; +import { useEditTask, useSurveyResponse } from '../../../api'; +import { displayDate } from '../../../utils'; +import { Button as BaseButton, SurveyTickIcon, Tile } from '../../../components'; +import { SingleTaskResponse } from '../../../types'; +import { RepeatScheduleInput } from '../RepeatScheduleInput'; +import { DueDatePicker } from '../DueDatePicker'; +import { AssigneeInput } from '../AssigneeInput'; +import { TaskForm } from '../TaskForm'; +import { TaskMetadata } from './TaskMetadata'; +import { TaskComments } from './TaskComments'; + +const Container = styled(Paper).attrs({ + variant: 'outlined', +})` + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: min(2.5rem, 2%); + ${({ theme }) => theme.breakpoints.up('md')} { + flex-direction: row; + padding: 2.5rem; + } +`; + +const MainColumn = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + margin-block: 1.2rem; + border-color: ${({ theme }) => theme.palette.divider}; + border-style: solid; + border-width: 1px 0; + padding-block: 1.2rem; + ${({ theme }) => theme.breakpoints.up('md')} { + width: 50%; + margin-block: 0; + padding-inline: 1.2rem; + padding-block: 0; + border-width: 0 1px; + } +`; + +const SideColumn = styled.div` + display: flex; + flex-direction: column; + ${({ theme }) => theme.breakpoints.up('md')} { + width: 25%; + } + + a.MuiButton-root { + border: 1px solid ${({ theme }) => theme.palette.divider}; + } +`; + +const ItemWrapper = styled.div` + &:not(:last-child) { + margin-block-end: 1.2rem; + } +`; + +const Button = styled(BaseButton).attrs({ + variant: 'outlined', +})` + &:disabled { + color: ${({ theme }) => theme.palette.primary.main}; + border-color: ${({ theme }) => theme.palette.primary.main}; + opacity: 0.3; + } +`; + +const ClearButton = styled(Button).attrs({ + variant: 'text', +})` + padding-inline: 0.5rem; +`; + +const ButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + align-items: flex-end; + flex: 1; +`; + +const Form = styled(TaskForm)` + display: flex; + flex-direction: column; +`; + +const Wrapper = styled.div` + .loading-screen { + border: 1px solid ${({ theme }) => theme.palette.divider}; + } +`; + +const SectionHeading = styled(Typography).attrs({ + variant: 'h2', +})` + font-size: 0.875rem; + line-height: 1.3; + font-weight: 500; + margin-bottom: 0.25rem; +`; + +const InitialRequest = ({ initialRequestId }) => { + const { data: surveyResponse, isLoading } = useSurveyResponse(initialRequestId, { + meta: { applyCustomErrorHandling: true }, + }); + if (isLoading || !surveyResponse) { + return null; + } + const { id, countryName, dataTime, surveyName, entityName } = surveyResponse; + return ( + + {surveyName} +
    + {entityName} + + } + Icon={SurveyTickIcon} + > + {countryName}, {displayDate(dataTime as Date)} +
    + ); +}; + +export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { + const generateDefaultValues = (task: SingleTaskResponse) => { + return { + due_date: task.taskDueDate ?? null, + repeat_frequency: task.repeatSchedule?.freq ?? null, + assignee: task.assignee?.id ? task.assignee : null, + }; + }; + const [defaultValues, setDefaultValues] = useState(generateDefaultValues(task)); + + const formContext = useForm({ + mode: 'onChange', + defaultValues, + }); + const { + control, + handleSubmit, + watch, + formState: { dirtyFields }, + reset, + } = formContext; + + const { mutate: editTask, isLoading: isSaving } = useEditTask(task.id); + + const isDirty = Object.keys(dirtyFields).length > 0; + + const onClearEdit = () => { + reset(); + }; + + // Reset form when task changes, i.e after task is saved and the task is re-fetched + useEffect(() => { + const newDefaultValues = generateDefaultValues(task); + + setDefaultValues(newDefaultValues); + + reset(newDefaultValues); + }, [JSON.stringify(task)]); + + const canEditFields = + task.taskStatus !== TaskStatus.completed && task.taskStatus !== TaskStatus.cancelled; + + const dueDate = watch('due_date'); + + const onSubmit = data => { + const updatedFields = Object.keys(dirtyFields).reduce((acc, key) => { + acc[key] = data[key]; + return acc; + }, {}); + + editTask(updatedFields); + }; + + return ( + + + + +
    + + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + {canEditFields && ( + + + Clear changes + + + + )} +
    +
    + + + + + Initial request + {task.initialRequestId && } + +
    +
    +
    + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx new file mode 100644 index 0000000000..096fba9b20 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskMetadata.tsx @@ -0,0 +1,102 @@ +/** + * 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 { StatusPill } from '../StatusPill'; +import { useEntityByCode } from '../../../api'; +import { SingleTaskResponse } from '../../../types'; + +const Container = styled.div` + border: 1px solid ${props => props.theme.palette.divider}; + padding-block: 1.2rem; + padding-inline: 1rem; + border-radius: 4px; + background-color: ${props => props.theme.palette.background.default}; +`; + +const Title = styled(Typography).attrs({ + variant: 'h2', + color: 'textSecondary', +})` + font-size: 0.875rem; + font-weight: normal; + margin-block-end: 0.2rem; +`; + +const Value = styled(Typography)` + font-size: 0.875rem; +`; + +const Bold = styled.span` + font-weight: ${props => props.theme.typography.fontWeightMedium}; +`; + +const DataWrapper = styled.div` + width: 100%; + &:not(:first-child) { + margin-block-start: 1rem; + } +`; + +const Row = styled(DataWrapper)` + display: flex; + justify-content: space-between; + align-items: flex-start; +`; + +const Pin = styled.img.attrs({ + src: '/tupaia-pin.svg', + ['aria-hidden']: true, // this pin is not of any use to the screen reader, so hide from the screen reader +})` + width: auto; + height: 1rem; + margin-inline-end: 0.5rem; +`; + +const CountryWrapper = styled.div` + display: flex; + width: 100%; + justify-content: flex-end; +`; + +export const TaskMetadata = ({ task }: { task?: SingleTaskResponse }) => { + const { data: country } = useEntityByCode(task?.entity?.countryCode, { + enabled: !!task?.entity?.countryCode, + }); + if (!task) return null; + const { survey, entity, taskStatus } = task; + + return ( + + + + Survey + + {survey?.name} + + + + + + {country?.name} + + + + + + Entity + + {entity?.name} {entity?.parentName && <>| {entity?.parentName}} + + + + Status + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/index.ts b/packages/datatrak-web/src/features/Tasks/TaskDetails/index.ts new file mode 100644 index 0000000000..e7ded14c82 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { TaskDetails } from './TaskDetails'; diff --git a/packages/datatrak-web/src/features/Tasks/TaskForm.tsx b/packages/datatrak-web/src/features/Tasks/TaskForm.tsx new file mode 100644 index 0000000000..41b1766c04 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskForm.tsx @@ -0,0 +1,42 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import styled from 'styled-components'; + +export const TaskForm = styled.form` + .MuiFormLabel-root { + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + margin-block-end: 0.2rem; + font-size: 0.875rem; + &.Mui-disabled { + color: initial; + } + } + .MuiFormLabel-asterisk { + color: ${({ theme }) => theme.palette.error.main}; + } + .MuiInputBase-root { + font-size: 0.875rem; + &.Mui-disabled { + background-color: ${({ theme }) => theme.palette.background.default}; + } + } + .MuiInputBase-input { + padding-block: 0.9rem; + &.Mui-disabled { + background-color: transparent; + } + } + .MuiInputBase-input::placeholder { + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: inherit; + } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.divider}; + } + .MuiInputBase-root.Mui-error { + background-color: transparent; + } +`; diff --git a/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx b/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx new file mode 100644 index 0000000000..8023efdd12 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx @@ -0,0 +1,96 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Typography } from '@material-ui/core'; +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; +import { ArrowLeftIcon, Button, TaskIcon } from '../../components'; +import { useFromLocation } from '../../utils'; + +const BackButton = styled(Button)` + min-width: 0; + color: ${({ theme }) => theme.palette.text.primary}; + padding: 0.7rem; + border-radius: 50%; + .MuiSvgIcon-root { + font-size: 1.3rem; + } +`; + +const Wrapper = styled.div` + padding-block: 0.7rem; + display: flex; + align-items: self-start; + padding-inline-end: 2.7rem; + ${({ theme }) => theme.breakpoints.down('xs')} { + flex-direction: column; + align-items: flex-start; + padding-inline-end: 0; + } +`; + +const HeadingContainer = styled.div` + display: flex; + align-items: center; + margin-inline-end: 1.2rem; + ${({ theme }) => theme.breakpoints.down('xs')} { + margin-inline-end: 0; + } +`; + +const Title = styled(Typography).attrs({ + variant: 'h1', +})` + font-size: 1.5rem; + margin-inline-start: 0.7rem; + ${({ theme }) => theme.breakpoints.down('xs')} { + font-size: 1.2rem; + } +`; + +const Container = styled.div` + display: flex; + align-items: center; +`; + +const ContentWrapper = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + flex: 1; + ${({ theme }) => theme.breakpoints.down('xs')} { + padding-inline-start: 1rem; + flex-direction: column; + padding-inline-end: 0.6rem; + } +`; + +export const TaskPageHeader = ({ + title, + children, + backTo, +}: { + title: string; + children?: ReactNode; + backTo?: string; +}) => { + const from = useFromLocation(); + return ( + + + + + + + + {title} + + + {children} + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskSummary.tsx b/packages/datatrak-web/src/features/Tasks/TaskSummary.tsx new file mode 100644 index 0000000000..b137c00d6e --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskSummary.tsx @@ -0,0 +1,115 @@ +/* + * 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 { SingleTaskResponse } from '../../types'; +import { displayDate } from '../../utils'; +import { getDisplayRepeatSchedule } from './utils'; +import { StatusPill } from './StatusPill'; + +const MetaDataContainer = styled.div` + padding-inline: 1rem; + padding-block-start: 1.1rem; + padding-block-end: 1.5rem; + border: 1px solid ${({ theme }) => theme.palette.divider}; + border-radius: 4px; + margin-block-end: 1.2rem; +`; + +const ItemWrapper = styled.div` + &:not(:last-child) { + margin-block-end: 1.2rem; + } +`; + +const Title = styled(Typography).attrs({ + variant: 'h3', +})` + font-size: 0.875rem; + color: ${({ theme }) => theme.palette.text.secondary}; + font-weight: normal; + margin-block-end: 0.2rem; +`; + +const Value = styled(Typography)` + font-size: 0.875rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const Column = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 50%; + &:first-child { + padding-inline-end: 1rem; + } + &:last-child { + border-left: 1px solid ${({ theme }) => theme.palette.divider}; + padding-inline-start: 1rem; + } +`; + +const EntityName = styled(Typography)` + font-size: 0.875rem; +`; + +const Bold = styled.span` + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const Row = styled.div` + display: flex; + padding-block-end: 1.2rem; + &:first-child { + padding-block-end: 0; + ${Column} { + padding-block-end: 1.2rem; + } + } +`; + +export const TaskSummary = ({ task }: { task: SingleTaskResponse }) => { + const displayRepeatSchedule = getDisplayRepeatSchedule(task); + return ( + + + + + Survey + {task.survey.name} + + + + + Entity + + {task.entity.name} | {task.entity.parentName} + + + + + + + + Repeating task + {displayRepeatSchedule} + + + + + Due date + {displayDate(task.taskDueDate)} + + + + + Status + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TaskTile.tsx b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx new file mode 100644 index 0000000000..6b641cc76b --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TaskTile.tsx @@ -0,0 +1,123 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { generatePath, Link } from 'react-router-dom'; +import { PRIMARY_ENTITY_CODE_PARAM, ROUTES } from '../../constants'; +import { displayDate } from '../../utils'; +import { ButtonLink } from '../../components'; +import { StatusPill } from './StatusPill'; +import { CommentsCount } from './CommentsCount'; + +const TileContainer = styled(Link)` + display: flex; + text-align: left; + justify-content: space-between; + text-decoration: none; + border-radius: 10px; + border: 1px solid ${({ theme }) => theme.palette.divider}; + width: 100%; + padding: 0.4rem 0.7rem; + margin-block-end: 0.5rem; + + .MuiButton-root { + padding: 0.2rem 1.2rem; + } + + .MuiButton-label { + font-size: 0.75rem; + } + + @media screen and (max-width: 30rem) { + .MuiButtonBase-root { + padding-inline: 0.8rem; + } + } + + @media screen and (max-width: 24rem) { + flex-direction: column; + .MuiButtonBase-root { + margin-block-end: 0.4rem; + margin-block-start: 0.8rem; + } + } + + &:hover { + background-color: ${({ theme }) => theme.palette.primaryHover}; + border-color: ${({ theme }) => theme.palette.primary.main}; + } + &:focus-within { + border-color: ${({ theme }) => theme.palette.primary.main}; + } +`; + +const TileTitle = styled.div` + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-block-end: 0.25rem; + color: ${({ theme }) => theme.palette.text.primary}; +`; + +const TileLeft = styled.div` + flex: 1; + padding-inline-end: 1rem; + min-width: 0; +`; + +const TileContent = styled.div` + display: flex; + font-size: 0.75rem; + color: ${({ theme }) => theme.palette.text.secondary}; + align-items: center; + + > span { + text-wrap: nowrap; + margin-inline-end: 0.6rem; + } +`; + +const TileRight = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; + +export const TaskTile = ({ task }) => { + const { survey, entity, taskStatus, taskDueDate } = task; + const path = generatePath(ROUTES.SURVEY, { + surveyCode: survey.code, + countryCode: entity.countryCode, + }); + // Link needs to include page number because if the redirect happens, the "from" state is lost + const surveyLink = `${path}/1?${PRIMARY_ENTITY_CODE_PARAM}=${entity.code}`; + const taskLink = generatePath(ROUTES.TASK_DETAILS, { + taskId: task.id, + }); + return ( + + + {survey.name} + + + {displayDate(taskDueDate)} + + + + + + Complete task + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx new file mode 100644 index 0000000000..63528c26d7 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx @@ -0,0 +1,73 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { TaskStatus } from '@tupaia/types'; +import { generatePath, useLocation } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { Button } from '@tupaia/ui-components'; +import { PRIMARY_ENTITY_CODE_PARAM, ROUTES } from '../../../constants'; +import { SingleTaskResponse } from '../../../types'; +import { AssignTaskModal } from './AssignTaskModal'; + +const ActionButtonComponent = styled(Button).attrs({ + color: 'primary', + size: 'small', +})` + padding-inline: 1.2rem; + padding-block: 0.4rem; + width: 6.5rem; + .MuiButton-label { + font-size: 0.75rem; + line-height: normal; + } + .cell-content:has(&) { + padding-block: 0.2rem; + padding-inline-start: 1.5rem; + } +`; + +interface ActionButtonProps { + task: SingleTaskResponse; +} + +export const ActionButton = ({ task }: ActionButtonProps) => { + const location = useLocation(); + if (!task) return null; + const { assignee, survey, entity, taskStatus } = task; + if (taskStatus === TaskStatus.cancelled || taskStatus === TaskStatus.completed) return null; + if (!assignee?.id) { + return ( + ( + + Assign + + )} + /> + ); + } + + const path = generatePath(ROUTES.SURVEY, { + surveyCode: survey.code, + countryCode: entity.countryCode, + }); + // Link needs to include page number because if the redirect happens, the "from" state is lost + const surveyLink = `${path}/1?${PRIMARY_ENTITY_CODE_PARAM}=${entity.code}`; + + return ( + + Complete task + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx new file mode 100644 index 0000000000..7d2d2d0112 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx @@ -0,0 +1,91 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useForm, Controller } from 'react-hook-form'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; +import { useEditTask } from '../../../api'; +import { SingleTaskResponse } from '../../../types'; +import { AssigneeInput } from '../AssigneeInput'; +import { TaskForm } from '../TaskForm'; +import { TaskSummary } from '../TaskSummary'; + +const Container = styled(ModalCenteredContent)` + width: 26rem; + max-width: 100%; + margin: 0 auto; + padding-block: 2.5rem; +`; + +interface AssignTaskModalProps { + task: SingleTaskResponse; + Button: React.ComponentType<{ onClick: () => void }>; +} + +export const AssignTaskModal = ({ task, Button }: AssignTaskModalProps) => { + const [isOpen, setIsOpen] = useState(false); + const formContext = useForm({ + mode: 'onChange', + }); + const { + control, + handleSubmit, + formState: { isValid }, + } = formContext; + const onClose = () => setIsOpen(false); + const { mutate: editTask, isLoading } = useEditTask(task.id, onClose); + + const modalButtons = [ + { + text: 'Cancel', + onClick: onClose, + variant: 'outlined', + id: 'cancel', + disabled: isLoading, + }, + { + text: 'Save', + onClick: handleSubmit(editTask), + id: 'save', + type: 'submit', + disabled: isLoading || !isValid, + }, + ]; + + return ( + <> + + )} + + ); + } else { + Contents = ; + } + + return ( + + + My tasks + {hasTasks && ( + + View more... + + )} + + {Contents} + + ); +}; diff --git a/packages/datatrak-web/src/views/SurveyPage.tsx b/packages/datatrak-web/src/views/SurveyPage.tsx index 2c805c8c27..2be8816e9a 100644 --- a/packages/datatrak-web/src/views/SurveyPage.tsx +++ b/packages/datatrak-web/src/views/SurveyPage.tsx @@ -47,7 +47,7 @@ const SurveyScreenContainer = styled.div<{ `; const SurveyPageInner = () => { - const { screenNumber, countryCode, surveyCode } = useParams(); + const { screenNumber } = useParams(); const { formData, isSuccessScreen, @@ -55,6 +55,8 @@ const SurveyPageInner = () => { cancelModalOpen, closeCancelConfirmation, isResubmit, + countryCode, + surveyCode, } = useSurveyForm(); const resolver = useValidationResolver(); const formContext = useForm({ defaultValues: formData, reValidateMode: 'onSubmit', resolver }); @@ -111,8 +113,9 @@ const SurveyPageInner = () => { // The form provider has to be outside the outlet so that the form context is available to all. This is also so that the side menu can be outside of the 'SurveyLayout' page, because otherwise it rerenders on survey screen change, which makes it close and open again every time you change screen via the jump-to menu. The survey side menu needs to be inside the form provider so that it can access the form context to save form data export const SurveyPage = () => { + const { countryCode, surveyCode } = useParams(); return ( - + ); diff --git a/packages/datatrak-web/src/views/SurveyResponsePage.tsx b/packages/datatrak-web/src/views/SurveyResponsePage.tsx deleted file mode 100644 index a26b22f878..0000000000 --- a/packages/datatrak-web/src/views/SurveyResponsePage.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ - -import React from 'react'; -import { useParams } from 'react-router-dom'; -import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; -import { ScrollableBody } from '../layout'; -import { useSurveyResponse } from '../api/queries'; -import { SurveyReviewSection } from '../features/Survey/Components'; -import { Button, SurveyTickIcon } from '../components'; -import { displayDate } from '../utils'; - -const Header = styled.div` - display: flex; - align-items: center; - padding: 1rem; - width: 100%; - border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; - - .MuiSvgIcon-root { - font-size: 2.5em; - margin-right: 0.35em; - } - - ${({ theme }) => theme.breakpoints.up('sm')} { - padding: 1rem 2rem; - } - ${({ theme }) => theme.breakpoints.up('md')} { - padding: 1.375rem 2rem; - } -`; - -const Heading = styled(Typography).attrs({ - variant: 'h2', -})` - font-size: 1.5rem; - color: ${({ theme }) => theme.palette.text.primary}; - font-weight: 600; - margin-bottom: 0.2rem; - ${({ theme }) => theme.breakpoints.down('sm')} { - font-size: 1rem; - } -`; - -const SubHeading = styled(Typography)` - color: ${({ theme }) => theme.palette.text.secondary}; - font-weight: 400; - font-size: 1rem; - ${({ theme }) => theme.breakpoints.down('sm')} { - font-size: 0.875rem; - } -`; - -const FormActions = styled.div` - display: flex; - justify-content: flex-end; - align-items: center; - padding: 1rem 0.5rem; - border-top: 1px solid ${props => props.theme.palette.divider}; - ${({ theme }) => theme.breakpoints.up('md')} { - padding: 1rem; - } -`; - -const getSubHeadingText = surveyResponse => { - if (!surveyResponse) { - return null; - } - const date = displayDate(surveyResponse.dataTime); - const country = surveyResponse?.countryName; - const entity = surveyResponse?.entityName; - const location = country === entity ? country : `${entity} | ${country}`; - return `${location} ${date}`; -}; - -export const SurveyResponsePage = () => { - const { surveyResponseId } = useParams(); - const { data: surveyResponse } = useSurveyResponse(surveyResponseId); - const subHeading = getSubHeadingText(surveyResponse); - - return ( - <> -
    - -
    - {surveyResponse?.surveyName} - {subHeading} -
    -
    - - - - - - - - ); -}; diff --git a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx b/packages/datatrak-web/src/views/SurveySelectPage.tsx similarity index 58% rename from packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx rename to packages/datatrak-web/src/views/SurveySelectPage.tsx index 35255d6a26..1318d82875 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage/SurveySelectPage.tsx +++ b/packages/datatrak-web/src/views/SurveySelectPage.tsx @@ -8,13 +8,12 @@ import { useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import { DialogActions, Paper, Typography } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; -import { useEditUser } from '../../api/mutations'; -import { SelectList, ListItemType, Button, SurveyFolderIcon, SurveyIcon } from '../../components'; -import { Survey } from '../../types'; -import { useCurrentUserContext, useProjectSurveys } from '../../api'; -import { HEADER_HEIGHT } from '../../constants'; -import { SurveyCountrySelector } from './SurveyCountrySelector'; -import { useUserCountries } from './useUserCountries'; +import { useEditUser } from '../api/mutations'; +import { Button } from '../components'; +import { useCurrentUserContext, useProjectSurveys } from '../api'; +import { HEADER_HEIGHT } from '../constants'; +import { CountrySelector, GroupedSurveyList, useUserCountries } from '../features'; +import { Survey } from '../types'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -53,17 +52,6 @@ const LoadingContainer = styled.div` flex: 1; `; -const ListWrapper = styled.div` - max-height: 35rem; - display: flex; - flex-direction: column; - overflow: auto; - flex: 1; - ${({ theme }) => theme.breakpoints.down('sm')} { - max-height: 100%; - } -`; - const HeaderWrapper = styled.div` display: flex; align-items: center; @@ -94,15 +82,9 @@ const Subheader = styled(Typography).attrs({ } `; -const sortAlphanumerically = (a: ListItemType, b: ListItemType) => { - return (a.content as string).trim()?.localeCompare((b.content as string).trim(), 'en', { - numeric: true, - }); -}; - export const SurveySelectPage = () => { const navigate = useNavigate(); - const [selectedSurvey, setSelectedSurvey] = useState(null); + const [selectedSurvey, setSelectedSurvey] = useState(null); const [urlSearchParams] = useSearchParams(); const urlProjectId = urlSearchParams.get('projectId'); const { @@ -113,54 +95,12 @@ export const SurveySelectPage = () => { isLoading: isLoadingCountries, } = useUserCountries(); const navigateToSurvey = () => { - navigate(`/survey/${selectedCountry?.code}/${selectedSurvey?.value}`); + navigate(`/survey/${selectedCountry?.code}/${selectedSurvey}`); }; const { mutateAsync: updateUser, isLoading: isUpdatingUser } = useEditUser(); const user = useCurrentUserContext(); - const { data: surveys, isLoading } = useProjectSurveys(user.projectId, selectedCountry?.name); - - // group the data by surveyGroupName for the list, and add the value and selected properties - const groupedSurveys = - surveys - ?.reduce((acc: ListItemType[], survey: Survey) => { - const { surveyGroupName, name, code } = survey; - const formattedSurvey = { - content: name, - value: code, - selected: selectedSurvey?.value === code, - icon: , - }; - // if there is no surveyGroupName, add the survey to the list as a top level item - if (!surveyGroupName) { - return [...acc, formattedSurvey]; - } - const group = acc.find(({ content }) => content === surveyGroupName); - // if the surveyGroupName doesn't exist in the list, add it as a top level item - if (!group) { - return [ - ...acc, - { - content: surveyGroupName, - icon: , - value: surveyGroupName, - children: [formattedSurvey], - }, - ]; - } - // if the surveyGroupName exists in the list, add the survey to the children - return acc.map(item => { - if (item.content === surveyGroupName) { - return { - ...item, - // sort the folder items alphanumerically - children: [...(item.children || []), formattedSurvey].sort(sortAlphanumerically), - }; - } - return item; - }); - }, []) - ?.sort(sortAlphanumerically) ?? []; + const { isLoading, data: surveys } = useProjectSurveys(user.projectId, selectedCountry?.code); const handleSelectSurvey = () => { if (countryHasUpdated) { @@ -176,7 +116,7 @@ export const SurveySelectPage = () => { useEffect(() => { // when the surveys change, check if the selected survey is still in the list. If not, clear the selection - if (selectedSurvey && !surveys?.find(survey => survey.code === selectedSurvey.value)) { + if (selectedSurvey && !surveys?.find(survey => survey.code === selectedSurvey)) { setSelectedSurvey(null); } }, [JSON.stringify(surveys)]); @@ -202,22 +142,22 @@ export const SurveySelectPage = () => { Select survey Select a survey from the list below - {!showLoader && ( - - )} + {showLoader ? ( ) : ( - - - + )} + ); + return ( + + ); + } + return ( + + ); +}; + +export const TaskDetailsPage = () => { + const [errorModalOpen, setErrorModalOpen] = useState(false); + const { taskId } = useParams(); + const { data: task, isLoading } = useTask(taskId); + + return ( + <> + + + setErrorModalOpen(true)} /> + {task && } + + + + {isLoading && } + {task && } + setErrorModalOpen(false)} /> + + + ); +}; diff --git a/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx new file mode 100644 index 0000000000..258b7583b7 --- /dev/null +++ b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx @@ -0,0 +1,65 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Add } from '@material-ui/icons'; +import { Button } from '../../components'; +import { CreateTaskModal, TaskPageHeader, TasksTable } from '../../features'; +import { TasksContentWrapper } from '../../layout'; +import { TaskMetrics } from '../../components/TaskMetrics'; + +const ButtonContainer = styled.div` + padding-block-end: 0.5rem; + margin-block-start: 1rem; + ${({ theme }) => theme.breakpoints.up('sm')} { + margin-inline-start: auto; + margin-block-start: 0; + padding-block-end: 0; + } + ${({ theme }) => theme.breakpoints.down('xs')} { + align-self: self-end; + } +`; + +const CreateButton = styled(Button).attrs({ + color: 'primary', + variant: 'outlined', + size: 'small', +})` + padding-inline-end: 1.2rem; + // the icon width creates the illusion of more padding on the left, so adjust the padding to compensate + padding-inline-start: 0.9rem; +`; + +const AddIcon = styled(Add)` + font-size: 1.2rem; + margin-inline-end: 0.2rem; +`; + +const ContentWrapper = styled(TasksContentWrapper)` + overflow: hidden; +`; + +export const TasksDashboardPage = () => { + const [createModalOpen, setCreateModalOpen] = useState(false); + const toggleCreateModal = () => setCreateModalOpen(!createModalOpen); + return ( + <> + + + + + Create task + + + + + + {createModalOpen && } + + + ); +}; diff --git a/packages/datatrak-web/src/views/Tasks/index.ts b/packages/datatrak-web/src/views/Tasks/index.ts new file mode 100644 index 0000000000..ba440ef719 --- /dev/null +++ b/packages/datatrak-web/src/views/Tasks/index.ts @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { TasksDashboardPage } from './TasksDashboardPage'; +export { TaskDetailsPage } from './TaskDetailsPage'; diff --git a/packages/datatrak-web/src/views/index.ts b/packages/datatrak-web/src/views/index.ts index 976f1338ee..93026c4860 100644 --- a/packages/datatrak-web/src/views/index.ts +++ b/packages/datatrak-web/src/views/index.ts @@ -12,7 +12,6 @@ export { VerifyEmailResendPage } from './VerifyEmailResendPage'; export { VerifyEmailPage } from './VerifyEmailPage'; export { ErrorPage } from './ErrorPage'; export { ProjectSelectPage } from './ProjectSelectPage'; -export { SurveyResponsePage } from './SurveyResponsePage'; export { SurveySuccessScreen, SurveyReviewScreen, @@ -24,4 +23,5 @@ export { ForgotPasswordPage } from './ForgotPasswordPage'; export { ResetPasswordPage } from './ResetPasswordPage'; export { AccountSettingsPage } from './AccountSettingsPage'; export { ReportsPage } from './ReportsPage'; +export { TasksDashboardPage, TaskDetailsPage } from './Tasks'; export { NotAuthorisedPage } from './NotAuthorisedPage'; diff --git a/packages/entity-server/src/__tests__/__integration__/EntityDescendantRoutes.test.ts b/packages/entity-server/src/__tests__/__integration__/EntityDescendantRoutes.test.ts index 5395480f01..cc02c40049 100644 --- a/packages/entity-server/src/__tests__/__integration__/EntityDescendantRoutes.test.ts +++ b/packages/entity-server/src/__tests__/__integration__/EntityDescendantRoutes.test.ts @@ -162,6 +162,19 @@ describe('descendants', () => { expect(entities).toBeArray(); expect(entities).toIncludeSameMembers(getEntitiesWithFields([], ['code', 'name', 'type'])); }); + + it('can limit by page size', async () => { + const { body: entities } = await app.get('hierarchy/redblue/KANTO/descendants', { + query: { + fields: 'code,name', + filter: 'type==city', + pageSize: 5, + }, + }); + + expect(entities).toBeArray(); + expect(entities.length).toBe(5); + }); }); describe('/hierarchy/:hierarchyName/descendants', () => { diff --git a/packages/entity-server/src/routes/hierarchy/EntityDescendantsRoute.ts b/packages/entity-server/src/routes/hierarchy/EntityDescendantsRoute.ts index c584c9fc80..4de008e623 100644 --- a/packages/entity-server/src/routes/hierarchy/EntityDescendantsRoute.ts +++ b/packages/entity-server/src/routes/hierarchy/EntityDescendantsRoute.ts @@ -17,16 +17,22 @@ export type DescendantsRequest = SingleEntityRequest< SingleEntityRequestParams, EntityResponse[], RequestBody, - EntityRequestQuery & { includeRootEntity?: string } + EntityRequestQuery & { includeRootEntity?: string; pageSize?: number } >; export class EntityDescendantsRoute extends Route { public async buildResponse() { const { hierarchyId, entity, fields, field, filter } = this.req.ctx; - const { includeRootEntity: includeRootEntityString = 'false' } = this.req.query; + const { includeRootEntity: includeRootEntityString = 'false', pageSize } = this.req.query; const includeRootEntity = includeRootEntityString?.toLowerCase() === 'true'; - const descendants = await entity.getDescendants(hierarchyId, { - ...filter, - }); + const descendants = await entity.getDescendants( + hierarchyId, + { + ...filter, + }, + { + limit: pageSize, + }, + ); const responseEntities = includeRootEntity ? [entity].concat(descendants) : descendants; return formatEntitiesForResponse( diff --git a/packages/entity-server/src/routes/hierarchy/middleware/attachCommonEntityContext.ts b/packages/entity-server/src/routes/hierarchy/middleware/attachCommonEntityContext.ts index 3bda95dd9e..02ef046636 100644 --- a/packages/entity-server/src/routes/hierarchy/middleware/attachCommonEntityContext.ts +++ b/packages/entity-server/src/routes/hierarchy/middleware/attachCommonEntityContext.ts @@ -4,8 +4,8 @@ */ import { NextFunction, Request, Response } from 'express'; import { PermissionsError } from '@tupaia/utils'; -import { extractEntityFieldsFromQuery, extractEntityFieldFromQuery } from './fields'; import { CommonContext } from '../types'; +import { extractEntityFieldsFromQuery, extractEntityFieldFromQuery } from './fields'; const throwNoAccessError = (hierarchyName: string) => { throw new PermissionsError(`No access to requested hierarchy: ${hierarchyName}`); diff --git a/packages/entity-server/src/routes/hierarchy/types.ts b/packages/entity-server/src/routes/hierarchy/types.ts index 3e589c4731..c30341f5f7 100644 --- a/packages/entity-server/src/routes/hierarchy/types.ts +++ b/packages/entity-server/src/routes/hierarchy/types.ts @@ -71,6 +71,7 @@ export type CommonContext = { fields: ExtendedEntityFieldName[]; filter: EntityFilter; field?: FlattableEntityFieldName; + pageSize?: string; }; export interface SingleEntityContext extends CommonContext { diff --git a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx index 676c53e15c..2e21c9600b 100644 --- a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx +++ b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx @@ -6,13 +6,8 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; -import { - ColumnActionButton, - DataChangeAction, - useApiContext, - Modal, - ModalCenteredContent, -} from '@tupaia/admin-panel'; +import { ColumnActionButton, DataChangeAction, useApiContext } from '@tupaia/admin-panel'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { Delete } from '@material-ui/icons'; import CircularProgress from '@material-ui/core/CircularProgress'; import { useRejectSurveyResponseStatus } from '../api'; diff --git a/packages/meditrak-app/android/app/build.gradle b/packages/meditrak-app/android/app/build.gradle index f8715bf93d..1081bb5f4d 100644 --- a/packages/meditrak-app/android/app/build.gradle +++ b/packages/meditrak-app/android/app/build.gradle @@ -85,7 +85,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 143 - versionName "1.14.143" + versionName "1.14.144" } signingConfigs { debug { diff --git a/packages/meditrak-app/app/assessment/Question.jsx b/packages/meditrak-app/app/assessment/Question.jsx index 1895200869..fb6c7aef9a 100644 --- a/packages/meditrak-app/app/assessment/Question.jsx +++ b/packages/meditrak-app/app/assessment/Question.jsx @@ -29,6 +29,7 @@ import { ArithmeticQuestion, ConditionQuestion, CodeGeneratorQuestion, + UserQuestion, } from './specificQuestions'; const QUESTION_TYPES = { @@ -54,6 +55,7 @@ const QUESTION_TYPES = { Arithmetic: ArithmeticQuestion, Condition: ConditionQuestion, File: FileQuestion, + User: UserQuestion, }; const TYPES_CONTROLLING_QUESTION_TEXT = ['Instruction', 'Checkbox']; diff --git a/packages/meditrak-app/app/assessment/specificQuestions/UserQuestion.jsx b/packages/meditrak-app/app/assessment/specificQuestions/UserQuestion.jsx new file mode 100644 index 0000000000..2ba99fffe8 --- /dev/null +++ b/packages/meditrak-app/app/assessment/specificQuestions/UserQuestion.jsx @@ -0,0 +1,159 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import { View } from 'react-native'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { takeScrollControl, releaseScrollControl } from '../actions'; +import { getQuestion } from '../selectors'; +import { Autocomplete } from '../../widgets/Autocomplete/Autocomplete'; + +const OPTIONS_PER_PAGE = 10; + +const generateSearchRegexp = searchTerm => { + if (!searchTerm) { + return null; + } + // if includes an apostrophe, search all variations of the apostrophe + if (searchTerm.includes("'")) { + return `((${searchTerm.replace("'", "\\'")})|(${searchTerm.replace( + "'", + '\\‘', + )})|(${searchTerm.replace("'", '\\‘')}))`; + } + + // if includes a left single quote, search all variations of the left single quote + if (searchTerm.includes('‘')) { + return `((${searchTerm.replace('‘', '\\’')})|(${searchTerm.replace( + '‘', + "\\'", + )})|(${searchTerm.replace('‘', '\\‘')})))`; + } + + // if includes a right single quote, search all variations of the right single quote + if (searchTerm.includes('’')) { + return `((${searchTerm.replace('’', "\\'")})|(${searchTerm.replace( + '’', + '\\‘', + )})|(${searchTerm.replace('’', '\\‘')})))`; + } + + return searchTerm; +}; + +const UserQuestionComponent = props => { + const { selectedUserId, users, onSelectUser, scrollIntoFocus } = props; + const [searchTerm, setSearchTerm] = useState(''); + const [maxResults, setMaxResults] = useState(OPTIONS_PER_PAGE); + + const sortList = list => list.sort((a, b) => a.localeCompare(b)); + + const userList = sortList(users?.map(user => user.name) ?? []); + + const generateOptionList = () => { + if (!searchTerm) { + return userList.slice(0, maxResults); + } + const searchRegexp = generateSearchRegexp(searchTerm); + const startsWithSearchTerm = new RegExp(`^${searchRegexp}`, 'i'); + const containsSearchTerm = new RegExp(searchRegexp, 'gi'); + + const usersThatStartWithSearchTerm = userList.filter(user => startsWithSearchTerm.test(user)); + + const usersThatContainSearchTerm = userList.filter( + user => containsSearchTerm.test(user) && !usersThatStartWithSearchTerm.includes(user), + ); + + // return first the users that start with the search term, then the users that contain the search term + return [...usersThatStartWithSearchTerm, ...usersThatContainSearchTerm].slice(0, maxResults); + }; + + const optionList = generateOptionList(); + + const getSelectedUser = () => users.find(user => user.id === selectedUserId); + + const selectedUser = getSelectedUser(); + + const handleEndReached = () => { + // If we've reached the end of the list, don't try to load more + if (users.length <= OPTIONS_PER_PAGE || maxResults >= users.length) return; + + const newMaxResults = maxResults + OPTIONS_PER_PAGE; + + setMaxResults(newMaxResults); + }; + + const handleSelectOption = option => { + if (!option) { + onSelectUser(null); + return; + } + const newSelectedUser = users.find(user => user.name === option); + if (!newSelectedUser) { + throw new Error(`Cannot find user in database: ${option}`); + } + onSelectUser(newSelectedUser.id); + }; + + const handleChangeSearchTerm = newSearchTerm => { + setSearchTerm(newSearchTerm); + setMaxResults(OPTIONS_PER_PAGE); + }; + + return ( + + + + ); +}; + +UserQuestionComponent.propTypes = { + onSelectUser: PropTypes.func.isRequired, + users: PropTypes.array.isRequired, + selectedUserId: PropTypes.string, + scrollIntoFocus: PropTypes.func.isRequired, +}; + +UserQuestionComponent.defaultProps = { + selectedUserId: null, +}; + +const mapStateToProps = ( + state, + { id: questionId, answer: selectedUserId, realmDatabase: database }, +) => { + const { code: countryCode } = database.getCountry(state.country.selectedCountryId); + const question = getQuestion(state, questionId); + + const users = database.getUsersByPermissionGroupAndCountry( + countryCode, + question.config?.user?.permissionGroup, + true, + ); + + return { + selectedUserId, + users, + }; +}; + +const mapDispatchToProps = (dispatch, { onChangeAnswer }) => ({ + onSelectUser: userId => onChangeAnswer(userId), + onClear: () => onChangeAnswer(null), + takeScrollControl: () => dispatch(takeScrollControl()), + releaseScrollControl: () => dispatch(releaseScrollControl()), +}); + +export const UserQuestion = connect(mapStateToProps, mapDispatchToProps)(UserQuestionComponent); diff --git a/packages/meditrak-app/app/assessment/specificQuestions/index.jsx b/packages/meditrak-app/app/assessment/specificQuestions/index.jsx index 9f08c1f141..e8e6e720d8 100644 --- a/packages/meditrak-app/app/assessment/specificQuestions/index.jsx +++ b/packages/meditrak-app/app/assessment/specificQuestions/index.jsx @@ -22,3 +22,5 @@ export { UnsupportedQuestion } from './UnsupportedQuestion'; export { DaysSinceQuestion } from './TimeSinceQuestion'; export { MonthsSinceQuestion } from './TimeSinceQuestion'; export { YearsSinceQuestion } from './TimeSinceQuestion'; + +export { UserQuestion } from './UserQuestion'; diff --git a/packages/meditrak-app/app/database/DatabaseAccess.jsx b/packages/meditrak-app/app/database/DatabaseAccess.jsx index 2aa68cd174..0b61ea3b32 100644 --- a/packages/meditrak-app/app/database/DatabaseAccess.jsx +++ b/packages/meditrak-app/app/database/DatabaseAccess.jsx @@ -147,4 +147,60 @@ export class DatabaseAccess extends SyncingDatabase { getOptionSetById(optionSetId) { return this.findOne('OptionSet', optionSetId); } + + getAncestorsOfPermissionGroup(permissionGroup, acc = []) { + if (!permissionGroup || !permissionGroup.parentId) { + return acc; + } + + const parent = this.findOne('PermissionGroup', permissionGroup.parentId); + + if (!parent) { + return acc; + } + + return this.getAncestorsOfPermissionGroup(parent, [...acc, parent.id]); + } + + getUsersByPermissionGroupAndCountry( + countryCode, + permissionGroupId, + excludeInternalUsers = false, + ) { + // get user entity permission entries by the country code and permission group name + const countryEntity = this.getEntities({ code: countryCode })[0]; + + const permissionGroup = this.findOne('PermissionGroup', permissionGroupId, 'id'); + + const ancestors = this.getAncestorsOfPermissionGroup(permissionGroup); + + const permissionGroupIds = [permissionGroup.id, ...ancestors]; + + const userEntityPermissionEntries = this.objects('UserEntityPermission').filtered( + combineClauses( + [ + conditionsToClauses({ entityId: countryEntity.id }), + conditionsToClauses({ permissionGroupId: permissionGroupIds }), + ], + 'AND', + ), + ); + + const userIds = [...new Set(userEntityPermissionEntries.map(entry => entry.userId))]; + + if (userIds.length === 0) { + return []; + } + + const clauses = conditionsToClauses({ id: userIds }); + if (excludeInternalUsers) { + clauses.push('internal = false'); + } + + const userQuery = combineClauses(clauses, 'AND'); + + const users = this.objects('UserAccount').filtered(userQuery); + + return users; + } } diff --git a/packages/meditrak-app/app/database/RealmExplorer.jsx b/packages/meditrak-app/app/database/RealmExplorer.jsx index 871d55252e..90d9375814 100644 --- a/packages/meditrak-app/app/database/RealmExplorer.jsx +++ b/packages/meditrak-app/app/database/RealmExplorer.jsx @@ -4,17 +4,12 @@ */ import React from 'react'; -import {FlatList, StyleSheet, Text, View} from 'react-native'; -import Menu, { - MenuContext, - MenuOptions, - MenuOption, - MenuTrigger, -} from 'react-native-menu'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; +import Menu, { MenuContext, MenuOptions, MenuOption, MenuTrigger } from 'react-native-menu'; -import {database} from './singleton'; +import { database } from './singleton'; import * as dataTypes from './types'; -import {TupaiaBackground} from '../widgets'; +import { TupaiaBackground } from '../widgets'; const DATA_TYPES = Object.keys(dataTypes); @@ -63,8 +58,7 @@ export class RealmExplorer extends React.Component { typeof value.getMonth === 'function') ) rowData[key] = String(value); - else if (typeof value === 'boolean') - rowData[key] = value ? 'True' : 'False'; + else if (typeof value === 'boolean') rowData[key] = value ? 'True' : 'False'; else if (value && value.length) rowData[key] = value.length; // Most likely a realm list else if (value && value.id) rowData[key] = value.id; @@ -90,11 +84,10 @@ export class RealmExplorer extends React.Component { style={localStyles.menu} onSelect={value => { this.setDataTypeDisplayed(value); - }}> + }} + > - - {this.state.selectedType} - + {this.state.selectedType} {DATA_TYPES.map(typeName => ( @@ -106,7 +99,7 @@ export class RealmExplorer extends React.Component { - {this.state.columns.map(({key, title}) => ( + {this.state.columns.map(({ key, title }) => ( {title} @@ -114,9 +107,9 @@ export class RealmExplorer extends React.Component { ( + renderItem={({ item }) => ( - {this.state.columns.map(({key}) => ( + {this.state.columns.map(({ key }) => ( {item[key]} @@ -157,6 +150,8 @@ const localStyles = StyleSheet.create({ position: 'absolute', left: 0, top: 0, + overflow: 'scroll', + maxHeight: 500, }, menu: { padding: 20, diff --git a/packages/meditrak-app/app/database/schema.jsx b/packages/meditrak-app/app/database/schema.jsx index cf63ccea54..fde30caab4 100644 --- a/packages/meditrak-app/app/database/schema.jsx +++ b/packages/meditrak-app/app/database/schema.jsx @@ -7,7 +7,7 @@ import * as DataTypes from './types'; export const schema = { schema: Object.values(DataTypes), - schemaVersion: 25, + schemaVersion: 26, migration: (oldRealm, newRealm) => { // For anyone upgrading from below version 3, change permission level to permission group if (oldRealm.schemaVersion < 3) { @@ -46,6 +46,7 @@ export const schema = { 8, // Replaced answersEnablingFollowUp with more complex visibilityCriteria 9, // Added validation criteria to survey screen components ]; + if (oldRealm.schemaVersion < Math.max(versionsRequiringFullResync)) { const results = newRealm.objects('Setting').filtered('key = "LATEST_SERVER_SYNC_TIMESTAMP"'); if (results) newRealm.delete(results); diff --git a/packages/meditrak-app/app/database/types/PermissionGroup.jsx b/packages/meditrak-app/app/database/types/PermissionGroup.jsx index fde482fa19..5019afeeea 100644 --- a/packages/meditrak-app/app/database/types/PermissionGroup.jsx +++ b/packages/meditrak-app/app/database/types/PermissionGroup.jsx @@ -13,13 +13,12 @@ PermissionGroup.schema = { properties: { id: 'string', name: { type: 'string', default: 'PermissionGroup not properly synchronised' }, + parentId: { type: 'string', optional: true }, }, }; PermissionGroup.requiredData = ['name']; PermissionGroup.construct = (database, data) => { - const { parentId, ...restOfData } = data; - const permissionGroupObject = restOfData; - return database.update('PermissionGroup', permissionGroupObject); + return database.update('PermissionGroup', data); }; diff --git a/packages/meditrak-app/app/database/types/UserAccount.jsx b/packages/meditrak-app/app/database/types/UserAccount.jsx new file mode 100644 index 0000000000..6653e7c3e5 --- /dev/null +++ b/packages/meditrak-app/app/database/types/UserAccount.jsx @@ -0,0 +1,32 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Object as RealmObject } from 'realm'; + +export class UserAccount extends RealmObject {} + +UserAccount.schema = { + name: 'UserAccount', + primaryKey: 'id', + properties: { + id: 'string', + name: { type: 'string', default: 'Failed to store user details' }, + internal: { type: 'bool', default: false }, + }, +}; + +UserAccount.requiredData = ['id']; + +UserAccount.construct = (database, data) => { + const { firstName, lastName, id, internal } = data; + + const fullName = [firstName, lastName].filter(value => !!value).join(' '); + + return database.update('UserAccount', { + id, + name: fullName, + internal, + }); +}; diff --git a/packages/meditrak-app/app/database/types/UserEntityPermission.jsx b/packages/meditrak-app/app/database/types/UserEntityPermission.jsx new file mode 100644 index 0000000000..7ec1b39351 --- /dev/null +++ b/packages/meditrak-app/app/database/types/UserEntityPermission.jsx @@ -0,0 +1,28 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Object as RealmObject } from 'realm'; + +export class UserEntityPermission extends RealmObject {} + +UserEntityPermission.schema = { + name: 'UserEntityPermission', + primaryKey: 'id', + properties: { + id: 'string', + userId: { type: 'string', default: 'UserEntityPermission not properly synchronised' }, + entityId: { type: 'string', default: '' }, + permissionGroupId: { + type: 'string', + default: '', + }, + }, +}; + +UserEntityPermission.requiredData = ['userId', 'entityId', 'permissionGroupId']; + +UserEntityPermission.construct = (database, data) => { + return database.update('UserEntityPermission', data); +}; diff --git a/packages/meditrak-app/app/database/types/index.jsx b/packages/meditrak-app/app/database/types/index.jsx index 43488616b2..2d8e22b6f8 100644 --- a/packages/meditrak-app/app/database/types/index.jsx +++ b/packages/meditrak-app/app/database/types/index.jsx @@ -23,3 +23,5 @@ export { Option } from './Option'; export { OptionSet } from './OptionSet'; export { User } from './User'; export { File } from './File'; +export { UserEntityPermission } from './UserEntityPermission'; +export { UserAccount } from './UserAccount'; diff --git a/packages/meditrak-app/app/sync/syncMigrations.jsx b/packages/meditrak-app/app/sync/syncMigrations.jsx index 8925020ace..8e371e0d8b 100644 --- a/packages/meditrak-app/app/sync/syncMigrations.jsx +++ b/packages/meditrak-app/app/sync/syncMigrations.jsx @@ -46,13 +46,26 @@ const migrations = { 9: async (synchroniser, setProgressMessage) => { await resyncRecordTypes(synchroniser, setProgressMessage, ['survey_screen_component']); }, + // Resync all users so that the new user entity permission structure comes through, and the permission grousp so that the user question can correctly filter by permission group: version 1.14.144 + 10: async (synchroniser, setProgressMessage) => { + await resyncRecordTypes(synchroniser, setProgressMessage, [ + 'user_account', + 'user_entity_permission', + 'permission_group', + ]); + }, }; export const getSyncMigrations = async database => { const currentMigrationVersion = (await database.getSetting(DATABASE_SYNC_MIGRATION_VERSION)) || 0; const availableMigrationVersions = Object.keys(migrations) - .sort() - .filter(version => version > currentMigrationVersion); + .map(version => parseInt(version, 10)) + .sort( + (versionA, versionB) => + // Sort in ascending order + versionA - versionB, + ) + .filter(version => version > parseInt(currentMigrationVersion, 10)); const hasNeverSynced = await !database.getSetting(LATEST_SERVER_SYNC_TIMESTAMP); if (hasNeverSynced) { // No need to migrate anything as it will do initial sync, jump the migration version up diff --git a/packages/meditrak-app/app/widgets/Autocomplete/Autocomplete.jsx b/packages/meditrak-app/app/widgets/Autocomplete/Autocomplete.jsx index 61f9b6b01a..1303bcc3c9 100644 --- a/packages/meditrak-app/app/widgets/Autocomplete/Autocomplete.jsx +++ b/packages/meditrak-app/app/widgets/Autocomplete/Autocomplete.jsx @@ -139,7 +139,8 @@ class AutocompleteComponent extends PureComponent { data={options} onEndReached={this.onEndReached} onEndReachedThreshold={endReachedOffset} - keyExtractor={item => item} + // add an index here to make sure all options are unique + keyExtractor={(item, i) => `${item}-${i}`} ItemSeparatorComponent={() => } renderItem={({ item }) => ( diff --git a/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj b/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj index c2678982d8..2e9488c1c0 100644 --- a/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj +++ b/packages/meditrak-app/ios/TupaiaMediTrak.xcodeproj/project.pbxproj @@ -504,7 +504,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14.143; + MARKETING_VERSION = 1.14.144; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -536,7 +536,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14.143; + MARKETING_VERSION = 1.14.144; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/packages/meditrak-app/package.json b/packages/meditrak-app/package.json index 2c8b40bc75..3495ff4a04 100644 --- a/packages/meditrak-app/package.json +++ b/packages/meditrak-app/package.json @@ -1,6 +1,6 @@ { "name": "@tupaia/meditrak-app", - "version": "1.14.143", + "version": "1.14.144", "private": true, "scripts": { "android": "react-native run-android", diff --git a/packages/psss/src/api/mutations.js b/packages/psss/src/api/mutations.js index 58cc9ac059..aa931b47cf 100644 --- a/packages/psss/src/api/mutations.js +++ b/packages/psss/src/api/mutations.js @@ -16,15 +16,15 @@ export const useConfirmWeeklyReport = (countryCode, period) => { { onSuccess: response => { // Same as useSaveWeeklyReport, we need to invalidate all weekly data - queryClient.invalidateQueries(`confirmedWeeklyReport/${countryCode}`); + queryClient.invalidateQueries([`confirmedWeeklyReport/${countryCode}`]); // regional (multi-country) level - queryClient.invalidateQueries('confirmedWeeklyReport', { exact: true }); + queryClient.invalidateQueries(['confirmedWeeklyReport', { exact: true }]); if (response?.alertData?.createdAlerts?.length > 0) { - queryClient.invalidateQueries(`alerts/active`); + queryClient.invalidateQueries([`alerts/active`]); } if (response?.alertData?.alertsArchived) { - queryClient.invalidateQueries(`alerts/archive`); + queryClient.invalidateQueries([`alerts/archive`]); } }, }, @@ -41,10 +41,10 @@ export const useSaveWeeklyReport = ({ countryCode, siteCode = '', week }) => { }), { onSuccess: () => { - queryClient.invalidateQueries(`weeklyReport/${countryCode}/sites`); + queryClient.invalidateQueries([`weeklyReport/${countryCode}/sites`]); // Even though we only changed one week of data, we need to re-fetch the complete list because // the data for a specific week is dependant on previous weeks, even across pages. - queryClient.invalidateQueries(`weeklyReport/${countryCode}`); + queryClient.invalidateQueries([`weeklyReport/${countryCode}`]); }, }, ); @@ -60,7 +60,7 @@ export const useDeleteWeeklyReport = ({ countryCode, week }) => { { onSuccess: () => { // Same as useSaveWeeklyReport, we need to invalidate all weekly data - queryClient.invalidateQueries(`weeklyReport/${countryCode}`); + queryClient.invalidateQueries([`weeklyReport/${countryCode}`]); }, }, ); diff --git a/packages/psss/src/api/queries/useAlerts.js b/packages/psss/src/api/queries/useAlerts.js index b14395e49a..a3b7e89bb9 100644 --- a/packages/psss/src/api/queries/useAlerts.js +++ b/packages/psss/src/api/queries/useAlerts.js @@ -38,8 +38,8 @@ export const useArchiveAlert = alertId => { const queryClient = useQueryClient(); return useMutation(() => put(`alerts/${alertId}/archive`), { onSuccess: () => { - queryClient.invalidateQueries(`alerts/active`); - queryClient.invalidateQueries(`alerts/archive`); + queryClient.invalidateQueries([`alerts/active`]); + queryClient.invalidateQueries([`alerts/archive`]); }, throwOnError: true, }); @@ -49,8 +49,8 @@ export const useRestoreArchivedAlert = alertId => { const queryClient = useQueryClient(); return useMutation(() => put(`alerts/${alertId}/unarchive`), { onSuccess: () => { - queryClient.invalidateQueries(`alerts/active`); - queryClient.invalidateQueries(`alerts/archive`); + queryClient.invalidateQueries([`alerts/active`]); + queryClient.invalidateQueries([`alerts/archive`]); }, throwOnError: true, }); @@ -60,7 +60,7 @@ export const useDeleteAlert = alertId => { const queryClient = useQueryClient(); return useMutation(() => remove(`alerts/${alertId}`), { onSuccess: () => { - queryClient.invalidateQueries(`alerts/archive`); + queryClient.invalidateQueries([`alerts/archive`]); }, throwOnError: true, }); diff --git a/packages/ui-components/src/components/Modal/ConfirmModal.jsx b/packages/psss/src/components/Modal/ConfirmModal.jsx similarity index 91% rename from packages/ui-components/src/components/Modal/ConfirmModal.jsx rename to packages/psss/src/components/Modal/ConfirmModal.jsx index dfeb0b7dd1..92ebddd6d0 100644 --- a/packages/ui-components/src/components/Modal/ConfirmModal.jsx +++ b/packages/psss/src/components/Modal/ConfirmModal.jsx @@ -8,11 +8,16 @@ import PropTypes from 'prop-types'; import styled from 'styled-components'; import ReportProblem from '@material-ui/icons/ReportProblem'; import Typography from '@material-ui/core/Typography'; - -import { Button, OutlinedButton } from '../Button'; -import { Dialog, DialogFooter, DialogHeader, DialogContent } from '../Dialog'; -import { LoadingContainer } from '../Loaders'; -import { Alert } from '../Alert'; +import { + Alert, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + LoadingContainer, + OutlinedButton, +} from '@tupaia/ui-components'; const DescriptionText = styled(Typography)` font-size: 1rem; diff --git a/packages/psss/src/components/Modal/index.js b/packages/psss/src/components/Modal/index.js index 4bd10d6d88..9cf0233064 100644 --- a/packages/psss/src/components/Modal/index.js +++ b/packages/psss/src/components/Modal/index.js @@ -4,3 +4,4 @@ */ export * from './AlertCreatedModal'; +export * from './ConfirmModal'; diff --git a/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx b/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx index b4684ba0a8..68f31fe4e0 100644 --- a/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx +++ b/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx @@ -17,11 +17,11 @@ import { DialogContent, DialogFooter, DialogHeader, - ConfirmModal, } from '@tupaia/ui-components'; import { useArchiveAlert } from '../../api/queries'; import { AlertsPanelContext } from '../../context'; +import { ConfirmModal } from '../../components'; const TickIcon = styled(CheckCircle)` font-size: 2.5rem; diff --git a/packages/psss/src/containers/Modals/DeleteAlertModal.jsx b/packages/psss/src/containers/Modals/DeleteAlertModal.jsx index 07464c1459..b90ef9124b 100644 --- a/packages/psss/src/containers/Modals/DeleteAlertModal.jsx +++ b/packages/psss/src/containers/Modals/DeleteAlertModal.jsx @@ -5,11 +5,9 @@ import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; - -import { ConfirmModal } from '@tupaia/ui-components'; - import { useDeleteAlert } from '../../api/queries'; import { SuccessModal } from './SuccessModal'; +import { ConfirmModal } from '../../components'; const STATUS = { INITIAL: 'initial', diff --git a/packages/server-boilerplate/src/models/Entity.ts b/packages/server-boilerplate/src/models/Entity.ts index bf6b1d7103..d5aaa08e84 100644 --- a/packages/server-boilerplate/src/models/Entity.ts +++ b/packages/server-boilerplate/src/models/Entity.ts @@ -17,7 +17,11 @@ export type EntityFilter = DbFilter; export interface EntityRecord extends Entity, BaseEntityRecord { getChildren: (hierarchyId: string, criteria?: EntityFilter) => Promise; getParent: (hierarchyId: string) => Promise; - getDescendants: (hierarchyId: string, criteria?: EntityFilter) => Promise; + getDescendants: ( + hierarchyId: string, + criteria?: EntityFilter, + options?: Record, + ) => Promise; getAncestors: (hierarchyId: string, criteria?: EntityFilter) => Promise; getAncestorOfType: (hierarchyId: string, type: string) => Promise; getRelatives: (hierarchyId: string, criteria?: EntityFilter) => Promise; diff --git a/packages/server-boilerplate/src/models/Task.ts b/packages/server-boilerplate/src/models/Task.ts new file mode 100644 index 0000000000..704b693d3f --- /dev/null +++ b/packages/server-boilerplate/src/models/Task.ts @@ -0,0 +1,15 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { TaskRecord as BaseTaskRecord, TaskModel as BaseTaskModel } from '@tupaia/database'; +import { Task } from '@tupaia/types'; +import { Model } from './types'; + +export interface TaskRecord extends Task, BaseTaskRecord { + project_id?: string | null; + data_time?: Date | null; + timezone?: string | null; +} + +export interface TaskModel extends Model {} 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/User.ts b/packages/server-boilerplate/src/models/User.ts index 8a687c812d..a37a12638e 100644 --- a/packages/server-boilerplate/src/models/User.ts +++ b/packages/server-boilerplate/src/models/User.ts @@ -9,6 +9,7 @@ import { Model } from './types'; export interface UserRecord extends UserAccount, BaseUserRecord { getData: () => Promise>; + full_name: string; } export interface UserModel extends Model {} diff --git a/packages/server-boilerplate/src/models/UserEntityPermission.ts b/packages/server-boilerplate/src/models/UserEntityPermission.ts index d44ec0e910..774a04f45b 100644 --- a/packages/server-boilerplate/src/models/UserEntityPermission.ts +++ b/packages/server-boilerplate/src/models/UserEntityPermission.ts @@ -6,12 +6,15 @@ import { UserEntityPermissionModel as BaseUserEntityPermissionModel, UserEntityPermissionRecord as BaseUserEntityPermissionRecord, } from '@tupaia/database'; -import { UserEntityPermission } from '@tupaia/types'; +import { Entity, PermissionGroup, UserEntityPermission } from '@tupaia/types'; import { Model } from './types'; export interface UserEntityPermissionRecord extends UserEntityPermission, - BaseUserEntityPermissionRecord {} + BaseUserEntityPermissionRecord { + entity_code?: Entity['code']; + permission_group_name?: PermissionGroup['name']; +} export interface UserEntityPermissionModel extends Model {} diff --git a/packages/server-boilerplate/src/models/index.ts b/packages/server-boilerplate/src/models/index.ts index 6460c5f10f..d87aba7a75 100644 --- a/packages/server-boilerplate/src/models/index.ts +++ b/packages/server-boilerplate/src/models/index.ts @@ -61,3 +61,5 @@ export { SurveyScreenModel, SurveyScreenRecord } from './SurveyScreen'; 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/server-boilerplate/src/models/types.ts b/packages/server-boilerplate/src/models/types.ts index ad2502d1cf..3202cf9c45 100644 --- a/packages/server-boilerplate/src/models/types.ts +++ b/packages/server-boilerplate/src/models/types.ts @@ -75,6 +75,7 @@ export type QueryOptions = { sort?: string[]; rawSort?: string; joinWith?: string; + columns?: string[]; joinCondition?: [string, string]; }; diff --git a/packages/server-boilerplate/src/utils/emailAfterTimeout.ts b/packages/server-boilerplate/src/utils/emailAfterTimeout.ts index ec2eda5fed..b37f1f3e68 100644 --- a/packages/server-boilerplate/src/utils/emailAfterTimeout.ts +++ b/packages/server-boilerplate/src/utils/emailAfterTimeout.ts @@ -7,26 +7,39 @@ import { sendEmail } from '@tupaia/server-utils'; import { UserAccount } from '@tupaia/types'; import { respond } from '@tupaia/utils'; +type TemplateContext = { + title: string; + message: string; + cta?: { + text: string; + url: string; + }; +}; + type ConstructEmailFromResponseT = ( responseBody: any, req: any, ) => Promise<{ subject: string; - message: string; attachments?: { filename: string; content: Buffer }[]; + templateContext: TemplateContext; }>; const sendResponseAsEmail = ( user: UserAccount, subject: string, - message: string, + templateContext: TemplateContext, attachments?: { filename: string; content: Buffer }[], ) => { - const text = `Hi ${user.first_name}, - -${message} - `; - sendEmail(user.email, { subject, text, attachments }); + sendEmail(user.email, { + subject, + attachments, + templateName: 'emailAfterTimeout', + templateContext: { + ...templateContext, + userName: user.first_name, + }, + }); }; const setupEmailResponse = async ( @@ -54,8 +67,11 @@ const setupEmailResponse = async ( // override the respond function so that when the endpoint handler finishes (or throws an error), // the response is sent via email res.overrideRespond = async (responseBody: any) => { - const { subject, message, attachments } = await constructEmailFromResponse(responseBody, req); - sendResponseAsEmail(user, subject, message, attachments); + const { subject, attachments, templateContext } = await constructEmailFromResponse( + responseBody, + req, + ); + sendResponseAsEmail(user, subject, templateContext, attachments); }; }; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index cd2cce4483..37949b625f 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -12,7 +12,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "rm -rf dist && npm run --prefix ../../ package:build:ts", + "copy-templates": "copyfiles -u 1 src/email/templates/**/**/* ./dist", + "build": "rm -rf dist && npm run --prefix ../../ package:build:ts && npm run copy-templates", "build-dev": "npm run build", "lint": "yarn package:lint", "lint:fix": "yarn lint --fix", @@ -25,13 +26,15 @@ "@aws-sdk/lib-storage": "^3.348.0", "@tupaia/utils": "workspace:*", "cookie": "^0.5.0", + "copyfiles": "^2.4.1", "dotenv": "^16.4.5", + "handlebars": "^4.7.8", "nodemailer": "^6.9.12", "puppeteer": "^15.4.0", "sha256": "^0.2.0" }, "devDependencies": { - "@types/nodemailer": "^6.4.13", + "@types/nodemailer": "^6.4.15", "@types/sha256": "^0.2.2" } } diff --git a/packages/server-utils/src/constructExportEmail.ts b/packages/server-utils/src/constructExportEmail.ts index 2733c81510..0b76182e36 100644 --- a/packages/server-utils/src/constructExportEmail.ts +++ b/packages/server-utils/src/constructExportEmail.ts @@ -39,8 +39,11 @@ export const constructExportEmail = async (responseBody: ResponseBody, req: Req) if (error) { return { subject, - message: `Unfortunately, your export failed. -${error}`, + templateContext: { + title: 'Export failed', + message: `Unfortunately, your export failed. + ${error}`, + }, }; } @@ -51,15 +54,25 @@ ${error}`, if (emailExportFileMode === EmailExportFileModes.ATTACHMENT) { return { subject, - message: 'Please find your requested export attached to this email.', attachments: await generateAttachments(filePath), + templateContext: { + title: 'Your export is ready', + message: 'Please find your requested export attached to this email.', + }, }; } const downloadLink = createDownloadLink(filePath); return { subject, - message: `Please click this one-time link to download your requested export: ${downloadLink} -Note that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.`, + templateContext: { + title: 'Your export is ready', + message: + "Here is your one time link to access your requested export.\nNote that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.", + cta: { + url: downloadLink, + text: 'Download export', + }, + }, }; }; diff --git a/packages/server-utils/src/email/index.ts b/packages/server-utils/src/email/index.ts new file mode 100644 index 0000000000..7a2169c9e9 --- /dev/null +++ b/packages/server-utils/src/email/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { sendEmail } from './sendEmail'; diff --git a/packages/server-utils/src/sendEmail.ts b/packages/server-utils/src/email/sendEmail.ts similarity index 52% rename from packages/server-utils/src/sendEmail.ts rename to packages/server-utils/src/email/sendEmail.ts index 79e0b775cb..de8750a232 100644 --- a/packages/server-utils/src/sendEmail.ts +++ b/packages/server-utils/src/email/sendEmail.ts @@ -3,38 +3,57 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import fs from 'fs'; +import path from 'path'; import nodemailer from 'nodemailer'; import { getEnvVarOrDefault, getIsProductionEnvironment, requireEnv } from '@tupaia/utils'; import Mail from 'nodemailer/lib/mailer'; +import handlebars from 'handlebars'; -const TEXT_SIGN_OFF = 'Cheers,\n\nThe Tupaia Team'; -const HTML_SIGN_OFF = '

    Cheers,

    The Tupaia Team

    '; +type CTA = { + text: string; + url: string; +}; +type TemplateContext = { + signOff?: string; + templateName: string; + templateContext: Record & { + cta?: CTA; + title: string; + }; +}; -type MailOptions = { +type MailOptions = TemplateContext & { subject?: string; - text?: string; - html?: string; attachments?: Mail.Attachment[]; signOff?: string; }; -export const sendEmail = async (to: string | string[], mailOptions: MailOptions = {}) => { - const { - subject, - text, - html, - attachments, - signOff = html ? HTML_SIGN_OFF : TEXT_SIGN_OFF, - } = mailOptions; +const compileHtml = (context: TemplateContext) => { + const { templateName, templateContext } = context; + const templatePath = path.resolve(__dirname, './templates/wrapper.html'); + const mainTemplate = fs.readFileSync(templatePath); + const compiledTemplate = handlebars.compile(mainTemplate.toString()); + let content = ''; + if (templateName) { + const innerContentTemplate = fs.readFileSync( + path.resolve(__dirname, `./templates/content/${templateName}.html`), + ); + content = handlebars.compile(innerContentTemplate.toString())(templateContext); + } + return compiledTemplate({ + ...templateContext, + content, + }).toString(); +}; + +export const sendEmail = async (to: string | string[], mailOptions: MailOptions) => { + const { subject, templateName, templateContext, attachments, signOff } = mailOptions || {}; const SMTP_HOST = getEnvVarOrDefault('SMTP_HOST', undefined); const SMTP_USER = getEnvVarOrDefault('SMTP_USER', undefined); const SMTP_PASSWORD = getEnvVarOrDefault('SMTP_PASSWORD', undefined); const SITE_EMAIL_ADDRESS = getEnvVarOrDefault('SITE_EMAIL_ADDRESS', undefined); - if (text && html) { - throw new Error('Only text or HTML can be sent in an email, not both'); - } - if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD || !SITE_EMAIL_ADDRESS) { return {}; } @@ -52,8 +71,7 @@ export const sendEmail = async (to: string | string[], mailOptions: MailOptions // Make sure it doesn't send real users mail from the dev server const sendTo = getIsProductionEnvironment() ? to : (requireEnv('DEV_EMAIL_ADDRESS') as string); - const fullText = text ? `${text}\n${signOff}` : undefined; - const fullHtml = html ? `${html}
    ${signOff}` : undefined; + const fullHtml = compileHtml({ templateName, templateContext, signOff }); return transporter.sendMail({ from: `Tupaia <${SITE_EMAIL_ADDRESS}>`, @@ -61,7 +79,6 @@ export const sendEmail = async (to: string | string[], mailOptions: MailOptions to: sendTo, subject, attachments, - text: fullText, html: fullHtml, }); }; diff --git a/packages/server-utils/src/email/templates/content/dashboardSubscription.html b/packages/server-utils/src/email/templates/content/dashboardSubscription.html new file mode 100644 index 0000000000..2011de78f9 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/dashboardSubscription.html @@ -0,0 +1,3 @@ +
    +

    The latest data for the {{dashboardName}} dashboard in {{entityName}} is ready to view.

    +
    diff --git a/packages/server-utils/src/email/templates/content/deleteAccount.html b/packages/server-utils/src/email/templates/content/deleteAccount.html new file mode 100644 index 0000000000..4ed54a4d60 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/deleteAccount.html @@ -0,0 +1,6 @@ +
    +

    + {{user.first_name}} {{user.last_name}} ({{user.email}} - {{user.id}}, {{user.position}} at + {{user.employer}}) has requested to delete their account. +

    +
    diff --git a/packages/server-utils/src/email/templates/content/emailAfterTimeout.html b/packages/server-utils/src/email/templates/content/emailAfterTimeout.html new file mode 100644 index 0000000000..d9c2b24310 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/emailAfterTimeout.html @@ -0,0 +1,4 @@ +
    +

    Hi {{userName}},

    +

    {{message}}

    +
    diff --git a/packages/server-utils/src/email/templates/content/overdueTask.html b/packages/server-utils/src/email/templates/content/overdueTask.html new file mode 100644 index 0000000000..96a1f4a680 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/overdueTask.html @@ -0,0 +1,12 @@ +
    +

    Hi {{userName}},

    +

    + Oh no! Looks like you have an overdue task. +

    +

    + This is just to let you know that you have an overdue task. The task is {{surveyName}} for {{entityName}}. To view and complete your tasks head to DataTrak. +

    +

    + Have fun using the platform and feel free to get in touch if you have any questions. +

    +
    diff --git a/packages/server-utils/src/email/templates/content/passwordReset.html b/packages/server-utils/src/email/templates/content/passwordReset.html new file mode 100644 index 0000000000..e044eab6ef --- /dev/null +++ b/packages/server-utils/src/email/templates/content/passwordReset.html @@ -0,0 +1,11 @@ +
    +

    Hi {{userName}}

    +

    + You are receiving this email because someone requested a password reset for this user account on + Tupaia.org. To reset your password follow the link below. +

    +

    + If you believe this email was sent to you in error, please contact us immediately at + admin@tupaia.org. +

    +
    diff --git a/packages/server-utils/src/email/templates/content/permissionGranted.html b/packages/server-utils/src/email/templates/content/permissionGranted.html new file mode 100644 index 0000000000..3bb14d6860 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/permissionGranted.html @@ -0,0 +1,13 @@ +
    +

    Hi {{userName}}

    +

    + This is just to let you know that you've been added to the {{permissionGroupName}} access group + for for {{entityName}}. +

    +

    {{{description}}}

    +

    + Please note that you'll need to log out and then log back in to get access to the new + permissions. +

    +

    Feel free to get in touch if you have any questions.

    +
    diff --git a/packages/server-utils/src/email/templates/content/requestCountryAccess.html b/packages/server-utils/src/email/templates/content/requestCountryAccess.html new file mode 100644 index 0000000000..49f7786320 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/requestCountryAccess.html @@ -0,0 +1,19 @@ +
    +

    + {{user.first_name}} {{user.last_name}} ({{user.email}} - {{user.id}}, {{user.position}} at + {{user.employer}}) has requested access to countries: +

    +
      + {{#each countries}} +
    • {{this}}
    • + {{/each}} +
    + {{#if project}} +

    + For the project {{project.code}} (linked to permission groups: {{project.permissionGroups}}) +

    + {{/if}} + {{#if message}} +

    With the message: "{{message}}"

    + {{/if}} +
    diff --git a/packages/server-utils/src/email/templates/content/taskAssigned.html b/packages/server-utils/src/email/templates/content/taskAssigned.html new file mode 100644 index 0000000000..f9c15dcf8a --- /dev/null +++ b/packages/server-utils/src/email/templates/content/taskAssigned.html @@ -0,0 +1,9 @@ +
    +

    Hi {{userName}},

    +

    + This is just to let you know that you've been assigned a new task. The task is {{surveyName}} + for {{entityName}}. To view and complete your tasks head to + DataTrak. +

    +

    Have fun using the platform and feel free to get in touch if you have any questions.

    +
    diff --git a/packages/server-utils/src/email/templates/content/verifyEmail.html b/packages/server-utils/src/email/templates/content/verifyEmail.html new file mode 100644 index 0000000000..a1ba2e92f1 --- /dev/null +++ b/packages/server-utils/src/email/templates/content/verifyEmail.html @@ -0,0 +1,8 @@ +
    +

    Thank you for registering with {{platform}}

    +

    Please click below to register your email address.

    +

    + If you believe this email was sent to you in error, please contact us immediately at + admin@tupaia.org. +

    +
    diff --git a/packages/server-utils/src/email/templates/wrapper.html b/packages/server-utils/src/email/templates/wrapper.html new file mode 100644 index 0000000000..2bf0b86fe5 --- /dev/null +++ b/packages/server-utils/src/email/templates/wrapper.html @@ -0,0 +1,120 @@ + + + + + + + +
    +
    + + + + + + + + + + + + + {{#if cta}} + + + + {{/if}} + + + + + + + + {{#if unsubscribeUrl}} + + + + {{/if}} +
    + Tupaia logo +
    +

    {{title}}

    +
    {{{content}}}
    + {{cta.text}} +
    + {{#if signoff}} +

    {{signoff}}

    + {{else}} +

    + Cheers, +
    + The Tupaia Team +

    + {{/if}} + Tupaia logo +
    + tupaia.org + bes.au +

    + Beyond Essential Systems +
    + 89 Nicholson St, Brunswick East VIC 3057 Australia +

    +
    +

    + If you wish to unsubscribe from these emails please click + here +

    +
    +
    +
    + + diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 5c78cfa9e7..3d6fcc3cc1 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -1,6 +1,6 @@ export { downloadPageAsPDF } from './downloadPageAsPDF'; export * from './s3'; -export { sendEmail } from './sendEmail'; +export { sendEmail } from './email'; export { generateUnsubscribeToken, verifyUnsubscribeToken } from './unsubscribeToken'; export { configureDotEnv } from './configureDotEnv'; export { constructExportEmail } from './constructExportEmail'; diff --git a/packages/tupaia-web-server/src/routes/EmailDashboardRoute.ts b/packages/tupaia-web-server/src/routes/EmailDashboardRoute.ts index 27f67b7a74..fc90fc2217 100644 --- a/packages/tupaia-web-server/src/routes/EmailDashboardRoute.ts +++ b/packages/tupaia-web-server/src/routes/EmailDashboardRoute.ts @@ -112,7 +112,6 @@ export class EmailDashboardRoute extends Route { const emails = mailingListEntries.map(({ email }) => email); const subject = `Tupaia Dashboard: ${projectEntity.name} ${entity.name} ${dashboard.name}`; - const html = `

    Latest data for the ${dashboard.name} dashboard in ${entity.name}.

    `; const filename = `${projectEntity.name}-${entity.name}-${dashboard.name}-export.pdf`; emails.forEach(email => { @@ -122,13 +121,16 @@ export class EmailDashboardRoute extends Route { token: unsubscribeToken, mailingListId: mailingList.id, }); - const unsubscribeHtml = `If you wish to unsubscribe from these emails please click here`; - const signOff = `

    Cheers,

    The Tupaia Team


    ${unsubscribeHtml}

    `; return sendEmail(email, { subject, - html, - signOff, attachments: [{ filename, content: buffer }], + templateName: 'dashboardSubscription', + templateContext: { + title: 'Your Tupaia Dashboard Export is ready', + dashboardName: dashboard.name, + entityName: entity.name, + unsubscribeUrl, + }, }); }); diff --git a/packages/tupaia-web/src/api/queries/useEntitySearch.ts b/packages/tupaia-web/src/api/queries/useEntitySearch.ts index facd47d31c..8835d6adf0 100644 --- a/packages/tupaia-web/src/api/queries/useEntitySearch.ts +++ b/packages/tupaia-web/src/api/queries/useEntitySearch.ts @@ -4,9 +4,9 @@ */ import { useQuery } from '@tanstack/react-query'; +import { useDebounce } from '@tupaia/ui-components'; import { ProjectCode, Entity } from '../../types'; import { get } from '../api'; -import { useDebounce } from '../../utils'; export const useEntitySearch = ( projectCode?: ProjectCode, diff --git a/packages/tupaia-web/src/utils/index.ts b/packages/tupaia-web/src/utils/index.ts index 095192f6d4..db7856f586 100644 --- a/packages/tupaia-web/src/utils/index.ts +++ b/packages/tupaia-web/src/utils/index.ts @@ -10,7 +10,6 @@ export { useEntityLink } from './useEntityLink'; export { useDateRanges, convertDateRangeToUrlPeriodString } from './useDateRanges'; export { gaEvent } from './ga'; export { transformDownloadLink } from './transformDownloadLink'; -export { useDebounce } from './useDebounce'; export { getDefaultDashboard } from './getDefaultDashboard'; export { useGAEffect } from './useGAEffect'; export { useUrlLoginToken } from './useUrlLoginToken'; diff --git a/packages/types/config/models/config.json b/packages/types/config/models/config.json index 3dd7b0cbf6..4449c86100 100644 --- a/packages/types/config/models/config.json +++ b/packages/types/config/models/config.json @@ -23,7 +23,9 @@ "public.entity.attributes": "EntityAttributes", "public.user_account.preferences": "UserAccountPreferences", "public.dashboard_relation.entity_types": "EntityType[]", - "public.project.config": "ProjectConfig" + "public.project.config": "ProjectConfig", + "public.task_comment.template_variables": "TaskCommentTemplateVariables", + "public.task.repeat_schedule": "RepeatSchedule" }, "typeMap": { "string": ["geography"], @@ -37,8 +39,10 @@ "MapOverlayConfig": "./models-extra", "EntityAttributes": "./models-extra", "UserAccountPreferences": "./models-extra", + "EntityType": "./models-extra", "ProjectConfig": "./models-extra", - "EntityType": "./models-extra" + "TaskCommentTemplateVariables": "./models-extra", + "RepeatSchedule": "./models-extra" } } } diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index d7942c0c75..36de1737fa 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -38838,6 +38838,90 @@ export const ArithmeticQuestionConfigSchema = { ] } +export const UserQuestionConfigSchema = { + "type": "object", + "properties": { + "permissionGroup": { + "description": "Filters the users by permission group.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "permissionGroup" + ] +} + +export const TaskQuestionConfigSchema = { + "type": "object", + "properties": { + "shouldCreateTask": { + "description": "Determines if a task should be created.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "entityId": { + "description": "Determines the entity that the task will be created for.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "surveyCode": { + "description": "Determines the survey that the task will be created for.", + "type": "string" + }, + "dueDate": { + "description": "Determines the due date of the task.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "assignee": { + "description": "Determines the assignee of the task.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + } + }, + "additionalProperties": false, + "required": [ + "assignee", + "dueDate", + "entityId", + "shouldCreateTask", + "surveyCode" + ] +} + export const SurveyScreenComponentConfigSchema = { "type": "object", "properties": { @@ -39293,6 +39377,88 @@ export const SurveyScreenComponentConfigSchema = { "required": [ "formula" ] + }, + "user": { + "type": "object", + "properties": { + "permissionGroup": { + "description": "Filters the users by permission group.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "permissionGroup" + ] + }, + "task": { + "type": "object", + "properties": { + "shouldCreateTask": { + "description": "Determines if a task should be created.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "entityId": { + "description": "Determines the entity that the task will be created for.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "surveyCode": { + "description": "Determines the survey that the task will be created for.", + "type": "string" + }, + "dueDate": { + "description": "Determines the due date of the task.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + }, + "assignee": { + "description": "Determines the assignee of the task.", + "type": "object", + "properties": { + "questionId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "questionId" + ] + } + }, + "additionalProperties": false, + "required": [ + "assignee", + "dueDate", + "entityId", + "shouldCreateTask", + "surveyCode" + ] } }, "additionalProperties": false @@ -39549,6 +39715,193 @@ export const ProjectConfigSchema = { "additionalProperties": false } +export const SystemCommentSubTypeSchema = { + "enum": [ + "complete", + "create", + "overdue", + "update" + ], + "type": "string" +} + +export const TaskUpdateCommentTemplateVariablesSchema = { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "originalValue": { + "type": [ + "string", + "number" + ] + }, + "newValue": { + "type": [ + "string", + "number" + ] + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] +} + +export const TaskCreateCommentTemplateVariablesSchema = { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "create" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ] +} + +export const TaskCompletedCommentTemplateVariablesSchema = { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "taskId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] +} + +export const TaskCommentTemplateVariablesSchema = { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "originalValue": { + "type": [ + "string", + "number" + ] + }, + "newValue": { + "type": [ + "string", + "number" + ] + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "create" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "taskId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] +} + +export const RepeatScheduleSchema = { + "additionalProperties": false, + "type": "object", + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } +} + export const AccessRequestSchema = { "type": "object", "properties": { @@ -39814,7 +40167,9 @@ export const AnalyticsSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" }, @@ -39879,7 +40234,9 @@ export const AnalyticsCreateSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" }, @@ -39944,7 +40301,9 @@ export const AnalyticsUpdateSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" }, @@ -79641,7 +80000,9 @@ export const QuestionSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" } @@ -79704,7 +80065,9 @@ export const QuestionCreateSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" } @@ -79769,7 +80132,9 @@ export const QuestionUpdateSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" } @@ -80918,19 +81283,70 @@ export const TaskSchema = { "assignee_id": { "type": "string" }, - "due_date": { + "created_at": { "type": "string", "format": "date-time" }, + "due_date": { + "type": "number" + }, "entity_id": { "type": "string" }, "id": { "type": "string" }, + "initial_request_id": { + "type": "string" + }, + "overdue_email_sent": { + "type": "string", + "format": "date-time" + }, + "parent_task_id": { + "type": "string" + }, "repeat_schedule": { + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ @@ -80942,10 +81358,14 @@ export const TaskSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" } }, "additionalProperties": false, "required": [ + "created_at", "entity_id", "id", "survey_id" @@ -80958,16 +81378,67 @@ export const TaskCreateSchema = { "assignee_id": { "type": "string" }, - "due_date": { + "created_at": { "type": "string", "format": "date-time" }, + "due_date": { + "type": "number" + }, "entity_id": { "type": "string" }, + "initial_request_id": { + "type": "string" + }, + "overdue_email_sent": { + "type": "string", + "format": "date-time" + }, + "parent_task_id": { + "type": "string" + }, "repeat_schedule": { + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ @@ -80979,6 +81450,9 @@ export const TaskCreateSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" } }, "additionalProperties": false, @@ -80994,19 +81468,70 @@ export const TaskUpdateSchema = { "assignee_id": { "type": "string" }, - "due_date": { + "created_at": { "type": "string", "format": "date-time" }, + "due_date": { + "type": "number" + }, "entity_id": { "type": "string" }, "id": { "type": "string" }, + "initial_request_id": { + "type": "string" + }, + "overdue_email_sent": { + "type": "string", + "format": "date-time" + }, + "parent_task_id": { + "type": "string" + }, "repeat_schedule": { + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ @@ -81018,6 +81543,318 @@ export const TaskUpdateSchema = { }, "survey_id": { "type": "string" + }, + "survey_response_id": { + "type": "string" + } + }, + "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" + }, + "template_variables": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "originalValue": { + "type": [ + "string", + "number" + ] + }, + "newValue": { + "type": [ + "string", + "number" + ] + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "create" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "taskId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "created_at", + "id", + "task_id", + "template_variables", + "type", + "user_name" + ] +} + +export const TaskCommentCreateSchema = { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "message": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "template_variables": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "originalValue": { + "type": [ + "string", + "number" + ] + }, + "newValue": { + "type": [ + "string", + "number" + ] + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "create" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "taskId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "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" + }, + "template_variables": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "originalValue": { + "type": [ + "string", + "number" + ] + }, + "newValue": { + "type": [ + "string", + "number" + ] + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "create" + ] + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "taskId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "type": { + "enum": [ + "system", + "user" + ], + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" } }, "additionalProperties": false @@ -81572,6 +82409,14 @@ export const TaskStatusSchema = { "type": "string" } +export const TaskCommentTypeSchema = { + "enum": [ + "system", + "user" + ], + "type": "string" +} + export const SyncGroupSyncStatusSchema = { "enum": [ "ERROR", @@ -81614,7 +82459,9 @@ export const QuestionTypeSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" } @@ -82258,7 +83105,9 @@ export const CamelCasedQuestionSchema = { "Photo", "PrimaryEntity", "Radio", - "SubmissionDate" + "SubmissionDate", + "Task", + "User" ], "type": "string" }, @@ -82345,6 +83194,23 @@ export const FileUploadAnswerSchema = { ] } +export const UserAnswerSchema = { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "name" + ] +} + export const AnswersSchema = { "type": "object", "additionalProperties": false @@ -82733,6 +83599,9 @@ export const EntityResponseSchema = { }, "isRecent": { "type": "boolean" + }, + "parent_name": { + "type": "string" } }, "required": [ @@ -82745,6 +83614,376 @@ export const EntityResponseSchema = { ] } +export const AssigneeSchema = { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false +} + +export const TaskResponseSchema = { + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "assigneeId": { + "type": "string" + }, + "initialRequestId": { + "type": "string" + }, + "overdueEmailSent": { + "type": "object", + "properties": { + "toString": { + "type": "object", + "additionalProperties": false + }, + "toDateString": { + "type": "object", + "additionalProperties": false + }, + "toTimeString": { + "type": "object", + "additionalProperties": false + }, + "toLocaleString": { + "type": "object", + "additionalProperties": false + }, + "toLocaleDateString": { + "type": "object", + "additionalProperties": false + }, + "toLocaleTimeString": { + "type": "object", + "additionalProperties": false + }, + "valueOf": { + "type": "object", + "additionalProperties": false + }, + "getTime": { + "type": "object", + "additionalProperties": false + }, + "getFullYear": { + "type": "object", + "additionalProperties": false + }, + "getUTCFullYear": { + "type": "object", + "additionalProperties": false + }, + "getMonth": { + "type": "object", + "additionalProperties": false + }, + "getUTCMonth": { + "type": "object", + "additionalProperties": false + }, + "getDate": { + "type": "object", + "additionalProperties": false + }, + "getUTCDate": { + "type": "object", + "additionalProperties": false + }, + "getDay": { + "type": "object", + "additionalProperties": false + }, + "getUTCDay": { + "type": "object", + "additionalProperties": false + }, + "getHours": { + "type": "object", + "additionalProperties": false + }, + "getUTCHours": { + "type": "object", + "additionalProperties": false + }, + "getMinutes": { + "type": "object", + "additionalProperties": false + }, + "getUTCMinutes": { + "type": "object", + "additionalProperties": false + }, + "getSeconds": { + "type": "object", + "additionalProperties": false + }, + "getUTCSeconds": { + "type": "object", + "additionalProperties": false + }, + "getMilliseconds": { + "type": "object", + "additionalProperties": false + }, + "getUTCMilliseconds": { + "type": "object", + "additionalProperties": false + }, + "getTimezoneOffset": { + "type": "object", + "additionalProperties": false + }, + "setTime": { + "type": "object", + "additionalProperties": false + }, + "setMilliseconds": { + "type": "object", + "additionalProperties": false + }, + "setUTCMilliseconds": { + "type": "object", + "additionalProperties": false + }, + "setSeconds": { + "type": "object", + "additionalProperties": false + }, + "setUTCSeconds": { + "type": "object", + "additionalProperties": false + }, + "setMinutes": { + "type": "object", + "additionalProperties": false + }, + "setUTCMinutes": { + "type": "object", + "additionalProperties": false + }, + "setHours": { + "type": "object", + "additionalProperties": false + }, + "setUTCHours": { + "type": "object", + "additionalProperties": false + }, + "setDate": { + "type": "object", + "additionalProperties": false + }, + "setUTCDate": { + "type": "object", + "additionalProperties": false + }, + "setMonth": { + "type": "object", + "additionalProperties": false + }, + "setUTCMonth": { + "type": "object", + "additionalProperties": false + }, + "setFullYear": { + "type": "object", + "additionalProperties": false + }, + "setUTCFullYear": { + "type": "object", + "additionalProperties": false + }, + "toUTCString": { + "type": "object", + "additionalProperties": false + }, + "toISOString": { + "type": "object", + "additionalProperties": false + }, + "toJSON": { + "type": "object", + "additionalProperties": false + }, + "getVarDate": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "getDate", + "getDay", + "getFullYear", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getSeconds", + "getTime", + "getTimezoneOffset", + "getUTCDate", + "getUTCDay", + "getUTCFullYear", + "getUTCHours", + "getUTCMilliseconds", + "getUTCMinutes", + "getUTCMonth", + "getUTCSeconds", + "getVarDate", + "setDate", + "setFullYear", + "setHours", + "setMilliseconds", + "setMinutes", + "setMonth", + "setSeconds", + "setTime", + "setUTCDate", + "setUTCFullYear", + "setUTCHours", + "setUTCMilliseconds", + "setUTCMinutes", + "setUTCMonth", + "setUTCSeconds", + "toDateString", + "toISOString", + "toJSON", + "toLocaleDateString", + "toLocaleString", + "toLocaleTimeString", + "toString", + "toTimeString", + "toUTCString", + "valueOf" + ] + }, + "parentTaskId": { + "type": "string" + }, + "status": { + "enum": [ + "cancelled", + "completed", + "to_do" + ], + "type": "string" + }, + "surveyResponseId": { + "type": "string" + }, + "assignee": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "taskStatus": { + "enum": [ + "cancelled", + "completed", + "overdue", + "repeating", + "to_do" + ], + "type": "string" + }, + "survey": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "code", + "id", + "name" + ] + }, + "entity": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "countryCode": { + "type": "string" + }, + "parentName": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "code", + "countryCode", + "id", + "name" + ] + }, + "repeatSchedule": { + "type": "object", + "additionalProperties": false + }, + "taskDueDate": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "entity", + "survey", + "taskStatus" + ] +} + +export const UserResponseSchema = { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "name" + ] +} + export const MailingListSchema = { "type": "object", "properties": { diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index f0eb9e6d88..1206c042a3 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -98,6 +98,11 @@ export { EntityQuestionConfigFieldValue, EntityQuestionConfigFieldKey, ProjectConfig, + TaskQuestionConfig, + UserQuestionConfig, + SystemCommentSubType, + TaskCommentTemplateVariables, + RepeatSchedule, EntityType, } from './models-extra'; export * from './requests'; diff --git a/packages/types/src/types/models-extra/index.ts b/packages/types/src/types/models-extra/index.ts index f58df72194..bb7484a22f 100644 --- a/packages/types/src/types/models-extra/index.ts +++ b/packages/types/src/types/models-extra/index.ts @@ -96,6 +96,8 @@ export { EntityQuestionConfigFields, EntityQuestionConfigFieldValue, EntityQuestionConfigFieldKey, + TaskQuestionConfig, + UserQuestionConfig, } from './survey'; export { LeaderboardItem } from './leaderboard'; export { @@ -108,4 +110,5 @@ export { VizPeriodGranularity, DashboardItemType } from './common'; export { isChartReport, isViewReport, isMatrixReport } from './report'; export { UserAccountPreferences } from './user'; export { ProjectConfig } from './project'; +export { RepeatSchedule, TaskCommentTemplateVariables, SystemCommentSubType } from './task'; export { EntityType } from './entityType'; diff --git a/packages/types/src/types/models-extra/survey/index.ts b/packages/types/src/types/models-extra/survey/index.ts index c3f4c7eddb..2f60c9a936 100644 --- a/packages/types/src/types/models-extra/survey/index.ts +++ b/packages/types/src/types/models-extra/survey/index.ts @@ -13,4 +13,6 @@ export { EntityQuestionConfigFields, EntityQuestionConfigFieldValue, EntityQuestionConfigFieldKey, + TaskQuestionConfig, + UserQuestionConfig, } from './surveyScreenComponent'; diff --git a/packages/types/src/types/models-extra/survey/surveyScreenComponent.ts b/packages/types/src/types/models-extra/survey/surveyScreenComponent.ts index 3af3ed3f9b..ec1aaafdf7 100644 --- a/packages/types/src/types/models-extra/survey/surveyScreenComponent.ts +++ b/packages/types/src/types/models-extra/survey/surveyScreenComponent.ts @@ -3,8 +3,8 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { Entity, Question } from '../../models'; import { EntityType } from '../entityType'; +import { Entity, PermissionGroup, Question, Survey } from '../../models'; export type CodeGeneratorQuestionConfig = { type: 'shortid' | 'mongoid'; @@ -70,10 +70,42 @@ export type ArithmeticQuestionConfig = { >; }; +export type UserQuestionConfig = { + /** + * @description Filters the users by permission group. + */ + permissionGroup: PermissionGroup['id']; +}; + +export type TaskQuestionConfig = { + /** + * @description Determines if a task should be created. + */ + shouldCreateTask: QuestionValue; + /** + * @description Determines the entity that the task will be created for. + */ + entityId: QuestionValue; + /** + * @description Determines the survey that the task will be created for. + */ + surveyCode: Survey['code']; + /** + * @description Determines the due date of the task. + */ + dueDate: QuestionValue; + /** + * @description Determines the assignee of the task. + */ + assignee: QuestionValue; +}; + export type SurveyScreenComponentConfig = { codeGenerator?: CodeGeneratorQuestionConfig; autocomplete?: AutocompleteQuestionConfig; entity?: EntityQuestionConfig; condition?: ConditionQuestionConfig; arithmetic?: ArithmeticQuestionConfig; + user?: UserQuestionConfig; + task?: TaskQuestionConfig; }; diff --git a/packages/types/src/types/models-extra/task.ts b/packages/types/src/types/models-extra/task.ts new file mode 100644 index 0000000000..e9121fd8a7 --- /dev/null +++ b/packages/types/src/types/models-extra/task.ts @@ -0,0 +1,42 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Task } from '../models'; + +export enum SystemCommentSubType { + update = 'update', + create = 'create', + overdue = 'overdue', + complete = 'complete', +} + +export type TaskUpdateCommentTemplateVariables = { + type: SystemCommentSubType.update; + originalValue?: string | number; + newValue?: string | number; + field?: string; +}; + +export type TaskCreateCommentTemplateVariables = { + type: SystemCommentSubType.create; +}; + +export type TaskCompletedCommentTemplateVariables = { + type: SystemCommentSubType.complete; + taskId?: Task['id']; +}; + +export type TaskCommentTemplateVariables = + | TaskUpdateCommentTemplateVariables + | TaskCreateCommentTemplateVariables + | TaskCompletedCommentTemplateVariables; + +export type RepeatSchedule = Record & { + freq?: number; + interval?: number; + bymonthday?: number | number[] | null; + bysetpos?: number | number[] | null; + dtstart?: Date | null; +}; diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index ea87740d8a..c345cfaeb9 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -12,8 +12,10 @@ import { DashboardItemConfig } from './models-extra'; import { MapOverlayConfig } from './models-extra'; import { EntityAttributes } from './models-extra'; import { UserAccountPreferences } from './models-extra'; -import { ProjectConfig } from './models-extra'; import { EntityType } from './models-extra'; +import { ProjectConfig } from './models-extra'; +import { TaskCommentTemplateVariables } from './models-extra'; +import { RepeatSchedule } from './models-extra'; export interface AccessRequest { 'approved'?: boolean | null; @@ -1536,29 +1538,73 @@ export interface SyncGroupLogUpdate { } export interface Task { 'assignee_id'?: string | null; - 'due_date'?: Date | null; + 'created_at': Date; + 'due_date'?: number | null; 'entity_id': string; 'id': string; - 'repeat_schedule'?: {} | null; + 'initial_request_id'?: string | null; + 'overdue_email_sent'?: Date | null; + 'parent_task_id'?: string | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id': string; + 'survey_response_id'?: string | null; } export interface TaskCreate { 'assignee_id'?: string | null; - 'due_date'?: Date | null; + 'created_at'?: Date; + 'due_date'?: number | null; 'entity_id': string; - 'repeat_schedule'?: {} | null; + 'initial_request_id'?: string | null; + 'overdue_email_sent'?: Date | null; + 'parent_task_id'?: string | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id': string; + 'survey_response_id'?: string | null; } export interface TaskUpdate { 'assignee_id'?: string | null; - 'due_date'?: Date | null; + 'created_at'?: Date; + 'due_date'?: number | null; 'entity_id'?: string; 'id'?: string; - 'repeat_schedule'?: {} | null; + 'initial_request_id'?: string | null; + 'overdue_email_sent'?: Date | null; + 'parent_task_id'?: string | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id'?: string; + 'survey_response_id'?: string | null; +} +export interface TaskComment { + 'created_at': Date; + 'id': string; + 'message'?: string | null; + 'task_id': string; + 'template_variables': TaskCommentTemplateVariables; + 'type': TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentCreate { + 'created_at'?: Date; + 'message'?: string | null; + 'task_id': string; + 'template_variables'?: TaskCommentTemplateVariables; + 'type'?: TaskCommentType; + 'user_id'?: string | null; + 'user_name': string; +} +export interface TaskCommentUpdate { + 'created_at'?: Date; + 'id'?: string; + 'message'?: string | null; + 'task_id'?: string; + 'template_variables'?: TaskCommentTemplateVariables; + 'type'?: TaskCommentType; + 'user_id'?: string | null; + 'user_name'?: string; } export interface TupaiaWebSession { 'access_policy': {}; @@ -1697,6 +1743,10 @@ export enum TaskStatus { 'cancelled' = 'cancelled', 'completed' = 'completed', } +export enum TaskCommentType { + 'user' = 'user', + 'system' = 'system', +} export enum SyncGroupSyncStatus { 'IDLE' = 'IDLE', 'SYNCING' = 'SYNCING', @@ -1731,6 +1781,8 @@ export enum QuestionType { 'Radio' = 'Radio', 'SubmissionDate' = 'SubmissionDate', 'File' = 'File', + 'Task' = 'Task', + 'User' = 'User', } export enum PrimaryPlatform { 'tupaia' = 'tupaia', diff --git a/packages/types/src/types/requests/datatrak-web-server/EntityDescendantsRequest.ts b/packages/types/src/types/requests/datatrak-web-server/EntityDescendantsRequest.ts index 7cf45e43f9..2ac0f3fae1 100644 --- a/packages/types/src/types/requests/datatrak-web-server/EntityDescendantsRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/EntityDescendantsRequest.ts @@ -8,6 +8,7 @@ import { KeysToCamelCase } from '../../../utils/casing'; type EntityResponse = Entity & { isRecent?: boolean; + parent_name?: Entity['name']; }; export type Params = Record; @@ -27,4 +28,5 @@ export type ReqQuery = { type?: string; }; searchString?: string; + pageSize?: number; }; diff --git a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts index 270fafe450..b264782bdc 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts @@ -14,9 +14,12 @@ export interface ResBody extends KeysToCamelCase; countryName: Country['name']; entityName: Entity['name']; + entityId: Entity['id']; surveyName: Survey['name']; surveyCode: Survey['code']; + countryCode: Country['code']; dataTime: Date; + entityParentName: Entity['name']; } export type ReqBody = Record; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts index e2864ea7a8..689a3d293a 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts @@ -10,7 +10,12 @@ export type FileUploadAnswer = { value: string; }; -export type Answer = string | number | boolean | null | undefined | FileUploadAnswer; +export type UserAnswer = { + id: string; + name: string; +}; + +export type Answer = string | number | boolean | null | undefined | FileUploadAnswer | UserAnswer; export type Answers = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SurveyRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SurveyRequest.ts index 930c703d74..04589a411a 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SurveyRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SurveyRequest.ts @@ -56,6 +56,7 @@ export type SurveyScreenComponent = CamelCasedComponent & label?: BaseSurveyScreenComponent['question_label']; options?: Option[] | null; screenId?: string; + id?: string; }; type CamelCasedSurveyScreen = KeysToCamelCase>; @@ -77,4 +78,5 @@ export type ReqBody = Record; export interface ReqQuery { fields?: string[]; projectId?: string; + countryCode?: string; } diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts new file mode 100644 index 0000000000..9df6643d4e --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts @@ -0,0 +1,22 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Survey, Task } from '../../models'; +import { RepeatSchedule } from '../../models-extra'; + +export type Params = Record; +export type ResBody = { + message: string; +}; +export type ReqQuery = Record; +export type ReqBody = Partial> & { + survey_code: Survey['code']; + comment?: string; + repeat_frequency?: RepeatSchedule['freq']; + assignee?: { + value: string; + label: string; + } | null; +}; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts new file mode 100644 index 0000000000..b97cc48686 --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts @@ -0,0 +1,8 @@ +export type Params = Record; +export interface ResBody { + unassignedTasks: number; + overdueTasks: number; + onTimeCompletionRate: number; +} +export type ReqBody = Record; +export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts new file mode 100644 index 0000000000..27fefdc854 --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/TaskRequest.ts @@ -0,0 +1,22 @@ +/** + * Tupaia + * 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; +}; + +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 new file mode 100644 index 0000000000..5b29ac579c --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/TasksRequest.ts @@ -0,0 +1,51 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { KeysToCamelCase } from '../../../utils/casing'; +import { Entity, Survey, Task, TaskStatus } from '../../models'; + +export type Params = Record; + +type Assignee = { + id?: string | null; + name?: string | null; +}; + +export type TaskResponse = KeysToCamelCase< + Partial> +> & { + assignee?: Assignee; + taskStatus: TaskStatus | 'overdue' | 'repeating'; + survey: { + name: Survey['name']; + id: Survey['id']; + code: Survey['code']; + }; + entity: { + name: Entity['name']; + id: Entity['id']; + code: Entity['code']; + countryCode: string; // this is not undefined or null so use string explicitly here + parentName?: Entity['name']; + }; + repeatSchedule?: Record | null; + taskDueDate?: Date | null; +}; + +export type ResBody = { + tasks: (TaskResponse & { + commentsCount: number; + })[]; + count: number; + numberOfPages: number; +}; +export type ReqBody = Record; +export interface ReqQuery { + fields?: string[]; + pageSize?: number; + sort?: string[]; + page?: number; + filters?: Record[]; +} diff --git a/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts b/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts index 6e9320e5e1..87699714b8 100644 --- a/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts @@ -9,7 +9,7 @@ import { Country } from '../../models'; export type Params = Record; export interface ResBody { id?: string; - userName?: string; + fullName?: string; firstName?: string; lastName?: string; email?: string; diff --git a/packages/types/src/types/requests/datatrak-web-server/UsersRequest.ts b/packages/types/src/types/requests/datatrak-web-server/UsersRequest.ts new file mode 100644 index 0000000000..e2770b648a --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/UsersRequest.ts @@ -0,0 +1,20 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { PermissionGroup } from '../../models'; + +export type Params = Record; + +type UserResponse = { + id: string; + name: string; +}; + +export type ResBody = UserResponse[]; +export type ReqBody = Record; +export interface ReqQuery { + searchTerm?: string; + permissionGroupId?: PermissionGroup['id']; +} diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index fcec54e1bc..598904707c 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -17,3 +17,8 @@ export * as DatatrakWebLeaderboardRequest from './LeaderboardRequest'; export * as DatatrakWebActivityFeedRequest from './ActivityFeedRequest'; export * as DatatrakWebGenerateLoginTokenRequest from './GenerateLoginTokenRequest'; export * as DatatrakWebEntityDescendantsRequest from './EntityDescendantsRequest'; +export * as DatatrakWebTaskMetricsRequest from './TaskMetricsRequest'; +export * as DatatrakWebTasksRequest from './TasksRequest'; +export * as DatatrakWebTaskRequest from './TaskRequest'; +export * as DatatrakWebUsersRequest from './UsersRequest'; +export * as DatatrakWebTaskChangeRequest from './TaskChangeRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index d3010441b9..b93544c385 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -20,6 +20,11 @@ export { DatatrakWebActivityFeedRequest, DatatrakWebGenerateLoginTokenRequest, DatatrakWebEntityDescendantsRequest, + DatatrakWebTaskMetricsRequest, + DatatrakWebTasksRequest, + DatatrakWebTaskRequest, + DatatrakWebUsersRequest, + DatatrakWebTaskChangeRequest, } from './datatrak-web-server'; export { TupaiaWebChangePasswordRequest, diff --git a/packages/ui-components/src/components/ActionsMenu.tsx b/packages/ui-components/src/components/ActionsMenu.tsx index 2d0724ec7e..4291beba15 100644 --- a/packages/ui-components/src/components/ActionsMenu.tsx +++ b/packages/ui-components/src/components/ActionsMenu.tsx @@ -5,20 +5,30 @@ import React from 'react'; import { + IconButton as MuiIconButton, ListItemIcon, - MenuItem as MuiMenuItem, Menu as MuiMenu, - IconButton, - Typography, + MenuItem as MuiMenuItem, Tooltip, + Typography, } from '@material-ui/core'; import MoreVertIcon from '@material-ui/icons/MoreVert'; import styled from 'styled-components'; import { ActionsMenuOptionType } from '../types'; +const StyledMenu = styled(MuiMenu)` + .MuiPaper-root { + border: 1px solid ${props => props.theme.palette.divider}; + } + .MuiList-root { + padding: 0.2rem; + } +`; + const StyledMenuItem = styled(MuiMenuItem)` - padding-top: 0.625rem; - padding-bottom: 0.625rem; + padding: 0.3rem 0.3rem; + font-size: 0.75rem; + min-width: 5rem; `; const StyledMenuIcon = styled(MoreVertIcon)` @@ -40,6 +50,7 @@ type ActionMenuProps = { vertical?: 'top' | 'bottom'; horizontal?: 'left' | 'right'; }; + IconButton?: typeof MuiIconButton; }; export const ActionsMenu = ({ @@ -47,14 +58,15 @@ export const ActionsMenu = ({ includesIcons = false, anchorOrigin = {}, transformOrigin = {}, + IconButton = MuiIconButton, }: ActionMenuProps) => { const [anchorEl, setAnchorEl] = React.useState<(EventTarget & HTMLButtonElement) | null>(null); return ( <> - setAnchorEl(event.currentTarget)}> + setAnchorEl(event.currentTarget)}> - setAnchorEl(null)} anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', + vertical: 'top', + horizontal: 'right', ...anchorOrigin, }} transformOrigin={{ horizontal: 'right', vertical: 'top', ...transformOrigin }} @@ -104,7 +116,7 @@ export const ActionsMenu = ({ ), )} - + ); }; diff --git a/packages/ui-components/src/components/Alert.tsx b/packages/ui-components/src/components/Alert.tsx index 6ace97a69f..b7f8b2006b 100644 --- a/packages/ui-components/src/components/Alert.tsx +++ b/packages/ui-components/src/components/Alert.tsx @@ -56,6 +56,7 @@ const StyledSmallAlert = styled(StyledAlert)` padding-block: 0; padding-inline: 1rem; box-shadow: none; + word-break: break-word; .MuiAlert-icon { padding: 0.5rem 0; diff --git a/packages/ui-components/src/components/Button.tsx b/packages/ui-components/src/components/Button.tsx index 3bcbc8119f..43179a2cf2 100644 --- a/packages/ui-components/src/components/Button.tsx +++ b/packages/ui-components/src/components/Button.tsx @@ -11,7 +11,7 @@ import { OverrideableComponentProps } from '../types'; const StyledButton = styled(MuiButton)` line-height: 1.75; letter-spacing: 0; - padding: 0.5em 1.75em; + padding: 0.5rem 1.75rem; box-shadow: none; min-width: 3rem; diff --git a/packages/ui-components/src/components/FilterableTable/Cells.tsx b/packages/ui-components/src/components/FilterableTable/Cells.tsx index a8c442048b..37df653e17 100644 --- a/packages/ui-components/src/components/FilterableTable/Cells.tsx +++ b/packages/ui-components/src/components/FilterableTable/Cells.tsx @@ -32,7 +32,7 @@ const CellContentWrapper = styled.div` align-items: center; tr:not(:last-child) & { - border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; } td:first-child & { padding-inline-start: 0.2rem; @@ -53,7 +53,7 @@ const HeaderCell = styled(Cell)` color: ${({ theme }) => theme.palette.text.secondary}; font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; background-color: ${({ theme }) => theme.palette.background.paper}; - border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; padding-block: 0.7rem; padding-inline: 0.7rem 0; display: flex; @@ -71,7 +71,7 @@ const CellLink = styled(Link)` color: inherit; text-decoration: none; &:hover { - tr:has(&) td > * { + tr:has(&) td { background-color: ${({ theme }) => `${theme.palette.primary.main}18`}; // 18 is 10% opacity } } @@ -116,15 +116,20 @@ interface TableCellProps { width?: string; row: Record; maxWidth?: number; + column?: Record; } -export const TableCell = ({ children, width, row, maxWidth, ...props }: TableCellProps) => { - const url = row?.original?.url; +export const TableCell = ({ children, width, row, maxWidth, column, ...props }: TableCellProps) => { + const getRowUrl = () => { + if (!row) return {}; + if (row.url) return { to: row.url }; + if (column?.generateUrl) return column.generateUrl(row); + return {}; + }; + const { to, state } = getRowUrl(); return ( - - - {children} - + + {children} ); diff --git a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx index d939bedd44..0524f22f7d 100644 --- a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx +++ b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx @@ -2,17 +2,22 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; -import { HeaderDisplayCell, HeaderDisplayCellProps } from './Cells'; -import { TextField } from '../Inputs'; import { Search } from '@material-ui/icons'; +import { StandardTextFieldProps } from '@material-ui/core'; import { ColumnInstance } from 'react-table'; +import { TextField } from '../Inputs'; +import { HeaderDisplayCell, HeaderDisplayCellProps } from './Cells'; +import { useDebounce } from '../../hooks'; const FilterWrapper = styled.div` .MuiFormControl-root { margin-block-end: 0; } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.divider}; + } .MuiInputBase-input, .MuiOutlinedInput-root { font-size: inherit; @@ -39,15 +44,12 @@ const FilterWrapper = styled.div` .MuiAutocomplete-option { padding-block: 0.5rem; } + .MuiInputBase-input::-webkit-input-placeholder { + color: ${({ theme }) => theme.palette.text.secondary}; + } `; -export const DefaultFilter = styled(TextField).attrs(props => ({ - InputProps: { - ...props.InputProps, - startAdornment: , - }, - placeholder: 'Search...', -}))` +const DefaultFilterInput = styled(TextField)` margin-block-end: 0; font-size: inherit; width: 100%; @@ -64,10 +66,41 @@ export const DefaultFilter = styled(TextField).attrs(props => ({ padding-inline-start: 0.3rem; } .MuiSvgIcon-root { - color: ${({ theme }) => theme.palette.text.tertiary}; + color: ${({ theme }) => theme.palette.divider}; } `; +interface DefaultFilterProps extends Omit { + value?: string | null; + onChange: (value: string) => void; +} + +const DefaultFilter = ({ value, onChange, ...props }: DefaultFilterProps) => { + const [stateValue, setStateValue] = useState(value ?? ''); + const debouncedSearchValue = useDebounce(stateValue, 500); + + useEffect(() => { + if (debouncedSearchValue === value) return; + onChange(debouncedSearchValue); + }, [debouncedSearchValue]); + + useEffect(() => { + if (value === stateValue) return; + setStateValue(value ?? ''); + }, [value]); + return ( + setStateValue(e.target.value)} + InputProps={{ + startAdornment: , + }} + placeholder="Search..." + /> + ); +}; + export type Filters = Record[]; export interface FilterCellProps extends Partial { @@ -76,6 +109,7 @@ export interface FilterCellProps extends Partial { column: ColumnInstance>; filter?: any; onChange: (value: any) => void; + value: any; }>; filterable?: boolean; }; @@ -99,11 +133,16 @@ export const FilterCell = ({ column, filters, onChangeFilters, ...props }: Filte {Filter ? ( - + ) : ( handleUpdate(e.target.value)} + onChange={handleUpdate} aria-label={`Search ${column.Header}`} /> )} diff --git a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx index 7c2ceae9d0..d263838d9c 100644 --- a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx +++ b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx @@ -11,10 +11,12 @@ import { TableHead, TableRow, TableSortLabel, + Typography, } from '@material-ui/core'; import styled from 'styled-components'; import { Column, useFlexLayout, useResizeColumns, useTable, SortingRule } from 'react-table'; import { KeyboardArrowDown } from '@material-ui/icons'; +import { SpinningLoader } from '../Loaders'; import { HeaderDisplayCell, TableCell } from './Cells'; import { FilterCell, FilterCellProps, Filters } from './FilterCell'; import { Pagination } from './Pagination'; @@ -23,6 +25,9 @@ const TableContainer = styled(MuiTableContainer)` position: relative; flex: 1; overflow: auto; + display: flex; + flex-direction: column; + background-color: ${({ theme }) => theme.palette.background.paper}; table { min-width: 45rem; } @@ -31,23 +36,39 @@ const TableContainer = styled(MuiTableContainer)` position: sticky; top: 0; z-index: 2; - background-color: ${({ theme }) => theme.palette.background.paper}; } tr { display: flex; - + } .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { border-color: ${({ theme }) => theme.palette.primary.main}; } `; +const NoDataMessage = styled.div` + width: 100%; + text-align: center; + padding-block: 2.5rem; +`; + +const LoadingContainer = styled.div` + flex: 1; + display: flex; + justify-content: center; + align-items: center; +`; + type SortBy = { id: string; desc: boolean; }; +type ColumnInstance = Record & { + CellContentComponent?: React.ComponentType; +}; + interface FilterableTableProps { - columns: Column>[]; + columns: Column[]; data?: Record[]; pageIndex?: number; pageSize?: number; @@ -57,12 +78,11 @@ interface FilterableTableProps { onChangePage: (pageIndex: number) => void; onChangePageSize: (pageSize: number) => void; onChangeSorting: (sorting: SortingRule>[]) => void; - refreshData: () => void; - isLoading: boolean; - errorMessage: string; onChangeFilters: FilterCellProps['onChangeFilters']; filters?: Filters; totalRecords: number; + noDataMessage?: string; + isLoading?: boolean; } export const FilterableTable = ({ @@ -79,6 +99,8 @@ export const FilterableTable = ({ onChangeFilters, filters = [], totalRecords, + noDataMessage, + isLoading, }: FilterableTableProps) => { const memoisedData = useMemo(() => data ?? [], [data]); const { @@ -189,13 +211,14 @@ export const FilterableTable = ({ return ( {row.cells.map(({ getCellProps, render }, i) => { - const col = visibleColumns[i]; + const col = visibleColumns[i] as ColumnInstance; return ( {render('Cell')} @@ -206,6 +229,16 @@ export const FilterableTable = ({ })} + {rows.length === 0 && noDataMessage && !isLoading && ( + + {noDataMessage} + + )} + {isLoading && ( + + + + )} theme.palette.grey['400']}; + border-top: 1px solid ${({ theme }) => theme.palette.divider}; background-color: ${({ theme }) => theme.palette.background.paper}; } .MuiSelect-root { padding-block: 0.5rem; padding-inline: 0.8rem 0.2rem; } + .MuiOutlinedInput-notchedOutline, + .MuiInput-root, + .MuiButtonBase-root { + border-color: ${({ theme }) => theme.palette.divider}; + } `; interface PaginationProps { @@ -34,17 +39,16 @@ export const Pagination = ({ onChangePageSize, totalRecords, }: PaginationProps) => { - if (!totalRecords) return null; - return ( ); diff --git a/packages/ui-components/src/components/Inputs/Autocomplete.tsx b/packages/ui-components/src/components/Inputs/Autocomplete.tsx index 3850b23224..423b6536a0 100644 --- a/packages/ui-components/src/components/Inputs/Autocomplete.tsx +++ b/packages/ui-components/src/components/Inputs/Autocomplete.tsx @@ -116,6 +116,7 @@ export const Autocomplete = ({ renderOption={renderOption} popupIcon={} PaperComponent={StyledPaper} + blurOnSelect renderInput={params => ( { - const [status, setStatus] = useState(STATUS.IDLE); - const [errorMessage, setErrorMessage] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [file, setFile] = useState(null); - - const handleSubmit = async event => { - event.preventDefault(); - setErrorMessage(null); - setStatus(STATUS.LOADING); - - try { - const { message } = await onSubmit(file); - if (showLoadingContainer && message) { - setStatus(STATUS.SUCCESS); - setSuccessMessage(message); - } else { - handleClose(); - } - } catch (error) { - setStatus(STATUS.ERROR); - setErrorMessage(error.message); - } - }; - - const handleClose = async () => { - onClose(); - - setStatus(STATUS.IDLE); - setErrorMessage(null); - setSuccessMessage(null); - setFile(null); - }; - - const handleDismiss = () => { - setStatus(STATUS.IDLE); - setErrorMessage(null); - setSuccessMessage(null); - // Deselect file when dismissing an error, this avoids an error when editing selected files - // @see https://github.com/beyondessential/tupaia-backlog/issues/1211 - setFile(null); - }; - - const ContentContainer = showLoadingContainer - ? ({ children }) => ( - - {children} - - ) - : React.Fragment; - - const renderContent = useCallback(() => { - switch (status) { - case STATUS.SUCCESS: - return

    {successMessage}

    ; - case STATUS.ERROR: - return ( - <> - An error has occurred. - - {errorMessage} - - - ); - default: - return ( - <> -

    {subtitle}

    -
    - setFile(files[0])} name="file-upload" /> - - - ); - } - }, [status, successMessage, errorMessage, subtitle]); - - const renderButtons = useCallback(() => { - switch (status) { - case STATUS.SUCCESS: - return ; - case STATUS.ERROR: - return ( - <> - Dismiss - - - ); - default: - return ( - <> - - - - ); - } - }, [status, file, handleDismiss, handleClose, handleSubmit]); - - return ( - - - - {renderContent()} - - {renderButtons()} - - ); -}; - -ImportModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - title: PropTypes.string, - subtitle: PropTypes.string, - actionText: PropTypes.string, - loadingText: PropTypes.string, - loadingHeading: PropTypes.string, - showLoadingContainer: PropTypes.bool, - onSubmit: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -ImportModal.defaultProps = { - title: 'Import', - subtitle: '', - actionText: 'Import', - loadingText: 'Importing', - loadingHeading: 'Importing data', - showLoadingContainer: false, -}; diff --git a/packages/admin-panel/src/widgets/Modal/Modal.jsx b/packages/ui-components/src/components/Modal/Modal.tsx similarity index 64% rename from packages/admin-panel/src/widgets/Modal/Modal.jsx rename to packages/ui-components/src/components/Modal/Modal.tsx index 9a0eed83e2..a0774d4e9d 100644 --- a/packages/admin-panel/src/widgets/Modal/Modal.jsx +++ b/packages/ui-components/src/components/Modal/Modal.tsx @@ -4,10 +4,10 @@ */ import React from 'react'; import styled from 'styled-components'; -import PropTypes from 'prop-types'; -import { DialogFooter as BaseDialogFooter, Button } from '@tupaia/ui-components'; -import { Dialog } from '@material-ui/core'; -import { ModalContentProvider } from './ModalContentProvider'; +import { ButtonProps, Dialog, DialogProps } from '@material-ui/core'; +import { DialogFooter as BaseDialogFooter } from '../Dialog'; +import { Button } from '../Button'; +import { ModalContentProvider, ModalContentProviderProps } from './ModalContentProvider'; import { ModalHeader } from './ModalHeader'; export const ModalFooter = styled(BaseDialogFooter)` @@ -16,6 +16,25 @@ export const ModalFooter = styled(BaseDialogFooter)` padding-inline: 1.9rem; `; +type ButtonT = Omit & { + id: string; + text: string; + component?: React.ElementType; + to?: string; + type?: string; + variant?: string; // declare as a string here because passing 'contained' or 'outlined' is coming up as invalid elsewhere +}; + +interface ModalProps extends Omit { + children: React.ReactNode; + isOpen: boolean; + onClose: () => void; + title: string; + isLoading?: boolean; + error?: ModalContentProviderProps['error']; + buttons?: ButtonT[]; +} + export const Modal = ({ children, isOpen, @@ -23,9 +42,9 @@ export const Modal = ({ title, isLoading, error, - buttons, + buttons = [], ...muiDialogProps -}) => { +}: ModalProps) => { const getModalTitle = () => { if (error) { return title || 'Error'; @@ -55,6 +74,7 @@ export const Modal = ({ to, }) => (