diff --git a/packages/central-server/package.json b/packages/central-server/package.json index 06c30b53bb..4428c84d84 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.10.9" }, "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..eecf6d90e8 --- /dev/null +++ b/packages/central-server/scripts/scheduledTask.js @@ -0,0 +1,48 @@ +/* + * 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 { TaskOverdueChecker } from '../src/scheduledTasks'; +import * as modelClasses from '../src/database/models'; + +const SCHEDULED_TASK_MODULES = { + TaskOverdueChecker, +}; + +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/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/index.js b/packages/central-server/src/index.js index 6ac34d7cb6..ae5ab8867b 100644 --- a/packages/central-server/src/index.js +++ b/packages/central-server/src/index.js @@ -5,6 +5,7 @@ import '@babel/polyfill'; import http from 'http'; +import nodeSchedule from 'node-schedule'; import { AnalyticsRefresher, EntityHierarchyCacher, @@ -24,7 +25,7 @@ import { startSyncWithMs1 } from './ms1'; import { startSyncWithKoBo } from './kobo'; import { startFeedScraper } from './social'; import { createApp } from './createApp'; - +import { TaskOverdueChecker } from './scheduledTasks'; import winston from './log'; import { configureEnv } from './configureEnv'; @@ -69,6 +70,11 @@ configureEnv(); const taskAssigneeEmailer = new TaskAssigneeEmailer(models); taskAssigneeEmailer.listenForChanges(); + /** + * Scheduled tasks + */ + new TaskOverdueChecker(models).init(); + /** * Set up actual app with routes etc. */ @@ -117,4 +123,11 @@ configureEnv(); 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/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..81b52e0ce0 --- /dev/null +++ b/packages/central-server/src/scheduledTasks/TaskOverdueChecker.js @@ -0,0 +1,45 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { sendEmail } from '@tupaia/server-utils'; +import { format } from 'date-fns'; +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 } = this.models; + const overdueTasks = await task.find({ + task_status: 'overdue', + overdue_email_sent: null, + }); + + winston.info(`Found ${overdueTasks.length} overdue task(s)`); + + for (const task of overdueTasks) { + const assignee = await user.findById(task.assignee_id); + + const result = await sendEmail(assignee.email, { + subject: 'Task overdue on Tupaia.org', + templateName: 'overdueTask', + templateContext: { + userName: assignee.first_name, + surveyName: task.survey_name, + entityName: task.entity_name, + dueDate: format(new Date(task.due_date), 'do MMMM yyyy'), + }, + }); + + winston.info(`Email sent to ${assignee.email} with status: ${result.response}`); + + task.overdue_email_sent = new Date(); + await task.save(); + } + } +} diff --git a/packages/central-server/src/scheduledTasks/index.js b/packages/central-server/src/scheduledTasks/index.js new file mode 100644 index 0000000000..33d1e3b1df --- /dev/null +++ b/packages/central-server/src/scheduledTasks/index.js @@ -0,0 +1,6 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { TaskOverdueChecker } from './TaskOverdueChecker'; 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/server-utils/src/email/templates/content/overdueTask.html b/packages/server-utils/src/email/templates/content/overdueTask.html new file mode 100644 index 0000000000..a25e2eabba --- /dev/null +++ b/packages/server-utils/src/email/templates/content/overdueTask.html @@ -0,0 +1,18 @@ +
+

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}} and was due on the {{dueDate}}. 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. +

+

+ Cheers +

+

+ The Tupaia Team +

+
diff --git a/yarn.lock b/yarn.lock index a904507d78..1630ccd92c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11849,6 +11849,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tupaia/central-server@workspace:packages/central-server" dependencies: + "@babel/node": ^7.10.5 "@babel/polyfill": ^7.0.0 "@tupaia/access-policy": "workspace:*" "@tupaia/auth": "workspace:*" @@ -11873,6 +11874,7 @@ __metadata: cors: ^2.8.5 countrynames: ^0.1.1 cross-env: ^7.0.2 + date-fns: ^2.29.2 deep-equal-in-any-order: ^1.0.21 del: ^2.2.2 express: ^4.19.2 @@ -11890,6 +11892,7 @@ __metadata: moment-timezone: ^0.5.45 morgan: ^1.9.0 multer: ^1.4.3 + node-schedule: ^2.1.1 npm-run-all: ^4.1.5 nyc: ^15.1.0 public-ip: ^2.5.0 @@ -19713,6 +19716,15 @@ __metadata: languageName: node linkType: hard +"cron-parser@npm:^4.2.0": + version: 4.9.0 + resolution: "cron-parser@npm:4.9.0" + dependencies: + luxon: ^3.2.1 + checksum: 3cf248fc5cae6c19ec7124962b1cd84b76f02b9bc4f58976b3bd07624db3ef10aaf1548efcc2d2dcdab0dad4f12029d640a55ecce05ea5e1596af9db585502cf + languageName: node + linkType: hard + "croner@npm:~4.1.92": version: 4.1.97 resolution: "croner@npm:4.1.97" @@ -30463,6 +30475,13 @@ __metadata: languageName: node linkType: hard +"long-timeout@npm:0.1.1": + version: 0.1.1 + resolution: "long-timeout@npm:0.1.1" + checksum: 48668e5362cb74c4b77a6b833d59f149b9bb9e99c5a5097609807e2597cd0920613b2a42b89bd0870848298be3691064d95599a04ae010023d07dba39932afa7 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.2.0, loose-envify@npm:^1.3.1, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -30572,6 +30591,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.2.1": + version: 3.5.0 + resolution: "luxon@npm:3.5.0" + checksum: f290fe5788c8e51e748744f05092160d4be12150dca70f9fadc0d233e53d60ce86acd82e7d909a114730a136a77e56f0d3ebac6141bbb82fd310969a4704825b + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" @@ -32672,6 +32698,17 @@ __metadata: languageName: node linkType: hard +"node-schedule@npm:^2.1.1": + version: 2.1.1 + resolution: "node-schedule@npm:2.1.1" + dependencies: + cron-parser: ^4.2.0 + long-timeout: 0.1.1 + sorted-array-functions: ^1.3.0 + checksum: 6a8822b16fb024277c42efe710bdb35b6f1f6ab3a2f826283640511247d693f34ebd5ddf2863cd91609e7f323574e36c81cd2084dc204fa521f931380f0f963f + languageName: node + linkType: hard + "node-stream-zip@npm:^1.9.1": version: 1.12.0 resolution: "node-stream-zip@npm:1.12.0" @@ -39478,6 +39515,13 @@ __metadata: languageName: node linkType: hard +"sorted-array-functions@npm:^1.3.0": + version: 1.3.0 + resolution: "sorted-array-functions@npm:1.3.0" + checksum: 673fd39ca3b6c92644d4483eac1700bb7d7555713a536822a7522a35af559bef3e72f10d89356b75042dc394cd7c2e2ab6f40024385218ec3c85bb7335032857 + languageName: node + linkType: hard + "source-list-map@npm:^2.0.0": version: 2.0.1 resolution: "source-list-map@npm:2.0.1"