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 +
+