From e4995ef0813e7032111ccad56fd66694f528d534 Mon Sep 17 00:00:00 2001 From: lennygir Date: Mon, 8 Jan 2024 12:29:52 +0100 Subject: [PATCH 1/4] feat: notify when a proposal expires in 7 days --- server/package-lock.json | 23 ++++++++++ server/package.json | 1 + server/src/cronjobs.js | 30 +++++++++++++ server/src/mail/proposal-expiration.js | 21 +++++++++ server/src/routes.js | 2 + server/src/server.js | 3 ++ server/src/theses-dao.js | 62 +++++++++++++++++++++++++- 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 server/src/cronjobs.js create mode 100644 server/src/mail/proposal-expiration.js diff --git a/server/package-lock.json b/server/package-lock.json index e607dd0..cab27a7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "better-sqlite3": "^9.1.1", "cors": "^2.8.5", + "cron": "^3.1.6", "dayjs": "^1.11.10", "dotenv": "^16.3.1", "eslint-plugin-jest": "^27.6.0", @@ -1363,6 +1364,11 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "node_modules/@types/luxon": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.8.tgz", + "integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==" + }, "node_modules/@types/node": { "version": "20.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", @@ -2254,6 +2260,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cron": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz", + "integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4647,6 +4662,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index 9ef935b..3865745 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,7 @@ "dependencies": { "better-sqlite3": "^9.1.1", "cors": "^2.8.5", + "cron": "^3.1.6", "dayjs": "^1.11.10", "dotenv": "^16.3.1", "eslint-plugin-jest": "^27.6.0", diff --git a/server/src/cronjobs.js b/server/src/cronjobs.js new file mode 100644 index 0000000..6598640 --- /dev/null +++ b/server/src/cronjobs.js @@ -0,0 +1,30 @@ +const { CronJob } = require('cron'); +const { getProposalsThatExpireInXDays, notifyProposalExpiration } = require('./theses-dao'); + +const cronjobNames = { + THESIS_EXPIRED: 'THESIS_EXPIRED' +}; + +const cronjobs = {}; + +const initCronjobs = () => { + cronjobs[cronjobNames.THESIS_EXPIRED] = new CronJob("0 0 * * *" , () => { + const proposals = getProposalsThatExpireInXDays(7); + proposals.forEach(async (proposal) => { + notifyProposalExpiration(proposal); + }); + }, undefined, true, "Europe/Rome", undefined, true); +}; + +const runCronjob = (cronjobName) => { + console.log('[Cron] Running job ' + cronjobName); + if(cronjobs[cronjobName]) { + cronjobs[cronjobName].fireOnTick(); + } +}; + +module.exports = { + cronjobNames, + runCronjob, + initCronjobs +}; \ No newline at end of file diff --git a/server/src/mail/proposal-expiration.js b/server/src/mail/proposal-expiration.js new file mode 100644 index 0000000..f47a14f --- /dev/null +++ b/server/src/mail/proposal-expiration.js @@ -0,0 +1,21 @@ +const proposalExpirationHtmlTemplate = (variables) => { + return ` + + +

Dear ${variables.name},

+

your thesis proposal "${variables.thesis}" expires in ${variables.nbOfDays} days (${variables.date}).

+

Best regards,

+

the Thesis Managment system

+ + + `; +}; + +const proposalExpirationTextTemplate = (variables) => { + return `Dear ${variables.name},\nyour thesis proposal "${variables.thesis}" expires in ${variables.nbOfDays} days (${variables.date}).\nBest regards,\nthe Thesis Managment system`; +}; + +exports.proposalExpirationTemplate = (variables) => ({ + html: proposalExpirationHtmlTemplate(variables), + text: proposalExpirationTextTemplate(variables) +}); \ No newline at end of file diff --git a/server/src/routes.js b/server/src/routes.js index 516d421..ebed883 100644 --- a/server/src/routes.js +++ b/server/src/routes.js @@ -51,6 +51,7 @@ const { getAcceptedProposal, } = require("./theses-dao"); const { getUser } = require("./user-dao"); +const { runCronjob, cronjobNames } = require("./cronjobs"); function getDate() { const clock = getDelta(); @@ -887,6 +888,7 @@ router.patch( return res.status(400).json({ message: "Cannot go back in the past" }); } setDelta(newDelta); + runCronjob(cronjobNames.THESIS_EXPIRED); return res.status(200).json({ message: "Date successfully changed" }); } catch (err) { return res.status(500).json({ message: "Internal Server Error" }); diff --git a/server/src/server.js b/server/src/server.js index 6583e73..9bc8220 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -5,6 +5,7 @@ const session = require("express-session"); const passport = require("passport"); require("./passport"); const { router } = require("./routes"); +const { initCronjobs } = require("./cronjobs"); const app = new express(); @@ -15,6 +16,8 @@ const corsOptions = { }; app.use(cors(corsOptions)); +initCronjobs(); + app.use( session({ secret: "Alright, then, keep your secrets.", diff --git a/server/src/theses-dao.js b/server/src/theses-dao.js index 9a29740..97d134a 100644 --- a/server/src/theses-dao.js +++ b/server/src/theses-dao.js @@ -9,6 +9,8 @@ const { newApplicationTemplate } = require("./mail/new-application"); const { supervisorStartRequestTemplate } = require("./mail/supervisor-start-request"); const { cosupervisorApplicationDecisionTemplate } = require("./mail/cosupervisor-application-decision"); const { cosupervisorStartRequestTemplate } = require("./mail/cosupervisor-start-request"); +const dayjs = require("dayjs"); +const { proposalExpirationTemplate } = require("./mail/proposal-expiration"); exports.insertApplication = (proposal, student, state) => { const result = db @@ -441,6 +443,35 @@ exports.notifyNewApplication = async (proposalId) => { ); }; +exports.notifyProposalExpiration = async (proposal) => { + // Send email to the supervisor + const mailBody = proposalExpirationTemplate({ + name: proposal.teacher_surname + " " + proposal.teacher_name, + thesis: proposal.title, + nbOfDays: 7, + date: dayjs(proposal.expiration_date).format("DD/MM/YYYY") + }); + try { + await nodemailer.sendMail({ + to: proposal.teacher_email, + subject: "Your proposal expires in 7 days", + text: mailBody.text, + html: mailBody.html, + }); + } catch (e) { + console.log("[mail service]", e); + } + + // Save email in DB + db.prepare( + "INSERT INTO NOTIFICATIONS(teacher_id, object, content) VALUES(?,?,?)", + ).run( + proposal.supervisor, + "Your proposal expires in 7 days", + mailBody.text, + ); +}; + /** * @param teacher_id * @returns {[ @@ -482,6 +513,34 @@ exports.getApplicationsOfTeacher = (teacher_id) => { .all(teacher_id); }; +/** + * @param nbOfDaysBeforeExpiration + * @returns {[ + * { + * supervisor, + * expiration_date, + * title + * } + * ]} + */ + exports.getProposalsThatExpireInXDays = (nbOfDaysBeforeExpiration) => { + const currentDate = dayjs().add(getDelta().delta, 'day'); + const notificationDateFormatted = currentDate.add(nbOfDaysBeforeExpiration, 'day').format('YYYY-MM-DD'); + return db + .prepare( + `select supervisor, + t.surname as teacher_surname, + t.email as teacher_email, + t.name as teacher_name, + expiration_date, + title + from PROPOSALS p + join TEACHER t on p.supervisor = t.id + where expiration_date = ?`, + ) + .all(notificationDateFormatted); + }; + exports.getApplicationsOfStudent = (student_id) => { return db .prepare( @@ -553,9 +612,10 @@ exports.updateArchivedStateProposal = (new_archived_state, proposal_id) => { ); }; -exports.getDelta = () => { +const getDelta = () => { return db.prepare("select delta from VIRTUAL_CLOCK where id = 1").get(); }; +exports.getDelta = getDelta; exports.setDelta = (delta) => { return db From 813b14e50dcf9811991f913cbe8881c217501356 Mon Sep 17 00:00:00 2001 From: lennygir Date: Thu, 11 Jan 2024 16:53:32 +0100 Subject: [PATCH 2/4] fix: fix notification if there's no proposal that expire in 7d --- server/src/cronjobs.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/cronjobs.js b/server/src/cronjobs.js index 6598640..71ec3ab 100644 --- a/server/src/cronjobs.js +++ b/server/src/cronjobs.js @@ -10,9 +10,11 @@ const cronjobs = {}; const initCronjobs = () => { cronjobs[cronjobNames.THESIS_EXPIRED] = new CronJob("0 0 * * *" , () => { const proposals = getProposalsThatExpireInXDays(7); - proposals.forEach(async (proposal) => { - notifyProposalExpiration(proposal); - }); + if(proposals) { + proposals.forEach(async (proposal) => { + notifyProposalExpiration(proposal); + }); + } }, undefined, true, "Europe/Rome", undefined, true); }; From 694988eeb1a0e84b44c28d6df751d00cb3406339 Mon Sep 17 00:00:00 2001 From: lennygir Date: Thu, 11 Jan 2024 17:04:22 +0100 Subject: [PATCH 3/4] fix: async is not async --- server/src/cronjobs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/cronjobs.js b/server/src/cronjobs.js index 71ec3ab..cd179fd 100644 --- a/server/src/cronjobs.js +++ b/server/src/cronjobs.js @@ -11,7 +11,7 @@ const initCronjobs = () => { cronjobs[cronjobNames.THESIS_EXPIRED] = new CronJob("0 0 * * *" , () => { const proposals = getProposalsThatExpireInXDays(7); if(proposals) { - proposals.forEach(async (proposal) => { + proposals.forEach((proposal) => { notifyProposalExpiration(proposal); }); } From d569877bb67e2446f77b9f80b38cfecb44f226de Mon Sep 17 00:00:00 2001 From: lennygir Date: Mon, 15 Jan 2024 17:33:00 +0100 Subject: [PATCH 4/4] fix: test coverage --- server/tests/proposal.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/tests/proposal.test.js b/server/tests/proposal.test.js index 52d1a07..0705afa 100644 --- a/server/tests/proposal.test.js +++ b/server/tests/proposal.test.js @@ -27,10 +27,12 @@ const { getPendingApplicationsOfStudent, getAcceptedApplicationsOfStudent, getRequestsForClerk, + getProposalsThatExpireInXDays, } = require("../src/theses-dao"); const dayjs = require("dayjs"); const isLoggedIn = require("../src/protect-routes"); +const { runCronjob, cronjobNames } = require("../src/cronjobs"); jest.mock("../src/theses-dao"); jest.mock("../src/protect-routes"); @@ -942,3 +944,21 @@ describe("GET /api/start-requests", () => { .expect(200); }); }); + +describe("Cronjobs", () => { + test("Notify on thesis expiration (7 days before)", async () => { + const expectedDate = dayjs().add(7 + 2, "day"); + getDelta.mockReturnValue({ delta: 2 }); + getProposalsThatExpireInXDays.mockReturnValue([ + { + supervisor: "s234567", + teacher_surname: "Torchiano", + teacher_email: "marco.torchiano@teacher.it", + teacher_name: "Marco", + expiration_date: expectedDate.format("YYYY-MM-DD"), + title: "Test proposal" + } + ]); + await runCronjob(cronjobNames.THESIS_EXPIRED); + }); +});