From fb151c39fed6de3d18d331ff8cae22208110ae70 Mon Sep 17 00:00:00 2001 From: chin Date: Tue, 15 Aug 2023 22:39:46 +0800 Subject: [PATCH] feat: #14 - Add deleteStatus == 0 in every query to filter not delete items --- .../WADO-RS/deletion/service/delete.js | 93 +++++----------- .../WADO-RS/service/rendered.service.js | 5 + models/DICOM/dicom-json-model.js | 1 + models/mongodb/deleteSchedule.js | 101 ++++++++++++++++++ models/mongodb/index.js | 1 + models/mongodb/models/dicom.js | 58 +++++++++- models/mongodb/models/dicomSeries.js | 37 ++++++- models/mongodb/models/dicomStudy.js | 54 +++++++--- package-lock.json | 76 +++++++++++++ package.json | 1 + 10 files changed, 336 insertions(+), 91 deletions(-) create mode 100644 models/mongodb/deleteSchedule.js diff --git a/api/dicom-web/controller/WADO-RS/deletion/service/delete.js b/api/dicom-web/controller/WADO-RS/deletion/service/delete.js index 63eade4c..d8129f68 100644 --- a/api/dicom-web/controller/WADO-RS/deletion/service/delete.js +++ b/api/dicom-web/controller/WADO-RS/deletion/service/delete.js @@ -12,7 +12,7 @@ class DeleteService { * @param {import("express").Response} res * @param { "study" | "series" | "instance" } level */ - constructor(req, res, level="study") { + constructor(req, res, level = "study") { this.request = req; this.response = res; this.level = level; @@ -20,103 +20,62 @@ class DeleteService { async delete() { let deleteFns = {}; - deleteFns["study"] = async() => this.deleteStudy(); - deleteFns["series"] = async() => this.deleteSeries(); - deleteFns["instance"] = async() => this.deleteInstance(); + deleteFns["study"] = async () => this.deleteStudy(); + deleteFns["series"] = async () => this.deleteSeries(); + deleteFns["instance"] = async () => this.deleteInstance(); await deleteFns[this.level](); } - + async deleteStudy() { - let studyImagesPathObjs = await dicomStudyModel.getPathGroupOfInstances({ + let study = await dicomStudyModel.findOne({ ...this.request.params }); - if(studyImagesPathObjs.length == 0) { + if (!study) { throw new NotFoundInstanceError(`Can not found studyUID: ${this.request.params.studyUID} instances' files`); } - for(let imagePathObj of studyImagesPathObjs) { - try { - await Promise.all([ - dicomStudyModel.deleteOne({ - studyUID: imagePathObj.studyUID - }), - dicomSeriesModel.deleteMany({ - studyUID: imagePathObj.studyUID - }), - dicomModel.deleteMany({ - studyUID: imagePathObj.studyUID - }) - ]); - await fsP.unlink(imagePathObj.instancePath); - } catch(e) { - console.error(e); - throw e; - } + try { + await study.incrementDeleteStatus(); + } catch (e) { + console.error(e); + throw e; } + } async deleteSeries() { - let seriesImagesPathObjs = await dicomSeriesModel.getPathGroupOfInstances({ + let aSeries = await dicomSeriesModel.findOne({ ...this.request.params }); - if(seriesImagesPathObjs.length == 0) { + if (!aSeries) { throw new NotFoundInstanceError(`Can not found studyUID: ${this.request.params.studyUID}, seriesUID: ${this.request.params.seriesUID}' files`); } - for(let imagePathObj of seriesImagesPathObjs) { - try { - await Promise.all([ - dicomSeriesModel.deleteMany({ - $and: [ - { - studyUID: imagePathObj.studyUID - }, - { - seriesUID: imagePathObj.seriesUID - } - ] - - }), - dicomModel.deleteMany({ - $and: [ - { - studyUID: imagePathObj.studyUID - }, - { - seriesUID: imagePathObj.seriesUID - } - ] - - }) - ]); - await fsP.unlink(imagePathObj.instancePath); - } catch(e) { - console.error(e); - throw e; - } + try { + await aSeries.incrementDeleteStatus(); + } catch (e) { + console.error(e); + throw e; } } + async deleteInstance() { - let imagePathObj = await dicomModel.getPathOfInstance({ + let instance = await dicomModel.findOne({ ...this.request.params }); - if(!imagePathObj) { + if (!instance) { throw new NotFoundInstanceError(`Can not found studyUID: ${this.request.params.studyUID}, seriesUID: ${this.request.params.seriesUID}, instanceUID: ${this.request.params.instanceUID} instances' files`); } try { - await Promise.all([ - dicomModel.deleteOne({ - instanceUID: imagePathObj.instanceUID - }) - ]); - await fsP.unlink(imagePathObj.instancePath); - } catch(e) { + await instance.incrementDeleteStatus(); + + } catch (e) { console.error(e); throw e; } diff --git a/api/dicom-web/controller/WADO-RS/service/rendered.service.js b/api/dicom-web/controller/WADO-RS/service/rendered.service.js index 1e3b7a81..4082bce3 100644 --- a/api/dicom-web/controller/WADO-RS/service/rendered.service.js +++ b/api/dicom-web/controller/WADO-RS/service/rendered.service.js @@ -289,6 +289,11 @@ async function getInstanceFrameObj(iParam, otherFields={}) { "00080016.Value": { $nin: notImageSOPClass } + }, + { + deleteStatus: { + $eq: 0 + } } ] }; diff --git a/models/DICOM/dicom-json-model.js b/models/DICOM/dicom-json-model.js index c9328336..0b978ba9 100644 --- a/models/DICOM/dicom-json-model.js +++ b/models/DICOM/dicom-json-model.js @@ -143,6 +143,7 @@ class DicomJsonModel { instancePath: dicomFileSaveInfo.relativePath }); _.merge(dicomJsonClone, mediaStorage); + _.set(dicomJsonClone, "deleteStatus", 0); delete dicomJsonClone.sopClass; delete dicomJsonClone.sopInstanceUID; diff --git a/models/mongodb/deleteSchedule.js b/models/mongodb/deleteSchedule.js new file mode 100644 index 00000000..ee6ce2f5 --- /dev/null +++ b/models/mongodb/deleteSchedule.js @@ -0,0 +1,101 @@ +const schedule = require("node-schedule"); +const moment = require("moment"); +const { logger } = require("@root/utils/logs/log"); +const dicomStudyModel = require("./models/dicomStudy"); +const dicomModel = require("./models/dicom"); +const dicomSeriesModel = require("./models/dicomSeries"); + +// Delete dicom with delete status >= 2 +schedule.scheduleJob("*/5 * * * * *", async function () { + deleteExpireStudies().catch((e) => { + logger.error(e); + }); + deleteExpireSeries().catch((e) => { + logger.error(e); + }); + deleteExpireInstances().catch((e) => { + logger.error(e); + }); +}); + + +async function deleteExpireStudies() { + let deletedStudies = await dicomStudyModel.find({ + deleteStatus: { + $gte: 2 + } + }); + + for (let deletedStudy of deletedStudies) { + let updateAtDate = moment(deletedStudy.updatedAt); + let now = moment(); + let diff = now.diff(updateAtDate, "seconds"); + if (diff >= 30) { + let studyUID = deletedStudy.studyUID; + + logger.info("delete expired study: " + studyUID); + await Promise.all([ + dicomModel.deleteMany({ + studyUID + }), + dicomSeriesModel.deleteMany({ + studyUID + }), + deletedStudy.delete() + ]); + + await deletedStudy.deleteStudyFolder(); + } + } +} + +async function deleteExpireSeries() { + let deletedSeries = await dicomSeriesModel.find({ + deleteStatus: { + $gte: 2 + } + }); + + for (let aDeletedSeries of deletedSeries) { + let updateAtDate = moment(aDeletedSeries.updatedAt); + let now = moment(); + let diff = now.diff(updateAtDate, "seconds"); + if (diff >= 30) { + let {studyUID, seriesUID} = aDeletedSeries; + + logger.info("delete expired series: " + seriesUID); + await Promise.all([ + dicomModel.deleteMany({ + $and: [ + { x0020000D: studyUID }, + { x0020000E: seriesUID } + ] + }), + aDeletedSeries.delete() + ]); + + await aDeletedSeries.deleteSeriesFolder(); + } + } +} + +async function deleteExpireInstances() { + let deletedInstances = await dicomModel.find({ + deleteStatus: { + $gte: 2 + } + }); + + for (let deletedInstance of deletedInstances) { + let {instanceUID} = deletedInstance; + + let updateAtDate = moment(deletedInstance.updatedAt); + let now = moment(); + let diff = now.diff(updateAtDate, "days"); + if (diff >= 30) { + logger.info("delete expired instance: " + instanceUID); + await deletedInstance.deleteInstance(); + await deletedInstance.delete(); + } + } +} \ No newline at end of file diff --git a/models/mongodb/index.js b/models/mongodb/index.js index c40f404f..698dce6f 100644 --- a/models/mongodb/index.js +++ b/models/mongodb/index.js @@ -1,2 +1,3 @@ const myMongoDB = require("./connector")(); +require("./deleteSchedule"); module.exports = myMongoDB; diff --git a/models/mongodb/models/dicom.js b/models/mongodb/models/dicom.js index f34dae92..d5d74837 100644 --- a/models/mongodb/models/dicom.js +++ b/models/mongodb/models/dicom.js @@ -1,3 +1,5 @@ +const fsP = require("fs/promises"); +const path = require("path"); const _ = require("lodash"); const mongoose = require("mongoose"); const { @@ -24,6 +26,9 @@ let verifyingObserverSchema = new mongoose.Schema( } ); +/** + * @constructs dicomModelSchema + */ let dicomModelSchema = new mongoose.Schema( { "studyUID": { @@ -44,6 +49,10 @@ let dicomModelSchema = new mongoose.Schema( index: true, required: true }, + "deleteStatus": { + type: Number, + default: 0 + }, "00080020": new mongoose.Schema(dicomJsonAttributeDASchema, { _id: false, id: false, @@ -116,7 +125,29 @@ let dicomModelSchema = new mongoose.Schema( versionKey: false, toObject: { getters: true - } + }, + methods: { + async incrementDeleteStatus() { + this.deleteStatus = this.deleteStatus + 1; + await this.save(); + }, + async deleteInstance() { + let instancePath = this.instancePath; + try { + logger.warn("Permanently delete instance: " + instancePath); + + await fsP.unlink( + path.join( + raccoonConfig.dicomWebConfig.storeRootPath, + instancePath + ) + ); + } catch (e) { + console.error(e); + } + } + }, + timestamps: true } ); @@ -340,7 +371,12 @@ dicomModelSchema.statics.getDicomJson = async function (queryOptions) { let docs = await mongoose .model("dicom") - .find(queryOptions.query, { + .find({ + ...queryOptions.query, + deleteStatus: { + $eq: 0 + } + }, { ...instanceFields }) .setOptions({ @@ -398,6 +434,11 @@ dicomModelSchema.statics.getPathOfInstance = async function (iParam) { }, { instanceUID: instanceUID + }, + { + deleteStatus: { + $eq: 0 + } } ] }; @@ -430,7 +471,16 @@ dicomModelSchema.statics.getPathOfInstance = async function (iParam) { */ dicomModelSchema.statics.getInstanceOfMedianIndex = async function (query) { let instanceCountOfStudy = await mongoose.model("dicom").countDocuments({ - studyUID: query.studyUID + $and: [ + { + studyUID: query.studyUID + }, + { + deleteStatus: { + $eq: 0 + } + } + ] }); return await mongoose.model("dicom").findOne(query, { @@ -461,8 +511,6 @@ dicomModelSchema.statics.getInstanceOfMedianIndex = async function (query) { * */ - -/** @type {DicomModelSchema} */ let dicomModel = mongoose.model("dicom", dicomModelSchema, "dicom"); /** @type {DicomModelSchema} */ diff --git a/models/mongodb/models/dicomSeries.js b/models/mongodb/models/dicomSeries.js index d5dd56ee..a22d8175 100644 --- a/models/mongodb/models/dicomSeries.js +++ b/models/mongodb/models/dicomSeries.js @@ -1,3 +1,5 @@ +const fsP = require("fs/promises"); +const path = require("path"); const mongoose = require("mongoose"); const _ = require("lodash"); const { tagsNeedStore } = require("../../DICOM/dicom-tags-mapping"); @@ -5,6 +7,7 @@ const { getVRSchema } = require("../schema/dicomJsonAttribute"); const { getStoreDicomFullPathGroup, IncludeFieldsFactory } = require("../service"); const { dictionary } = require("@models/DICOM/dicom-tags-dic"); const { raccoonConfig } = require("@root/config-class"); +const { logger } = require("@root/utils/logs/log"); let dicomSeriesSchema = new mongoose.Schema( { @@ -23,6 +26,10 @@ let dicomSeriesSchema = new mongoose.Schema( seriesPath: { type: String, default: void 0 + }, + deleteStatus: { + type: Number, + default: 0 } }, { @@ -30,7 +37,22 @@ let dicomSeriesSchema = new mongoose.Schema( versionKey: false, toObject: { getters: true - } + }, + methods: { + async incrementDeleteStatus() { + this.deleteStatus = this.deleteStatus + 1; + await this.save(); + }, + async deleteSeriesFolder() { + let seriesPath = this.seriesPath; + logger.warn("Permanently delete series folder: " + seriesPath); + await fsP.rm(path.join(raccoonConfig.dicomWebConfig.storeRootPath, seriesPath), { + force: true, + recursive: true + }); + } + }, + timestamps: true } ); @@ -69,14 +91,19 @@ dicomSeriesSchema.index({ * @param {import("../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions} queryOptions * @returns */ -dicomSeriesSchema.statics.getDicomJson = async function(queryOptions) { +dicomSeriesSchema.statics.getDicomJson = async function (queryOptions) { let includeFieldsFactory = new IncludeFieldsFactory(queryOptions.includeFields); let seriesFields = includeFieldsFactory.getSeriesLevelFields(); try { let docs = await mongoose .model("dicomSeries") - .find(queryOptions.query, { + .find({ + ...queryOptions.query, + deleteStatus: { + $eq: 0 + } + }, { ...seriesFields }) .setOptions({ @@ -86,7 +113,7 @@ dicomSeriesSchema.statics.getDicomJson = async function(queryOptions) { .skip(queryOptions.skip) .exec(); - + let seriesDicomJson = docs.map((v) => { let obj = v.toObject(); delete obj._id; @@ -119,7 +146,7 @@ dicomSeriesSchema.statics.getDicomJson = async function(queryOptions) { * @param {string} iParam.studyUID * @param {string} iParam.seriesUID */ -dicomSeriesSchema.statics.getPathGroupOfInstances = async function(iParam) { +dicomSeriesSchema.statics.getPathGroupOfInstances = async function (iParam) { let { studyUID, seriesUID } = iParam; try { let query = [ diff --git a/models/mongodb/models/dicomStudy.js b/models/mongodb/models/dicomStudy.js index 96a8e034..e474e96b 100644 --- a/models/mongodb/models/dicomStudy.js +++ b/models/mongodb/models/dicomStudy.js @@ -1,9 +1,10 @@ +const fsP = require("fs/promises"); const path = require("path"); const mongoose = require("mongoose"); const _ = require("lodash"); const { tagsNeedStore } = require("../../DICOM/dicom-tags-mapping"); const { getVRSchema } = require("../schema/dicomJsonAttribute"); -const { +const { getStoreDicomFullPathGroup, IncludeFieldsFactory } = require("../service"); @@ -12,6 +13,7 @@ const { } = require("../../DICOM/dicom-tags-mapping"); const { raccoonConfig } = require("../../../config-class"); const { dictionary } = require("@models/DICOM/dicom-tags-dic"); +const { logger } = require("@root/utils/logs/log"); let dicomStudySchema = new mongoose.Schema( { @@ -24,6 +26,10 @@ let dicomStudySchema = new mongoose.Schema( studyPath: { type: String, default: void 0 + }, + deleteStatus: { + type: Number, + default: 0 } }, { @@ -31,7 +37,22 @@ let dicomStudySchema = new mongoose.Schema( versionKey: false, toObject: { getters: true - } + }, + methods: { + async incrementDeleteStatus() { + this.deleteStatus = this.deleteStatus + 1; + await this.save(); + }, + async deleteStudyFolder() { + let studyPath = this.studyPath; + logger.warn("Permanently delete study folder: " + studyPath); + await fsP.rm(path.join(raccoonConfig.dicomWebConfig.storeRootPath, studyPath), { + force: true, + recursive: true + }); + } + }, + timestamps: true } ); @@ -60,14 +81,19 @@ dicomStudySchema.statics.getDicomJson = async function (queryOptions) { let studyFields = includeFieldsFactory.getStudyLevelFields(); try { - let docs = await mongoose.model("dicomStudy").find(queryOptions.query, studyFields) - .limit(queryOptions.limit) - .skip(queryOptions.skip) - .setOptions({ - strictQuery: false - }) - .exec(); - + let docs = await mongoose.model("dicomStudy").find({ + ...queryOptions.query, + deleteStatus: { + $eq: 0 + } + }, studyFields) + .limit(queryOptions.limit) + .skip(queryOptions.skip) + .setOptions({ + strictQuery: false + }) + .exec(); + let studyDicomJson = docs.map((v) => { let obj = v.toObject(); delete obj._id; @@ -81,13 +107,13 @@ dicomStudySchema.statics.getDicomJson = async function (queryOptions) { ...dictionary.tagVR[dictionary.keyword.RetrieveAETitle], Value: [raccoonConfig.aeTitle] }); - + return obj; }); return studyDicomJson; - } catch(e) { + } catch (e) { throw e; } }; @@ -97,7 +123,7 @@ dicomStudySchema.statics.getDicomJson = async function (queryOptions) { * @param {Object} iParam * @param {string} iParam.studyUID */ -dicomStudySchema.statics.getPathGroupOfInstances = async function(iParam) { +dicomStudySchema.statics.getPathGroupOfInstances = async function (iParam) { let { studyUID } = iParam; try { let query = [ @@ -122,7 +148,7 @@ dicomStudySchema.statics.getPathGroupOfInstances = async function(iParam) { ]; let docs = await mongoose.model("dicom").aggregate(query).exec(); let pathGroup = _.get(docs, "0.pathList", []); - + let fullPathGroup = getStoreDicomFullPathGroup(pathGroup); return fullPathGroup; diff --git a/package-lock.json b/package-lock.json index 8a32d415..1eebb294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "moment": "^2.29.3", "moment-timezone": "^0.5.34", "mongoose": "^6.7.2", + "node-schedule": "^2.1.1", "passport": "^0.6.0", "passport-local": "^1.0.0", "path-match": "^1.2.4", @@ -2854,6 +2855,17 @@ "node": ">= 10" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -5162,6 +5174,11 @@ "node": ">=8.0" } }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" + }, "node_modules/loupe": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", @@ -5182,6 +5199,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz", + "integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6147,6 +6172,19 @@ } } }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -7504,6 +7542,11 @@ "npm": ">= 3.0.0" } }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10922,6 +10965,14 @@ "readable-stream": "^3.4.0" } }, + "cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "requires": { + "luxon": "^3.2.1" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -12685,6 +12736,11 @@ "streamroller": "^3.1.5" } }, + "long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" + }, "loupe": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", @@ -12702,6 +12758,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz", + "integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -13406,6 +13467,16 @@ "whatwg-url": "^5.0.0" } }, + "node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "requires": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -14431,6 +14502,11 @@ "smart-buffer": "^4.2.0" } }, + "sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 8bc89e09..8b3dea38 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "moment": "^2.29.3", "moment-timezone": "^0.5.34", "mongoose": "^6.7.2", + "node-schedule": "^2.1.1", "passport": "^0.6.0", "passport-local": "^1.0.0", "path-match": "^1.2.4",