From 2485405a96f7819ba3ea887554d0d94871d39027 Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Wed, 21 Feb 2024 13:01:03 -0600 Subject: [PATCH 01/10] Implement Automatic Reminder Emails (#23) --- app.js | 3 + config/mailer.js | 9 +- email/activityReminder.hbs | 13 ++ email/activityWarning.hbs | 12 ++ helpers/controllerActivityHelper.js | 193 +++++++++++++++++++++++ models/User.js | 12 +- package-lock.json | 234 +++++++++++++++++++++++++--- package.json | 5 +- 8 files changed, 455 insertions(+), 26 deletions(-) create mode 100644 email/activityReminder.hbs create mode 100644 email/activityWarning.hbs create mode 100644 helpers/controllerActivityHelper.js diff --git a/app.js b/app.js index 01ca164..e5c1dd8 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,7 @@ import env from 'dotenv'; import mongoose from 'mongoose'; import Redis from 'ioredis'; import aws from 'aws-sdk'; +import activityHelper from './helpers/controllerActivityHelper.js'; // Route Controllers import UserController from './controllers/UserController.js'; @@ -122,6 +123,8 @@ app.use('/training', TrainingController); app.use('/discord', DiscordController); app.use('/stats', StatsController); +activityHelper.registerControllerActivityChecking(); + if(process.env.NODE_ENV === 'production') app.use(Sentry.Handlers.errorHandler()); app.listen('3000', () =>{ diff --git a/config/mailer.js b/config/mailer.js index 41e226c..74c5aeb 100644 --- a/config/mailer.js +++ b/config/mailer.js @@ -1,6 +1,9 @@ import nodemailer from 'nodemailer'; import neh from 'nodemailer-express-handlebars'; import path from 'path'; +import dotenv from 'dotenv'; + +dotenv.config(); const __dirname = path.resolve(); @@ -14,11 +17,11 @@ const transport = nodemailer.createTransport({ }); transport.use('compile', neh({ - viewPath: __dirname+"/email", + viewPath: __dirname + "/email", viewEngine: { extName: ".hbs", - layoutsDir: __dirname+"/email", - partialsDir: __dirname+"/email", + layoutsDir: __dirname + "/email", + partialsDir: __dirname + "/email", defaultLayout: "main" }, extName: ".hbs" diff --git a/email/activityReminder.hbs b/email/activityReminder.hbs new file mode 100644 index 0000000..3901f49 --- /dev/null +++ b/email/activityReminder.hbs @@ -0,0 +1,13 @@ +
+
+ Controller Activity Reminder +
+
+ Hello {{name}},

+ + This is a reminder that you must control {{requiredHours}} hours every {{activityWindow}} days to remain active. + You have {{daysRemaining}} days to control the required hours.

+ + You have currently controlled {{currentHours}} hours. +
+
\ No newline at end of file diff --git a/email/activityWarning.hbs b/email/activityWarning.hbs new file mode 100644 index 0000000..6205594 --- /dev/null +++ b/email/activityWarning.hbs @@ -0,0 +1,12 @@ +
+
+ Controller Inactivity Notice +
+
+ Hello {{name}},

+ + This is a notification that you have not controlled the required {{requiredHours}} hours in the last {{activityWindow}} days to remain active. + You may be removed from the roster due to inactivity. + If you believe this email was sent in error, please email the ZAB DATM cc'd on this email. +
+
\ No newline at end of file diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js new file mode 100644 index 0000000..c38ddd6 --- /dev/null +++ b/helpers/controllerActivityHelper.js @@ -0,0 +1,193 @@ +import cron from 'node-cron'; +import transporter from '../config/mailer.js'; +import User from '../models/User.js'; +import ControllerHours from '../models/ControllerHours.js'; +import TrainingRequest from '../models/TrainingRequest.js'; +import { DateTime as luxon } from 'luxon' +import Redis from 'redis'; +import RedisLock from 'redis-lock'; +import env from 'dotenv'; + +env.config(); + +let redis = Redis.createClient({ url: process.env.REDIS_URI }); +let redisLock = RedisLock(redis); +await redis.connect(); + +const observerRatingCode = 1; +const activityWindowInDays = 60; +const gracePeriodInDays = 15; +const requiredHoursPerPeriod = 2; +const redisActivityCheckKey = "ACTIVITYCHECKRUNNING"; + +/** + * Registers a CRON job that sends controllers reminder emails. + */ +function registerControllerActivityChecking() { + try { + if (process.env.NODE_ENV === 'production') { + cron.schedule('0 0 * * *', async () => { + // Lock the activity check to avoid multiple app instances trying to simulatenously run the check. + const lockRunningActivityCheck = await redisLock(redisActivityCheckKey); + + await checkControllerActivity(); + await checkControllersNeedingRemoval(); + + lockRunningActivityCheck(); // Releases the lock. + }); + + console.log("Successfully registered activity CRON checks") + } + } + catch (e) { + console.log("Error registering activity CRON checks") + console.error(e) + } +} + +/** + * Checks controllers for activity and sends a reminder email. + */ +async function checkControllerActivity() { + const today = luxon.utc(); + const minActivityDate = today.minus({ days: activityWindowInDays - 1 }); + const controllerHoursSummary = {}; + const controllerTrainingSummary = {}; + + try { + const usersNeedingActivityCheck = await User.find( + { + member: true, + $or: [{ nextActivityCheckDate: { $lte: today } }, { nextActivityCheckDate: null }] + }); + + const userCidsNeedingActivityCheck = usersNeedingActivityCheck.map(u => u.cid); + + (await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: minActivityDate }, + cid: { $in: userCidsNeedingActivityCheck } + } + }, + { + $project: { + length: { + "$divide": [ + { $subtract: ['$timeEnd', '$timeStart'] }, + 60 * 1000 * 60 // Convert to hours. + ] + }, + cid: 1 + } + }, + { + $group: { + _id: "$cid", + total: { "$sum": "$length" } + } + } + ])).forEach(i => controllerHoursSummary[i._id] = i.total); + + (await TrainingRequest.aggregate([ + { $match: { startTime: { $gt: minActivityDate }, studentCid: { $in: userCidsNeedingActivityCheck } } }, + { + $group: { + _id: "$studentCid", + total: { $sum: 1 } + } + } + ])).forEach(i => controllerTrainingSummary[i._id] = i.total); + + usersNeedingActivityCheck.forEach(async user => { + const controllerHasLessThanTwoHours = (controllerHoursSummary[user.cid] ?? 0) < requiredHoursPerPeriod; + const controllerJoinedMoreThan60DaysAgo = (user.joinDate ?? user.createdAt) < minActivityDate; + const controllerIsNotObserverWithTrainingSession = user.rating != observerRatingCode || !controllerTrainingSummary[user.cid]; + const controllerInactive = controllerHasLessThanTwoHours && controllerJoinedMoreThan60DaysAgo && controllerIsNotObserverWithTrainingSession; + + // Set check dates before emailing to prevent duplicate checks if an exception occurs. + await User.updateOne( + { "cid": user.cid }, + { + nextActivityCheckDate: today.plus({ days: activityWindowInDays }) + } + ) + + if (controllerInactive) { + await User.updateOne( + { "cid": user.cid }, + { + removalWarningDeliveryDate: today.plus({ days: gracePeriodInDays }) + } + ) + + transporter.sendMail({ + to: user.Email, + from: { + name: "Albuquerque ARTCC", + address: 'noreply@zabartcc.org' + }, + subject: `Controller Activity Warning | Albuquerque ARTCC`, + template: 'activityReminder', + context: { + name: user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays, + daysRemaining: gracePeriodInDays, + currentHours: (controllerHoursSummary[user.cid]?.toFixed(2) ?? 0) + } + }); + } + }); + } + catch (e) { + console.error(e) + } +} + +/** + * Checks for controllers that did not maintain activity and sends a removal email. + */ +async function checkControllersNeedingRemoval() { + const today = luxon.utc(); + + try { + const usersNeedingRemovalWarning = await User.find( + { + member: true, + removalWarningDeliveryDate: { $lte: today } + }); + + usersNeedingRemovalWarning.forEach(async user => { + await User.updateOne( + { "cid": user.cid }, + { + removalWarningDeliveryDate: null + } + ) + + transporter.sendMail({ + to: user.Email, + cc: 'datm@zabartcc.org', + from: { + name: "Albuquerque ARTCC", + address: 'noreply@zabartcc.org' + }, + subject: `Controller Inactivity Notice | Albuquerque ARTCC`, + template: 'activityWarning', + context: { + name: user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays + } + }); + }); + } + catch (e) { + console.error(e); + } +} + +export default { + registerControllerActivityChecking: registerControllerActivityChecking +} diff --git a/models/User.js b/models/User.js index ab2ff83..72817f4 100644 --- a/models/User.js +++ b/models/User.js @@ -6,6 +6,7 @@ import './Certification.js'; import './Role.js'; import './Absence.js'; import './TrainingMilestone.js'; +import { DateTime as luxon } from 'luxon'; import zab from '../config/zab.js'; @@ -36,10 +37,19 @@ const userSchema = new m.Schema({ roleCodes: [], trainingMilestones: [{ type: m.Schema.Types.ObjectId, ref: 'TrainingMilestone' - }] + }], + nextActivityCheckDate: { + type: Date, + default: luxon.utc() + }, + removalWarningDeliveryDate: { + type: Date, + default: null + }, }, { timestamps: true, }); + userSchema.plugin(softDelete, { deletedAt: true }); diff --git a/package-lock.json b/package-lock.json index 7a5825c..cb8e899 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,11 @@ "mongoose-select-virtuals": "^3.0.3", "multer": "^1.4.4", "multer-s3": "^2.10.0", + "node-cron": "^3.0.3", "nodemailer": "^6.7.5", - "nodemailer-express-handlebars": "^5.0.0" + "nodemailer-express-handlebars": "^5.0.0", + "redis": "^4.6.13", + "redis-lock": "^1.0.0" }, "devDependencies": { "eslint": "^8.18.0" @@ -80,6 +83,59 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.1.1.tgz", "integrity": "sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==" }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.14.tgz", + "integrity": "sha512-YGn0GqsRBFUQxklhY7v562VMOP0DcmlrHHs3IV1mFE3cbxe31IITUkqhBcIhVSI/2JqtWAJXg5mjV4aU+zD0HA==", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@sentry/core": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", @@ -548,9 +604,9 @@ } }, "node_modules/cluster-key-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", - "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", "engines": { "node": ">=0.10.0" } @@ -761,9 +817,9 @@ } }, "node_modules/denque": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz", - "integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "engines": { "node": ">=0.10" } @@ -1356,6 +1412,14 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, "node_modules/get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -1618,14 +1682,14 @@ } }, "node_modules/ioredis": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.1.0.tgz", - "integrity": "sha512-HYHnvwxFwefeUBj0hZFejLvd8Q/YNAfnZlZG/hSRxkRhXMs1H8soMEVccHd1WlLrKkynorXBsAtqDGskOdAfVQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", - "denque": "^2.0.1", + "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", @@ -2231,6 +2295,25 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/nodemailer": { "version": "6.7.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz", @@ -2501,6 +2584,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/redis": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.13.tgz", + "integrity": "sha512-MHgkS4B+sPjCXpf+HfdetBwbRz6vCtsceTmw1pHNYJAsYxrfpOP6dz+piJWGos8wqG7qb3vj/Rrc5qOlmInUuA==", + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.14", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -2509,6 +2605,14 @@ "node": ">=4" } }, + "node_modules/redis-lock": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redis-lock/-/redis-lock-1.0.0.tgz", + "integrity": "sha512-zfI+Il36jXwRT/W8SBsG132Bc2yp3tMuf3KTGjSzXimadI17NEGBvb/KrDkCuAC2hzVxW5uR5ns/rxuqiWeV3Q==", + "engines": { + "node": ">=12" + } + }, "node_modules/redis-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", @@ -3178,6 +3282,11 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } }, "dependencies": { @@ -3220,6 +3329,46 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.1.1.tgz", "integrity": "sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==" }, + "@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "requires": {} + }, + "@redis/client": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.14.tgz", + "integrity": "sha512-YGn0GqsRBFUQxklhY7v562VMOP0DcmlrHHs3IV1mFE3cbxe31IITUkqhBcIhVSI/2JqtWAJXg5mjV4aU+zD0HA==", + "requires": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + } + }, + "@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "requires": {} + }, + "@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "requires": {} + }, + "@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "requires": {} + }, + "@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "requires": {} + }, "@sentry/core": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", @@ -3584,9 +3733,9 @@ } }, "cluster-key-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", - "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" }, "color-convert": { "version": "2.0.1", @@ -3743,9 +3892,9 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "denque": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz", - "integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" }, "depd": { "version": "2.0.0", @@ -4197,6 +4346,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, "get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -4382,14 +4536,14 @@ } }, "ioredis": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.1.0.tgz", - "integrity": "sha512-HYHnvwxFwefeUBj0hZFejLvd8Q/YNAfnZlZG/hSRxkRhXMs1H8soMEVccHd1WlLrKkynorXBsAtqDGskOdAfVQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", "requires": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", - "denque": "^2.0.1", + "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", @@ -4846,6 +5000,21 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "requires": { + "uuid": "8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "nodemailer": { "version": "6.7.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz", @@ -5027,11 +5196,29 @@ "readable-stream": "^3.6.0" } }, + "redis": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.13.tgz", + "integrity": "sha512-MHgkS4B+sPjCXpf+HfdetBwbRz6vCtsceTmw1pHNYJAsYxrfpOP6dz+piJWGos8wqG7qb3vj/Rrc5qOlmInUuA==", + "requires": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.14", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } + }, "redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" }, + "redis-lock": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redis-lock/-/redis-lock-1.0.0.tgz", + "integrity": "sha512-zfI+Il36jXwRT/W8SBsG132Bc2yp3tMuf3KTGjSzXimadI17NEGBvb/KrDkCuAC2hzVxW5uR5ns/rxuqiWeV3Q==" + }, "redis-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", @@ -5514,6 +5701,11 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } } diff --git a/package.json b/package.json index b0332a6..c89cdc9 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,11 @@ "mongoose-select-virtuals": "^3.0.3", "multer": "^1.4.4", "multer-s3": "^2.10.0", + "node-cron": "^3.0.3", "nodemailer": "^6.7.5", - "nodemailer-express-handlebars": "^5.0.0" + "nodemailer-express-handlebars": "^5.0.0", + "redis": "^4.6.13", + "redis-lock": "^1.0.0" }, "devDependencies": { "eslint": "^8.18.0" From e2ed52ea1ec5f02e167f3ed9232e7a9ea0713cc2 Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Thu, 22 Feb 2024 16:10:13 -0600 Subject: [PATCH 02/10] [#22] Add Missing Activity Check Before Inactive Notice Email (#27) --- helpers/controllerActivityHelper.js | 242 ++++++++++++++++++---------- 1 file changed, 154 insertions(+), 88 deletions(-) diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js index 983ef72..daee652 100644 --- a/helpers/controllerActivityHelper.js +++ b/helpers/controllerActivityHelper.js @@ -26,7 +26,7 @@ const redisActivityCheckKey = "ACTIVITYCHECKRUNNING"; function registerControllerActivityChecking() { try { if (process.env.NODE_ENV === 'prod') { - cron.schedule('10 1 * * *', async () => { + cron.schedule('0 0 * * *', async () => { // Lock the activity check to avoid multiple app instances trying to simulatenously run the check. const lockRunningActivityCheck = await redisLock(redisActivityCheckKey); @@ -51,8 +51,6 @@ function registerControllerActivityChecking() { async function checkControllerActivity() { const today = luxon.utc(); const minActivityDate = today.minus({ days: activityWindowInDays - 1 }); - const controllerHoursSummary = {}; - const controllerTrainingSummary = {}; try { const usersNeedingActivityCheck = await User.find( @@ -61,83 +59,39 @@ async function checkControllerActivity() { $or: [{ nextActivityCheckDate: { $lte: today } }, { nextActivityCheckDate: null }] }); - const userCidsNeedingActivityCheck = usersNeedingActivityCheck.map(u => u.cid); - - (await ControllerHours.aggregate([ - { - $match: { - timeStart: { $gt: minActivityDate }, - cid: { $in: userCidsNeedingActivityCheck } - } - }, - { - $project: { - length: { - "$divide": [ - { $subtract: ['$timeEnd', '$timeStart'] }, - 60 * 1000 * 60 // Convert to hours. - ] - }, - cid: 1 - } - }, - { - $group: { - _id: "$cid", - total: { "$sum": "$length" } - } - } - ])).forEach(i => controllerHoursSummary[i._id] = i.total); - - (await TrainingRequest.aggregate([ - { $match: { startTime: { $gt: minActivityDate }, studentCid: { $in: userCidsNeedingActivityCheck } } }, + await User.updateMany( + { "cid": { $in: usersNeedingActivityCheck.map(u => u.cid) } }, { - $group: { - _id: "$studentCid", - total: { $sum: 1 } - } + nextActivityCheckDate: today.plus({ days: activityWindowInDays }) } - ])).forEach(i => controllerTrainingSummary[i._id] = i.total); + ) - usersNeedingActivityCheck.forEach(async user => { - const controllerHasLessThanTwoHours = (controllerHoursSummary[user.cid] ?? 0) < requiredHoursPerPeriod; - const controllerJoinedMoreThan60DaysAgo = (user.joinDate ?? user.createdAt) < minActivityDate; - const controllerIsNotObserverWithTrainingSession = user.rating != observerRatingCode || !controllerTrainingSummary[user.cid]; - const controllerInactive = controllerHasLessThanTwoHours && controllerJoinedMoreThan60DaysAgo && controllerIsNotObserverWithTrainingSession; + const inactiveUserData = await getControllerInactivityData(usersNeedingActivityCheck, minActivityDate); - // Set check dates before emailing to prevent duplicate checks if an exception occurs. + inactiveUserData.forEach(async record => { await User.updateOne( - { "cid": user.cid }, + { "cid": record.user.cid }, { - nextActivityCheckDate: today.plus({ days: activityWindowInDays }) + removalWarningDeliveryDate: today.plus({ days: gracePeriodInDays }) } ) - if (controllerInactive) { - await User.updateOne( - { "cid": user.cid }, - { - removalWarningDeliveryDate: today.plus({ days: gracePeriodInDays }) - } - ) - - transporter.sendMail({ - to: user.email, - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Controller Activity Warning | Albuquerque ARTCC`, - template: 'activityReminder', - context: { - name: user.fname, - requiredHours: requiredHoursPerPeriod, - activityWindow: activityWindowInDays, - daysRemaining: gracePeriodInDays, - currentHours: (controllerHoursSummary[user.cid]?.toFixed(2) ?? 0) - } - }); - } + transporter.sendMail({ + to: record.user.email, + from: { + name: "Albuquerque ARTCC", + address: 'noreply@zabartcc.org' + }, + subject: `Controller Activity Warning | Albuquerque ARTCC`, + template: 'activityReminder', + context: { + name: record.user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays, + daysRemaining: gracePeriodInDays, + currentHours: record.hours.toFixed(2) + } + }); }); } catch (e) { @@ -145,6 +99,7 @@ async function checkControllerActivity() { } } + /** * Checks for controllers that did not maintain activity and sends a removal email. */ @@ -152,13 +107,41 @@ async function checkControllersNeedingRemoval() { const today = luxon.utc(); try { - const usersNeedingRemovalWarning = await User.find( + const usersNeedingRemovalWarningCheck = await User.find( { member: true, removalWarningDeliveryDate: { $lte: today } }); - usersNeedingRemovalWarning.forEach(async user => { + usersNeedingRemovalWarningCheck.forEach(async user => { + const minActivityDate = luxon.fromJSDate(user.removalWarningDeliveryDate).minus({ days: activityWindowInDays - 1 }); + const userHourSums = await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: minActivityDate }, + cid: user.cid + } + }, + { + $project: { + length: { + "$divide": [ + { $subtract: ['$timeEnd', '$timeStart'] }, + 60 * 1000 * 60 // Convert to hours. + ] + } + } + }, + { + $group: { + _id: "$cid", + total: { "$sum": "$length" } + } + } + ]); + const userTotalHoursInPeriod = (userHourSums && userHourSums.length > 0) ? userHourSums[0].total : 0; + const userTrainingRequestCount = await TrainingRequest.count({ studentCid: user.cid, startTime: { $gt: minActivityDate } }); + await User.updateOne( { "cid": user.cid }, { @@ -166,21 +149,23 @@ async function checkControllersNeedingRemoval() { } ) - transporter.sendMail({ - to: user.email, - cc: 'datm@zabartcc.org', - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Controller Inactivity Notice | Albuquerque ARTCC`, - template: 'activityWarning', - context: { - name: user.fname, - requiredHours: requiredHoursPerPeriod, - activityWindow: activityWindowInDays - } - }); + if (controllerIsInactive(user, userTotalHoursInPeriod, userTrainingRequestCount, minActivityDate)) { + transporter.sendMail({ + to: user.email, + cc: 'datm@zabartcc.org', + from: { + name: "Albuquerque ARTCC", + address: 'noreply@zabartcc.org' + }, + subject: `Controller Inactivity Notice | Albuquerque ARTCC`, + template: 'activityWarning', + context: { + name: user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays + } + }); + } }); } catch (e) { @@ -188,6 +173,87 @@ async function checkControllersNeedingRemoval() { } } +/** + * Determines which controllers are inactive from a list and returns inactivity data of those controllers. + * @param controllersToGetStatusFor A list of users to check the activity of. + * @param minActivityDate The start date of the activity period. + * @return A map of inactive controllers with the amount of hours they've controlled in the current period. + */ +async function getControllerInactivityData(controllersToGetStatusFor, minActivityDate) { + const controllerHoursSummary = {}; + const controllerTrainingSummary = {}; + const inactiveControllers = []; + const controllerCids = controllersToGetStatusFor.map(c => c.cid); + + (await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: minActivityDate }, + cid: { $in: controllerCids } + } + }, + { + $project: { + length: { + "$divide": [ + { $subtract: ['$timeEnd', '$timeStart'] }, + 60 * 1000 * 60 // Convert to hours. + ] + }, + cid: 1 + } + }, + { + $group: { + _id: "$cid", + total: { "$sum": "$length" } + } + } + ])).forEach(i => controllerHoursSummary[i._id] = i.total); + + (await TrainingRequest.aggregate([ + { $match: { startTime: { $gt: minActivityDate }, studentCid: { $in: controllerCids } } }, + { + $group: { + _id: "$studentCid", + total: { $sum: 1 } + } + } + ])).forEach(i => controllerTrainingSummary[i._id] = i.total); + + controllersToGetStatusFor.forEach(async user => { + let controllerHoursCount = controllerHoursSummary[user.cid] ?? 0; + let controllerTrainingSessions = controllerTrainingSummary[user.cid] != null ? controllerTrainingSummary[user.cid].length : 0 + + if (controllerIsInactive(user, controllerHoursCount, controllerTrainingSessions, minActivityDate)) { + const inactiveControllerData = { + user: user, + hours: controllerHoursCount + }; + + inactiveControllers.push(inactiveControllerData); + } + }); + + return inactiveControllers; +} + +/** + * Determines if a controller meets activity requirements based on the information provided. + * @param user The user to check for inactivity. + * @param hoursInPeriod The hours the controller controlled in this activity window. + * @param trainingSessionInPeriod The number of training sessions the user scheduled in this period. + * @param minActivityDate The start date of the activity period. + * @return True if controller is inactive, false otherwise. + */ +function controllerIsInactive(user, hoursInPeriod, trainingSessionInPeriod, minActivityDate) { + const controllerHasLessThanTwoHours = (hoursInPeriod ?? 0) < requiredHoursPerPeriod; + const controllerJoinedMoreThan60DaysAgo = (user.joinDate ?? user.createdAt) < minActivityDate; + const controllerIsNotObserverWithTrainingSession = user.rating != observerRatingCode || trainingSessionInPeriod < 1; + + return controllerHasLessThanTwoHours && controllerJoinedMoreThan60DaysAgo && controllerIsNotObserverWithTrainingSession; +} + export default { registerControllerActivityChecking: registerControllerActivityChecking } From c24131f0f6de631413c9aa63977ad8f0fb56b225 Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:58:58 -0600 Subject: [PATCH 03/10] Change Controller Profile Certifications/Endorsements (#29) --- controllers/EventController.js | 8 ++-- models/TrainingMilestone.js | 6 ++- seeds/certifications.json | 82 ++++++++++++++++++---------------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/controllers/EventController.js b/controllers/EventController.js index 8c37662..f593f1f 100644 --- a/controllers/EventController.js +++ b/controllers/EventController.js @@ -316,28 +316,28 @@ router.put('/:slug', getUser, auth(['atm', 'datm', 'ec']), upload.single('banner computedPositions.push({ pos, type: thePos[2], - code: 'zab', + code: 'enroute', }) } if(['APP', 'DEP'].includes(thePos[2])) { computedPositions.push({ pos, type: thePos[2], - code: (thePos[1] === "PHX") ? 'p50app' : 'app', + code: (thePos[1] === "PHX") ? 'p50' : 'app', }) } if(['TWR'].includes(thePos[2])) { computedPositions.push({ pos, type: thePos[2], - code: (thePos[1] === "PHX") ? 'p50twr' : 'twr', + code: (thePos[1] === "PHX") ? 'kphxtower' : 'twr', }) } if(['GND', 'DEL'].includes(thePos[2])) { computedPositions.push({ pos, type: thePos[2], - code: (thePos[1] === "PHX") ? 'p50gnd' : 'gnd', + code: (thePos[1] === "PHX") ? 'kphxground' : 'gnd', }) } } diff --git a/models/TrainingMilestone.js b/models/TrainingMilestone.js index 4611414..dce8e29 100644 --- a/models/TrainingMilestone.js +++ b/models/TrainingMilestone.js @@ -3,8 +3,10 @@ import m from 'mongoose'; const trainingMilestoneSchema = new m.Schema({ code: String, name: String, - rating: Number, - certCode: String + rating: Number, // Legacy field, replaced by "availableAtRatings". + certCode: String, // Legacy field, replaced by "hiddenWhenControllerHasTierOne". + availableAtRatings: Array, + hiddenWhenControllerHasTierOne: Boolean }); export default m.model('TrainingMilestone', trainingMilestoneSchema, 'trainingMilestones'); \ No newline at end of file diff --git a/seeds/certifications.json b/seeds/certifications.json index d22c863..82e2fec 100644 --- a/seeds/certifications.json +++ b/seeds/certifications.json @@ -1,43 +1,49 @@ -[{ - "name": "Minor Ground", - "code": "gnd", +[ +{ + "name": "KPHX GND", + "code": "kphxground", + "order": 4, + "class": "tier-one" +}, +{ + "name": "KPHX TWR", + "code": "kphxtower", + "order": 3, + "class": "tier-one" +}, +{ + "name": "P50", + "code": "p50", + "order": 2, + "class": "tier-one" +}, +{ + "name": "Enroute", + "code": "enroute", + "order": 1, + "class": "tier-one" +}, +{ + "name": "KABQ", + "code": "kabq", "order": 1, - "class": "minor", - "facility": "gnd" -},{ - "name": "Major Ground", - "code": "p50gnd", + "class": "tier-two" +}, +{ + "name": "KFLG", + "code": "kflg", "order": 2, - "class": "major", - "facility": "gnd" -},{ - "name": "Minor Tower", - "code": "twr", + "class": "tier-two" +}, +{ + "name": "KLUF", + "code": "kluf", "order": 3, - "class": "minor", - "facility": "twr" -},{ - "name": "Major Tower", - "code": "p50twr", + "class": "tier-two" +}, +{ + "name": "KSAF", + "code": "ksaf", "order": 4, - "class": "major", - "facility": "twr" -},{ - "name": "Minor Approach", - "code": "app", - "order": 5, - "class": "minor", - "facility": "app" -},{ - "name": "Major Approach", - "code": "p50app", - "order": 6, - "class": "major", - "facility": "app" -},{ - "name": "Albuquerque Center", - "code": "zab", - "order": 7, - "class": "center", - "facility": "ctr" + "class": "tier-two" }] \ No newline at end of file From 16b512f0d862dd5e7006b41827c0cca272ba9c5a Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:10:58 -0600 Subject: [PATCH 04/10] Allow EC Read Feedback Access (#30) --- controllers/FeedbackController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controllers/FeedbackController.js b/controllers/FeedbackController.js index bbdc16a..2eba6ed 100644 --- a/controllers/FeedbackController.js +++ b/controllers/FeedbackController.js @@ -6,7 +6,7 @@ import Notification from '../models/Notification.js'; import getUser from '../middleware/getUser.js'; import auth from '../middleware/auth.js'; -router.get('/', getUser, auth(['atm', 'datm', 'ta']), async (req, res) => { // All feedback +router.get('/', getUser, auth(['atm', 'datm', 'ta', 'ec']), async (req, res) => { // All feedback try { const page = +req.query.page || 1; const limit = +req.query.limit || 20; @@ -85,7 +85,7 @@ router.get('/controllers', async ({res}) => { // Controller list on feedback pag return res.json(res.stdRes); }); -router.get('/unapproved', getUser, auth(['atm', 'datm', 'ta']), async ({res}) => { // Get all unapproved feedback +router.get('/unapproved', getUser, auth(['atm', 'datm', 'ta', 'ec']), async ({res}) => { // Get all unapproved feedback try { const feedback = await Feedback.find({deletedAt: null, approved: false}).populate('controller', 'fname lname cid').sort({createdAt: 'desc'}).lean(); res.stdRes.data = feedback; From 6686ad74b008198e33acdc0f6033e5e56ec4673d Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Wed, 6 Mar 2024 09:24:11 -0600 Subject: [PATCH 05/10] Issue Reporting #16 - Change to 90 day / 3 hour activity (#32) --- controllers/StatsController.js | 4 ++-- helpers/controllerActivityHelper.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/controllers/StatsController.js b/controllers/StatsController.js index 2cfe263..40cd7d3 100644 --- a/controllers/StatsController.js +++ b/controllers/StatsController.js @@ -173,7 +173,7 @@ router.get('/ins', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (re router.get('/activity', getUser, auth(['atm', 'datm', 'ta', 'wm']), async (req, res) => { try { const today = L.utc(); - const chkDate = today.minus({days: 61}); + const chkDate = today.minus({days: 91}); const users = await User.find({member: true}).select('fname lname cid rating oi vis createdAt roleCodes certCodes joinDate').sort({lname: 1}).populate('certifications').lean({virtuals: true}); const activityReduced = {}; const trainingReduced = {}; @@ -205,7 +205,7 @@ router.get('/activity', getUser, auth(['atm', 'datm', 'ta', 'wm']), async (req, ...user, totalTime, totalRequests, - tooLow: totalTime < 7200 && (user.joinDate ?? user.createdAt) < chkDate && !totalRequests, + tooLow: totalTime < 10800 && (user.joinDate ?? user.createdAt) < chkDate && !totalRequests, protected: user.isStaff || [1167179].includes(user.cid) } } diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js index daee652..bb4508c 100644 --- a/helpers/controllerActivityHelper.js +++ b/helpers/controllerActivityHelper.js @@ -15,7 +15,7 @@ let redisLock = RedisLock(redis); await redis.connect(); const observerRatingCode = 1; -const activityWindowInDays = 60; +const activityWindowInDays = 90; const gracePeriodInDays = 15; const requiredHoursPerPeriod = 2; const redisActivityCheckKey = "ACTIVITYCHECKRUNNING"; From b4b83babfa74d52d9820232343766bcbf9965651 Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:35:45 -0500 Subject: [PATCH 06/10] Allow manual run. (#36) --- .github/workflows/development.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 6a871fb..31b5dec 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -1,6 +1,7 @@ name: Build and Deploy to Development on: + workflow_dispatch: push: branches: [development] From 959d6d2177ffde13e945a938850d70aec1f22d2a Mon Sep 17 00:00:00 2001 From: Cole Connelly <30357632+cdconn00@users.noreply.github.com> Date: Thu, 19 Sep 2024 08:21:19 -0500 Subject: [PATCH 07/10] Disable Activity Emails (#37) --- app.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index e5c1dd8..4f9fb63 100644 --- a/app.js +++ b/app.js @@ -123,7 +123,9 @@ app.use('/training', TrainingController); app.use('/discord', DiscordController); app.use('/stats', StatsController); -activityHelper.registerControllerActivityChecking(); +// Uncomment to activate activity emails for controllers. Reset DB activity date fields before activating. +// Disabled per the ATM 9/19/24. +// activityHelper.registerControllerActivityChecking(); if(process.env.NODE_ENV === 'production') app.use(Sentry.Handlers.errorHandler()); From 00aa7f7a3c030229ccc800fb1e2b14c065a2c73a Mon Sep 17 00:00:00 2001 From: XDerpingxGruntX <41699998+XDerpingxGruntX@users.noreply.github.com> Date: Fri, 11 Oct 2024 09:14:37 -0600 Subject: [PATCH 08/10] Activity and staff email update (Should have been here. not on master lol) --- controllers/ControllerController.js | 1808 ++++++++++++++------------- helpers/controllerActivityHelper.js | 423 ++++--- 2 files changed, 1196 insertions(+), 1035 deletions(-) diff --git a/controllers/ControllerController.js b/controllers/ControllerController.js index 5a2cbe0..ddcc669 100644 --- a/controllers/ControllerController.js +++ b/controllers/ControllerController.js @@ -1,843 +1,953 @@ -import e from 'express'; +import e from "express"; const router = e.Router(); -import User from '../models/User.js'; -import ControllerHours from '../models/ControllerHours.js'; -import Role from '../models/Role.js'; -import VisitApplication from '../models/VisitApplication.js'; -import Absence from '../models/Absence.js'; -import Notification from '../models/Notification.js'; -import transporter from '../config/mailer.js'; -import getUser from '../middleware/getUser.js'; -import auth from '../middleware/auth.js'; -import microAuth from '../middleware/microAuth.js'; -import axios from 'axios'; -import dotenv from 'dotenv'; -import { DateTime as L } from 'luxon' +import User from "../models/User.js"; +import ControllerHours from "../models/ControllerHours.js"; +import Role from "../models/Role.js"; +import VisitApplication from "../models/VisitApplication.js"; +import Absence from "../models/Absence.js"; +import Notification from "../models/Notification.js"; +import transporter from "../config/mailer.js"; +import getUser from "../middleware/getUser.js"; +import auth from "../middleware/auth.js"; +import microAuth from "../middleware/microAuth.js"; +import axios from "axios"; +import dotenv from "dotenv"; +import { DateTime as L } from "luxon"; dotenv.config(); -router.get('/', async ({res}) => { - try { - const home = await User.find({vis: false, cid: { "$nin": [995625] }}).select('-email -idsToken -discordInfo').sort({ - rating: 'desc', - lname: 'asc', - fname: 'asc' - }).populate({ - path: 'certifications', - options: { - sort: {order: 'desc'} - } - }).populate({ - path: 'roles', - options: { - sort: {order: 'asc'} - } - }).populate({ - path: 'absence', - match: { - expirationDate: { - $gte: new Date() - }, - deleted: false - }, - select: '-reason' - }).lean({virtuals: true}); - - const visiting = await User.find({vis: true}).select('-email -idsToken -discordInfo').sort({ - rating: 'desc', - lname: 'asc', - fname: 'asc' - }).populate({ - path: 'certifications', - options: { - sort: {order: 'desc'} - } - }).populate({ - path: 'roles', - options: { - sort: {order: 'asc'} - } - }).populate({ - path: 'absence', - match: { - expirationDate: { - $gte: new Date() - }, - deleted: false - }, - select: '-reason' - }).lean({virtuals: true}); - - if(!home || !visiting) { - throw { - code: 503, - message: "Unable to retrieve controllers" - }; - } - - res.stdRes.data = {home, visiting}; - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/", async ({ res }) => { + try { + const home = await User.find({ vis: false, cid: { $nin: [995625] } }) + .select("-email -idsToken -discordInfo") + .sort({ + rating: "desc", + lname: "asc", + fname: "asc", + }) + .populate({ + path: "certifications", + options: { + sort: { order: "desc" }, + }, + }) + .populate({ + path: "roles", + options: { + sort: { order: "asc" }, + }, + }) + .populate({ + path: "absence", + match: { + expirationDate: { + $gte: new Date(), + }, + deleted: false, + }, + select: "-reason", + }) + .lean({ virtuals: true }); + + const visiting = await User.find({ vis: true }) + .select("-email -idsToken -discordInfo") + .sort({ + rating: "desc", + lname: "asc", + fname: "asc", + }) + .populate({ + path: "certifications", + options: { + sort: { order: "desc" }, + }, + }) + .populate({ + path: "roles", + options: { + sort: { order: "asc" }, + }, + }) + .populate({ + path: "absence", + match: { + expirationDate: { + $gte: new Date(), + }, + deleted: false, + }, + select: "-reason", + }) + .lean({ virtuals: true }); + + if (!home || !visiting) { + throw { + code: 503, + message: "Unable to retrieve controllers", + }; + } + + res.stdRes.data = { home, visiting }; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/staff', async (req, res) => { - try { - const users = await User.find().select('fname lname cid roleCodes').sort({ - lname: 'asc', - fname: 'asc' - })/*.populate({ +router.get("/staff", async (req, res) => { + try { + const users = await User.find() + .select("fname lname cid roleCodes") + .sort({ + lname: "asc", + fname: "asc", + }) /*.populate({ path: 'roles', options: { sort: {order: 'asc'} } - })*/.lean(); - - if(!users) { - throw { - code: 503, - message: "Unable to retrieve staff members" - }; - } - - const staff = { - atm: { - title: "Air Traffic Manager", - code: "atm", - users: [] - }, - datm: { - title: "Deputy Air Traffic Manager", - code: "datm", - users: [] - }, - ta: { - title: "Training Administrator", - code: "ta", - users: [] - }, - ec: { - title: "Events Team", - code: "ec", - users: [] - }, - wm: { - title: "Web Team", - code: "wm", - users: [] - }, - fe: { - title: "Facility Engineer", - code: "fe", - users: [] - }, - ins: { - title: "Instructors", - code: "instructors", - users: [] - }, - mtr: { - title: "Mentors", - code: "instructors", - users: [] - }, - dta: { - title: "Deputy Training Administrator", - code: "dta", - users: [] - }, - }; - - users.forEach(user => user.roleCodes.forEach(role => staff[role].users.push(user))); - - res.stdRes.data = staff; - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); + })*/ + .lean(); + + if (!users) { + throw { + code: 503, + message: "Unable to retrieve staff members", + }; + } + + const staff = { + atm: { + title: "Air Traffic Manager", + code: "atm", + email: "zab-atm", + users: [], + }, + datm: { + title: "Deputy Air Traffic Manager", + code: "datm", + email: "zab-datm", + users: [], + }, + ta: { + title: "Training Administrator", + code: "ta", + email: "zab-ta", + users: [], + }, + ec: { + title: "Events Team", + code: "ec", + users: [], + }, + wm: { + title: "Web Team", + code: "wm", + email: "john.morgan", + users: [], + }, + fe: { + title: "Facility Engineer", + code: "fe", + email: "edward.sterling", + users: [], + }, + ins: { + title: "Instructors", + code: "instructors", + users: [], + }, + mtr: { + title: "Mentors", + code: "instructors", + users: [], + }, + dta: { + title: "Deputy Training Administrator", + code: "dta", + email: "zab-dta", + users: [], + }, + }; + + users.forEach((user) => + user.roleCodes.forEach((role) => staff[role].users.push(user)) + ); + + res.stdRes.data = staff; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/role', async (req, res) => { - try { - const roles = await Role.find().lean(); - res.stdRes.data = roles; - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); -}); +router.get("/role", async (req, res) => { + try { + const roles = await Role.find().lean(); + res.stdRes.data = roles; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } -router.get('/oi', async (req, res) => { - try { - const oi = await User.find({deletedAt: null, member: true}).select('oi').lean(); - - if(!oi) { - throw { - code: 503, - message: "Unable to retrieve operating initials" - }; - } - - res.stdRes.data = oi.map(oi => oi.oi); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); + return res.json(res.stdRes); }); -router.get('/visit', getUser, auth(['atm', 'datm']), async ({res}) => { - try { - const applications = await VisitApplication.find({deletedAt: null, acceptedAt: null}).lean(); - res.stdRes.data = applications; - } catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/oi", async (req, res) => { + try { + const oi = await User.find({ deletedAt: null, member: true }) + .select("oi") + .lean(); + + if (!oi) { + throw { + code: 503, + message: "Unable to retrieve operating initials", + }; + } + + res.stdRes.data = oi.map((oi) => oi.oi); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/absence', getUser, auth(['atm', 'datm']), async(req, res) => { - try { - const absences = await Absence.find({ - expirationDate: { - $gte: new Date() - }, - deleted: false - }).populate( - 'user', 'fname lname cid' - ).sort({ - expirationDate: 'asc' - }).lean(); - - res.stdRes.data = absences; - } catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/visit", getUser, auth(["atm", "datm"]), async ({ res }) => { + try { + const applications = await VisitApplication.find({ + deletedAt: null, + acceptedAt: null, + }).lean(); + res.stdRes.data = applications; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.post('/absence', getUser, auth(['atm', 'datm']), async(req, res) => { - try { - if(!req.body || req.body.controller === '' || req.body.expirationDate === 'T00:00:00.000Z' || req.body.reason === '') { - throw { - code: 400, - message: "You must fill out all required fields" - } - } - - if(new Date(req.body.expirationDate) < new Date()) { - throw { - code: 400, - message: "Expiration date must be in the future" - } - } - - await Absence.create(req.body); - - await Notification.create({ - recipient: req.body.controller, - title: 'Leave of Absence granted', - read: false, - content: `You have been granted Leave of Absence until ${new Date(req.body.expirationDate).toLocaleString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - timeZone: 'UTC', - })}.` - }); - - await req.app.dossier.create({ - by: res.user.cid, - affected: req.body.controller, - action: `%b added a leave of absence for %a: ${req.body.reason}` - }); - - } catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { + try { + const absences = await Absence.find({ + expirationDate: { + $gte: new Date(), + }, + deleted: false, + }) + .populate("user", "fname lname cid") + .sort({ + expirationDate: "asc", + }) + .lean(); + + res.stdRes.data = absences; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.delete('/absence/:id', getUser, auth(['atm', 'datm']), async(req, res) => { - try { - if(!req.params.id) { - throw { - code: 401, - message: "Invalid request" - } - } - - const absence = await Absence.findOne({_id: req.params.id}); - await absence.delete(); - - await req.app.dossier.create({ - by: res.user.cid, - affected: absence.controller, - action: `%b deleted the leave of absence for %a.` - }); - } catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.post("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { + try { + if ( + !req.body || + req.body.controller === "" || + req.body.expirationDate === "T00:00:00.000Z" || + req.body.reason === "" + ) { + throw { + code: 400, + message: "You must fill out all required fields", + }; + } + + if (new Date(req.body.expirationDate) < new Date()) { + throw { + code: 400, + message: "Expiration date must be in the future", + }; + } + + await Absence.create(req.body); + + await Notification.create({ + recipient: req.body.controller, + title: "Leave of Absence granted", + read: false, + content: `You have been granted Leave of Absence until ${new Date( + req.body.expirationDate + ).toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + })}.`, + }); + + await req.app.dossier.create({ + by: res.user.cid, + affected: req.body.controller, + action: `%b added a leave of absence for %a: ${req.body.reason}`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/log', getUser, auth(['atm', 'datm', 'ta', 'fe', 'ec', 'wm']), async (req, res) => { - const page = +req.query.page || 1; - const limit = +req.query.limit || 20; - const amount = await req.app.dossier.countDocuments(); - - try { - const dossier = await req.app.dossier - .find() - .sort({ - createdAt: 'desc' - }).skip(limit * (page - 1)).limit(limit).populate( - 'userBy', 'fname lname cid' - ).populate( - 'userAffected', 'fname lname cid' - ).lean(); - - res.stdRes.data = { - dossier, - amount - }; - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); -}) - -router.get('/:cid', getUser, async (req, res) => { - try { - const user = await User.findOne({ - cid: req.params.cid - }).select( - '-idsToken -discordInfo -trainingMilestones' - ).populate('roles').populate('certifications').populate({ - path: 'absence', - match: { - expirationDate: { - $gte: new Date() - }, - deleted: false - }, - select: '-reason' - }).lean({virtuals: true}); - - if(!user || [995625].includes(user.cid)) { - throw { - code: 503, - message: "Unable to find controller" - }; - } - - if(!res.user || !res.user.isStaff) { - delete user.email; - } - - res.stdRes.data = user; - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.delete( + "/absence/:id", + getUser, + auth(["atm", "datm"]), + async (req, res) => { + try { + if (!req.params.id) { + throw { + code: 401, + message: "Invalid request", + }; + } + + const absence = await Absence.findOne({ _id: req.params.id }); + await absence.delete(); + + await req.app.dossier.create({ + by: res.user.cid, + affected: absence.controller, + action: `%b deleted the leave of absence for %a.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); + } +); + +router.get( + "/log", + getUser, + auth(["atm", "datm", "ta", "fe", "ec", "wm"]), + async (req, res) => { + const page = +req.query.page || 1; + const limit = +req.query.limit || 20; + const amount = await req.app.dossier.countDocuments(); + + try { + const dossier = await req.app.dossier + .find() + .sort({ + createdAt: "desc", + }) + .skip(limit * (page - 1)) + .limit(limit) + .populate("userBy", "fname lname cid") + .populate("userAffected", "fname lname cid") + .lean(); + + res.stdRes.data = { + dossier, + amount, + }; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); + } +); + +router.get("/:cid", getUser, async (req, res) => { + try { + const user = await User.findOne({ + cid: req.params.cid, + }) + .select("-idsToken -discordInfo -trainingMilestones") + .populate("roles") + .populate("certifications") + .populate({ + path: "absence", + match: { + expirationDate: { + $gte: new Date(), + }, + deleted: false, + }, + select: "-reason", + }) + .lean({ virtuals: true }); + + if (!user || [995625].includes(user.cid)) { + throw { + code: 503, + message: "Unable to find controller", + }; + } + + if (!res.user || !res.user.isStaff) { + delete user.email; + } + + res.stdRes.data = user; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/stats/:cid', async (req, res) => { - try { - const controllerHours = await ControllerHours.find({cid: req.params.cid}); - const hours = { - gtyear: { - del: 0, - gnd: 0, - twr: 0, - app: 0, - ctr: 0 - }, - total: { - del: 0, - gnd: 0, - twr: 0, - app: 0, - ctr: 0 - }, - sessionCount: controllerHours.length, - sessionAvg: 0, - months: [], - }; - const pos = { - del: 'del', - gnd: 'gnd', - twr: 'twr', - dep: 'app', - app: 'app', - ctr: 'ctr' - } - const today = L.utc(); - - const getMonthYearString = date => date.toFormat('LLL yyyy'); - - for(let i = 0; i < 13; i++) { - const theMonth = today.minus({months: i}); - const ms = getMonthYearString(theMonth) - hours[ms] = { - del: 0, - gnd: 0, - twr: 0, - app: 0, - ctr: 0 - }; - hours.months.push(ms); - } - - for(const sess of controllerHours) { - const thePos = sess.position.toLowerCase().match(/([a-z]{3})$/); // 🤮 - - if(thePos) { - const start = L.fromJSDate(sess.timeStart).toUTC(); - const end = L.fromJSDate(sess.timeEnd).toUTC(); - const type = pos[thePos[1]]; - const length = end.toFormat('X') - start.toFormat('X'); - let ms = getMonthYearString(start); - - if(!hours[ms]) { - ms = 'gtyear'; - } - - hours[ms][type] += length; - hours.total[type] += length; - } - - } - - hours.sessionAvg = Math.round(Object.values(hours.total).reduce((acc, cv) => acc + cv)/hours.sessionCount); - res.stdRes.data = hours; - } - - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/stats/:cid", async (req, res) => { + try { + const controllerHours = await ControllerHours.find({ cid: req.params.cid }); + const hours = { + gtyear: { + del: 0, + gnd: 0, + twr: 0, + app: 0, + ctr: 0, + }, + total: { + del: 0, + gnd: 0, + twr: 0, + app: 0, + ctr: 0, + }, + sessionCount: controllerHours.length, + sessionAvg: 0, + months: [], + }; + const pos = { + del: "del", + gnd: "gnd", + twr: "twr", + dep: "app", + app: "app", + ctr: "ctr", + }; + const today = L.utc(); + + const getMonthYearString = (date) => date.toFormat("LLL yyyy"); + + for (let i = 0; i < 13; i++) { + const theMonth = today.minus({ months: i }); + const ms = getMonthYearString(theMonth); + hours[ms] = { + del: 0, + gnd: 0, + twr: 0, + app: 0, + ctr: 0, + }; + hours.months.push(ms); + } + + for (const sess of controllerHours) { + const thePos = sess.position.toLowerCase().match(/([a-z]{3})$/); // 🤮 + + if (thePos) { + const start = L.fromJSDate(sess.timeStart).toUTC(); + const end = L.fromJSDate(sess.timeEnd).toUTC(); + const type = pos[thePos[1]]; + const length = end.toFormat("X") - start.toFormat("X"); + let ms = getMonthYearString(start); + + if (!hours[ms]) { + ms = "gtyear"; + } + + hours[ms][type] += length; + hours.total[type] += length; + } + } + + hours.sessionAvg = Math.round( + Object.values(hours.total).reduce((acc, cv) => acc + cv) / + hours.sessionCount + ); + res.stdRes.data = hours; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.post('/visit', getUser, async (req, res) => { - try { - if(!res.user) { - throw { - code: 401, - message: "Unable to verify user" - }; - } - - const userData = { - cid: res.user.cid, - fname: res.user.fname, - lname: res.user.lname, - rating: res.user.ratingLong, - email: req.body.email, - home: req.body.facility, - reason: req.body.reason - } - - await VisitApplication.create(userData); - - await transporter.sendMail({ - to: req.body.email, - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Visiting Application Received | Albuquerque ARTCC`, - template: 'visitReceived', - context: { - name: `${res.user.fname} ${res.user.lname}`, - } - }); - await transporter.sendMail({ - to: 'atm@zabartcc.org, datm@zabartcc.org', - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `New Visiting Application: ${res.user.fname} ${res.user.lname} | Albuquerque ARTCC`, - template: 'staffNewVisit', - context: { - user: userData - } - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.post("/visit", getUser, async (req, res) => { + try { + if (!res.user) { + throw { + code: 401, + message: "Unable to verify user", + }; + } + + const userData = { + cid: res.user.cid, + fname: res.user.fname, + lname: res.user.lname, + rating: res.user.ratingLong, + email: req.body.email, + home: req.body.facility, + reason: req.body.reason, + }; + + await VisitApplication.create(userData); + + await transporter.sendMail({ + to: req.body.email, + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `Visiting Application Received | Albuquerque ARTCC`, + template: "visitReceived", + context: { + name: `${res.user.fname} ${res.user.lname}`, + }, + }); + await transporter.sendMail({ + to: "atm@zabartcc.org, datm@zabartcc.org", + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `New Visiting Application: ${res.user.fname} ${res.user.lname} | Albuquerque ARTCC`, + template: "staffNewVisit", + context: { + user: userData, + }, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.get('/visit/status', getUser, async (req, res) => { - try { - if(!res.user) { - throw { - code: 401, - message: "Unable to verify user" - }; - } - const count = await VisitApplication.countDocuments({cid: res.user.cid, deleted: false}); - res.stdRes.data = count; - } catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.get("/visit/status", getUser, async (req, res) => { + try { + if (!res.user) { + throw { + code: 401, + message: "Unable to verify user", + }; + } + const count = await VisitApplication.countDocuments({ + cid: res.user.cid, + deleted: false, + }); + res.stdRes.data = count; + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.put('/visit/:cid', getUser, auth(['atm', 'datm']), async (req, res) => { - try { - await VisitApplication.delete({cid: req.params.cid}); - - const user = await User.findOne({cid: req.params.cid}); - const oi = await User.find({deletedAt: null, member: true}).select('oi').lean(); - const userOi = generateOperatingInitials(user.fname, user.lname, oi.map(oi => oi.oi)) - - user.member = true; - user.vis = true; - user.oi = userOi; - - await user.save(); - - await transporter.sendMail({ - to: user.email, - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Visiting Application Accepted | Albuquerque ARTCC`, - template: 'visitAccepted', - context: { - name: `${user.fname} ${user.lname}`, - } - }); - - await axios.post(`https://api.vatusa.net/v2/facility/ZAB/roster/manageVisitor/${req.params.cid}?apikey=${process.env.VATUSA_API_KEY}`) - - await req.app.dossier.create({ - by: res.user.cid, - affected: user.cid, - action: `%b approved the visiting application for %a.` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.put("/visit/:cid", getUser, auth(["atm", "datm"]), async (req, res) => { + try { + await VisitApplication.delete({ cid: req.params.cid }); + + const user = await User.findOne({ cid: req.params.cid }); + const oi = await User.find({ deletedAt: null, member: true }) + .select("oi") + .lean(); + const userOi = generateOperatingInitials( + user.fname, + user.lname, + oi.map((oi) => oi.oi) + ); + + user.member = true; + user.vis = true; + user.oi = userOi; + + await user.save(); + + await transporter.sendMail({ + to: user.email, + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `Visiting Application Accepted | Albuquerque ARTCC`, + template: "visitAccepted", + context: { + name: `${user.fname} ${user.lname}`, + }, + }); + + await axios.post( + `https://api.vatusa.net/v2/facility/ZAB/roster/manageVisitor/${req.params.cid}?apikey=${process.env.VATUSA_API_KEY}` + ); + + await req.app.dossier.create({ + by: res.user.cid, + affected: user.cid, + action: `%b approved the visiting application for %a.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); - -router.delete('/visit/:cid', getUser, auth(['atm', 'datm']), async (req, res) => { - try { - await VisitApplication.delete({cid: req.params.cid}); - - const user = await User.findOne({cid: req.params.cid}); - - await transporter.sendMail({ - to: user.email, - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Visiting Application Rejected | Albuquerque ARTCC`, - template: 'visitRejected', - context: { - name: `${user.fname} ${user.lname}`, - reason: req.body.reason - } - }); - await req.app.dossier.create({ - by: res.user.cid, - affected: user.cid, - action: `%b rejected the visiting application for %a: ${req.body.reason}` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.delete( + "/visit/:cid", + getUser, + auth(["atm", "datm"]), + async (req, res) => { + try { + await VisitApplication.delete({ cid: req.params.cid }); + + const user = await User.findOne({ cid: req.params.cid }); + + await transporter.sendMail({ + to: user.email, + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `Visiting Application Rejected | Albuquerque ARTCC`, + template: "visitRejected", + context: { + name: `${user.fname} ${user.lname}`, + reason: req.body.reason, + }, + }); + await req.app.dossier.create({ + by: res.user.cid, + affected: user.cid, + action: `%b rejected the visiting application for %a: ${req.body.reason}`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); + } +); + +router.post("/:cid", microAuth, async (req, res) => { + try { + const user = await User.findOne({ cid: req.params.cid }); + if (user) { + throw { + code: 409, + message: "This user already exists", + }; + } + + if (!req.body) { + throw { + code: 400, + message: "No user data provided", + }; + } + + const oi = await User.find({ deletedAt: null, member: true }) + .select("oi") + .lean(); + const userOi = generateOperatingInitials( + req.body.fname, + req.body.lname, + oi.map((oi) => oi.oi) + ); + const { data } = await axios.get( + `https://ui-avatars.com/api/?name=${userOi}&size=256&background=122049&color=ffffff`, + { responseType: "arraybuffer" } + ); + + await req.app.s3 + .putObject({ + Bucket: "zabartcc/avatars", + Key: `${req.body.cid}-default.png`, + Body: data, + ContentType: "image/png", + ACL: "public-read", + ContentDisposition: "inline", + }) + .promise(); + + await User.create({ + ...req.body, + oi: userOi, + avatar: `${req.body.cid}-default.png`, + }); + + const ratings = [ + "Unknown", + "OBS", + "S1", + "S2", + "S3", + "C1", + "C2", + "C3", + "I1", + "I2", + "I3", + "SUP", + "ADM", + ]; + + await transporter.sendMail({ + to: "atm@zabartcc.org; datm@zabartcc.org; ta@zabartcc.org", + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `New ${req.body.vis ? "Visitor" : "Member"}: ${req.body.fname} ${ + req.body.lname + } | Albuquerque ARTCC`, + template: "newController", + context: { + name: `${req.body.fname} ${req.body.lname}`, + email: req.body.email, + cid: req.body.cid, + rating: ratings[req.body.rating], + vis: req.body.vis, + type: req.body.vis ? "visitor" : "member", + home: req.body.vis ? req.body.homeFacility : "ZAB", + }, + }); + + await req.app.dossier.create({ + by: -1, + affected: req.body.cid, + action: `%a was created by an external service.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.post('/:cid', microAuth, async (req, res) => { - try { - const user = await User.findOne({cid: req.params.cid}); - if(user) { - throw { - code: 409, - message: "This user already exists" - }; - } - - if(!req.body) { - throw { - code: 400, - message: "No user data provided" - }; - } - - const oi = await User.find({deletedAt: null, member: true}).select('oi').lean(); - const userOi = generateOperatingInitials(req.body.fname, req.body.lname, oi.map(oi => oi.oi)) - const {data} = await axios.get(`https://ui-avatars.com/api/?name=${userOi}&size=256&background=122049&color=ffffff`, {responseType: 'arraybuffer'}); - - await req.app.s3.putObject({ - Bucket: 'zabartcc/avatars', - Key: `${req.body.cid}-default.png`, - Body: data, - ContentType: 'image/png', - ACL: 'public-read', - ContentDisposition: 'inline', - }).promise(); - - await User.create({ - ...req.body, - oi: userOi, - avatar: `${req.body.cid}-default.png`, - }); - - const ratings = ['Unknown', 'OBS', 'S1', 'S2', 'S3', 'C1', 'C2', 'C3', 'I1', 'I2', 'I3', 'SUP', 'ADM']; - - await transporter.sendMail({ - to: "atm@zabartcc.org; datm@zabartcc.org; ta@zabartcc.org", - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `New ${req.body.vis ? 'Visitor' : 'Member'}: ${req.body.fname} ${req.body.lname} | Albuquerque ARTCC`, - template: 'newController', - context: { - name: `${req.body.fname} ${req.body.lname}`, - email: req.body.email, - cid: req.body.cid, - rating: ratings[req.body.rating], - vis: req.body.vis, - type: req.body.vis ? 'visitor' : 'member', - home: req.body.vis ? req.body.homeFacility : 'ZAB' - } - }); - - await req.app.dossier.create({ - by: -1, - affected: req.body.cid, - action: `%a was created by an external service.` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.put("/:cid/member", microAuth, async (req, res) => { + try { + const user = await User.findOne({ cid: req.params.cid }); + + if (!user) { + throw { + code: 400, + message: "Unable to find user", + }; + } + + const oi = await User.find({ deletedAt: null, member: true }) + .select("oi") + .lean(); + + user.member = req.body.member; + user.oi = req.body.member + ? generateOperatingInitials( + user.fname, + user.lname, + oi.map((oi) => oi.oi) + ) + : null; + user.joinDate = req.body.member ? new Date() : null; + + await user.save(); + + await req.app.dossier.create({ + by: -1, + affected: req.params.cid, + action: `%a was ${ + req.body.member ? "added to" : "removed from" + } the roster by an external service.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.put('/:cid/member', microAuth, async (req, res) => { - try { - const user = await User.findOne({cid: req.params.cid}); - - if(!user) { - throw { - code: 400, - message: "Unable to find user" - }; - } - - const oi = await User.find({deletedAt: null, member: true}).select('oi').lean(); - - user.member = req.body.member; - user.oi = (req.body.member) ? generateOperatingInitials(user.fname, user.lname, oi.map(oi => oi.oi)) : null; - user.joinDate = req.body.member ? new Date() : null; - - await user.save(); - - - await req.app.dossier.create({ - by: -1, - affected: req.params.cid, - action: `%a was ${req.body.member ? 'added to' : 'removed from'} the roster by an external service.` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); -}) - -router.put('/:cid/visit', microAuth, async (req, res) => { - try { - const user = await User.findOne({cid: req.params.cid}); - - if(!user) { - throw { - code: 400, - message: "Unable to find user" - }; - } - - user.vis = req.body.vis; - user.joinDate = new Date(); - - await user.save(); - - await req.app.dossier.create({ - by: -1, - affected: req.params.cid, - action: `%a was set as a ${req.body.vis ? 'visiting controller' : 'home controller'} by an external service.` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); -}) - -router.put('/:cid', getUser, auth(['atm', 'datm', 'ta', 'wm', 'ins']), async (req, res) => { - try { - if(!req.body.form) { - throw { - code: 400, - message: "No user data included" - }; - } - - const {fname, lname, email, oi, roles, certs, vis} = req.body.form; - const toApply = { - roles: [], - certifications: [] - }; - - for(const [code, set] of Object.entries(roles)) { - if(set) { - toApply.roles.push(code); - } - } - - for(const [code, set] of Object.entries(certs)) { - if(set) { - toApply.certifications.push(code); - } - } - - const {data} = await axios.get(`https://ui-avatars.com/api/?name=${oi}&size=256&background=122049&color=ffffff`, {responseType: 'arraybuffer'}); - - await req.app.s3.putObject({ - Bucket: 'zabartcc/avatars', - Key: `${req.params.cid}-default.png`, - Body: data, - ContentType: 'image/png', - ACL: 'public-read', - ContentDisposition: 'inline', - }).promise(); - - await User.findOneAndUpdate({cid: req.params.cid}, { - fname, - lname, - email, - oi, - vis, - roleCodes: toApply.roles, - certCodes: toApply.certifications, - }); - - await req.app.dossier.create({ - by: res.user.cid, - affected: req.params.cid, - action: `%a was updated by %b.` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.put("/:cid/visit", microAuth, async (req, res) => { + try { + const user = await User.findOne({ cid: req.params.cid }); + + if (!user) { + throw { + code: 400, + message: "Unable to find user", + }; + } + + user.vis = req.body.vis; + user.joinDate = new Date(); + + await user.save(); + + await req.app.dossier.create({ + by: -1, + affected: req.params.cid, + action: `%a was set as a ${ + req.body.vis ? "visiting controller" : "home controller" + } by an external service.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); -router.delete('/:cid', getUser, auth(['atm', 'datm']), async (req, res) => { - try { - if(!req.body.reason) { - throw { - code: 400, - message: "You must specify a reason" - }; - } - - const user = await User.findOneAndUpdate({cid: req.params.cid}, { - member: false - }); - - if(user.vis) { - await axios.delete(`https://api.vatusa.net/v2/facility/ZAB/roster/manageVisitor/${req.params.cid}`, { - params: { - apikey: process.env.VATUSA_API_KEY, - }, - data: { - reason: req.body.reason - } - }); - } else { - await axios.delete(`https://api.vatusa.net/v2/facility/ZAB/roster/${req.params.cid}`, { - params: { - apikey: process.env.VATUSA_API_KEY, - }, - data: { - reason: req.body.reason - } - }); - } - - await req.app.dossier.create({ - by: res.user.cid, - affected: req.params.cid, - action: `%a was removed from the roster by %b: ${req.body.reason}` - }); - } - catch(e) { - req.app.Sentry.captureException(e); - res.stdRes.ret_det = e; - } - - return res.json(res.stdRes); +router.put( + "/:cid", + getUser, + auth(["atm", "datm", "ta", "wm", "ins"]), + async (req, res) => { + try { + if (!req.body.form) { + throw { + code: 400, + message: "No user data included", + }; + } + + const { fname, lname, email, oi, roles, certs, vis } = req.body.form; + const toApply = { + roles: [], + certifications: [], + }; + + for (const [code, set] of Object.entries(roles)) { + if (set) { + toApply.roles.push(code); + } + } + + for (const [code, set] of Object.entries(certs)) { + if (set) { + toApply.certifications.push(code); + } + } + + const { data } = await axios.get( + `https://ui-avatars.com/api/?name=${oi}&size=256&background=122049&color=ffffff`, + { responseType: "arraybuffer" } + ); + + await req.app.s3 + .putObject({ + Bucket: "zabartcc/avatars", + Key: `${req.params.cid}-default.png`, + Body: data, + ContentType: "image/png", + ACL: "public-read", + ContentDisposition: "inline", + }) + .promise(); + + await User.findOneAndUpdate( + { cid: req.params.cid }, + { + fname, + lname, + email, + oi, + vis, + roleCodes: toApply.roles, + certCodes: toApply.certifications, + } + ); + + await req.app.dossier.create({ + by: res.user.cid, + affected: req.params.cid, + action: `%a was updated by %b.`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); + } +); + +router.delete("/:cid", getUser, auth(["atm", "datm"]), async (req, res) => { + try { + if (!req.body.reason) { + throw { + code: 400, + message: "You must specify a reason", + }; + } + + const user = await User.findOneAndUpdate( + { cid: req.params.cid }, + { + member: false, + } + ); + + if (user.vis) { + await axios.delete( + `https://api.vatusa.net/v2/facility/ZAB/roster/manageVisitor/${req.params.cid}`, + { + params: { + apikey: process.env.VATUSA_API_KEY, + }, + data: { + reason: req.body.reason, + }, + } + ); + } else { + await axios.delete( + `https://api.vatusa.net/v2/facility/ZAB/roster/${req.params.cid}`, + { + params: { + apikey: process.env.VATUSA_API_KEY, + }, + data: { + reason: req.body.reason, + }, + } + ); + } + + await req.app.dossier.create({ + by: res.user.cid, + affected: req.params.cid, + action: `%a was removed from the roster by %b: ${req.body.reason}`, + }); + } catch (e) { + req.app.Sentry.captureException(e); + res.stdRes.ret_det = e; + } + + return res.json(res.stdRes); }); /** @@ -848,46 +958,50 @@ router.delete('/:cid', getUser, auth(['atm', 'datm']), async (req, res) => { * @return A two character set of operating initials (e.g. RA). */ const generateOperatingInitials = (fname, lname, usedOi) => { - let operatingInitials; - const MAX_TRIES = 10; - - operatingInitials = `${fname.charAt(0).toUpperCase()}${lname.charAt(0).toUpperCase()}`; - - if(!usedOi.includes(operatingInitials)) { - return operatingInitials; - } - - operatingInitials = `${lname.charAt(0).toUpperCase()}${fname.charAt(0).toUpperCase()}`; - - if(!usedOi.includes(operatingInitials)) { - return operatingInitials; - } - - const chars = `${lname.toUpperCase()}${fname.toUpperCase()}`; - - let tries = 0; - - do { - operatingInitials = random(chars, 2); - tries++; - } while(usedOi.includes(operatingInitials) || tries > MAX_TRIES); - - if(!usedOi.includes(operatingInitials)) { - return operatingInitials; - } - - tries = 0; - - do { - operatingInitials = random('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 2); - tries++; - } while(usedOi.includes(operatingInitials) || tries > MAX_TRIES); - - if(!usedOi.includes(operatingInitials)) { - return operatingInitials; - } - - return false; + let operatingInitials; + const MAX_TRIES = 10; + + operatingInitials = `${fname.charAt(0).toUpperCase()}${lname + .charAt(0) + .toUpperCase()}`; + + if (!usedOi.includes(operatingInitials)) { + return operatingInitials; + } + + operatingInitials = `${lname.charAt(0).toUpperCase()}${fname + .charAt(0) + .toUpperCase()}`; + + if (!usedOi.includes(operatingInitials)) { + return operatingInitials; + } + + const chars = `${lname.toUpperCase()}${fname.toUpperCase()}`; + + let tries = 0; + + do { + operatingInitials = random(chars, 2); + tries++; + } while (usedOi.includes(operatingInitials) || tries > MAX_TRIES); + + if (!usedOi.includes(operatingInitials)) { + return operatingInitials; + } + + tries = 0; + + do { + operatingInitials = random("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 2); + tries++; + } while (usedOi.includes(operatingInitials) || tries > MAX_TRIES); + + if (!usedOi.includes(operatingInitials)) { + return operatingInitials; + } + + return false; }; /** @@ -897,11 +1011,11 @@ const generateOperatingInitials = (fname, lname, usedOi) => { * @return String of selected characters. */ const random = (str, len) => { - let ret = ''; - for (let i = 0; i < len; i++) { - ret = `${ret}${str.charAt(Math.floor(Math.random() * str.length))}`; - } - return ret; + let ret = ""; + for (let i = 0; i < len; i++) { + ret = `${ret}${str.charAt(Math.floor(Math.random() * str.length))}`; + } + return ret; }; -export default router; \ No newline at end of file +export default router; diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js index bb4508c..a87c91c 100644 --- a/helpers/controllerActivityHelper.js +++ b/helpers/controllerActivityHelper.js @@ -1,12 +1,12 @@ -import cron from 'node-cron'; -import transporter from '../config/mailer.js'; -import User from '../models/User.js'; -import ControllerHours from '../models/ControllerHours.js'; -import TrainingRequest from '../models/TrainingRequest.js'; -import { DateTime as luxon } from 'luxon' -import Redis from 'redis'; -import RedisLock from 'redis-lock'; -import env from 'dotenv'; +import cron from "node-cron"; +import transporter from "../config/mailer.js"; +import User from "../models/User.js"; +import ControllerHours from "../models/ControllerHours.js"; +import TrainingRequest from "../models/TrainingRequest.js"; +import { DateTime as luxon } from "luxon"; +import Redis from "redis"; +import RedisLock from "redis-lock"; +import env from "dotenv"; env.config(); @@ -15,162 +15,175 @@ let redisLock = RedisLock(redis); await redis.connect(); const observerRatingCode = 1; -const activityWindowInDays = 90; +const activityWindowInDays = 90; // Update from 60 to 90 days const gracePeriodInDays = 15; -const requiredHoursPerPeriod = 2; +const requiredHoursPerPeriod = 3; // Ensure this is set to 3 hours const redisActivityCheckKey = "ACTIVITYCHECKRUNNING"; /** * Registers a CRON job that sends controllers reminder emails. */ function registerControllerActivityChecking() { - try { - if (process.env.NODE_ENV === 'prod') { - cron.schedule('0 0 * * *', async () => { - // Lock the activity check to avoid multiple app instances trying to simulatenously run the check. - const lockRunningActivityCheck = await redisLock(redisActivityCheckKey); + try { + if (process.env.NODE_ENV === "prod") { + cron.schedule("0 0 * * *", async () => { + // Lock the activity check to avoid multiple app instances trying to simulatenously run the check. + const lockRunningActivityCheck = await redisLock(redisActivityCheckKey); - await checkControllerActivity(); - await checkControllersNeedingRemoval(); + await checkControllerActivity(); + await checkControllersNeedingRemoval(); - lockRunningActivityCheck(); // Releases the lock. - }); + lockRunningActivityCheck(); // Releases the lock. + }); - console.log("Successfully registered activity CRON checks") - } - } - catch (e) { - console.log("Error registering activity CRON checks") - console.error(e) + console.log("Successfully registered activity CRON checks"); } + } catch (e) { + console.log("Error registering activity CRON checks"); + console.error(e); + } } /** * Checks controllers for activity and sends a reminder email. */ async function checkControllerActivity() { - const today = luxon.utc(); - const minActivityDate = today.minus({ days: activityWindowInDays - 1 }); + const today = luxon.utc(); + const minActivityDate = today.minus({ days: activityWindowInDays - 1 }); - try { - const usersNeedingActivityCheck = await User.find( - { - member: true, - $or: [{ nextActivityCheckDate: { $lte: today } }, { nextActivityCheckDate: null }] - }); + try { + const usersNeedingActivityCheck = await User.find({ + member: true, + $or: [ + { nextActivityCheckDate: { $lte: today } }, + { nextActivityCheckDate: null }, + ], + }); - await User.updateMany( - { "cid": { $in: usersNeedingActivityCheck.map(u => u.cid) } }, - { - nextActivityCheckDate: today.plus({ days: activityWindowInDays }) - } - ) + await User.updateMany( + { cid: { $in: usersNeedingActivityCheck.map((u) => u.cid) } }, + { + nextActivityCheckDate: today.plus({ days: activityWindowInDays }), + } + ); - const inactiveUserData = await getControllerInactivityData(usersNeedingActivityCheck, minActivityDate); + const inactiveUserData = await getControllerInactivityData( + usersNeedingActivityCheck, + minActivityDate + ); - inactiveUserData.forEach(async record => { - await User.updateOne( - { "cid": record.user.cid }, - { - removalWarningDeliveryDate: today.plus({ days: gracePeriodInDays }) - } - ) + inactiveUserData.forEach(async (record) => { + await User.updateOne( + { cid: record.user.cid }, + { + removalWarningDeliveryDate: today.plus({ days: gracePeriodInDays }), + } + ); - transporter.sendMail({ - to: record.user.email, - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Controller Activity Warning | Albuquerque ARTCC`, - template: 'activityReminder', - context: { - name: record.user.fname, - requiredHours: requiredHoursPerPeriod, - activityWindow: activityWindowInDays, - daysRemaining: gracePeriodInDays, - currentHours: record.hours.toFixed(2) - } - }); - }); - } - catch (e) { - console.error(e) - } + transporter.sendMail({ + to: record.user.email, + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `Controller Activity Warning | Albuquerque ARTCC`, + template: "activityReminder", + context: { + name: record.user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays, + daysRemaining: gracePeriodInDays, + currentHours: record.hours.toFixed(2), + }, + }); + }); + } catch (e) { + console.error(e); + } } - /** * Checks for controllers that did not maintain activity and sends a removal email. */ async function checkControllersNeedingRemoval() { - const today = luxon.utc(); + const today = luxon.utc(); - try { - const usersNeedingRemovalWarningCheck = await User.find( - { - member: true, - removalWarningDeliveryDate: { $lte: today } - }); + try { + const usersNeedingRemovalWarningCheck = await User.find({ + member: true, + removalWarningDeliveryDate: { $lte: today }, + }); - usersNeedingRemovalWarningCheck.forEach(async user => { - const minActivityDate = luxon.fromJSDate(user.removalWarningDeliveryDate).minus({ days: activityWindowInDays - 1 }); - const userHourSums = await ControllerHours.aggregate([ - { - $match: { - timeStart: { $gt: minActivityDate }, - cid: user.cid - } - }, - { - $project: { - length: { - "$divide": [ - { $subtract: ['$timeEnd', '$timeStart'] }, - 60 * 1000 * 60 // Convert to hours. - ] - } - } - }, - { - $group: { - _id: "$cid", - total: { "$sum": "$length" } - } - } - ]); - const userTotalHoursInPeriod = (userHourSums && userHourSums.length > 0) ? userHourSums[0].total : 0; - const userTrainingRequestCount = await TrainingRequest.count({ studentCid: user.cid, startTime: { $gt: minActivityDate } }); + usersNeedingRemovalWarningCheck.forEach(async (user) => { + const minActivityDate = luxon + .fromJSDate(user.removalWarningDeliveryDate) + .minus({ days: activityWindowInDays - 1 }); + const userHourSums = await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: minActivityDate }, + cid: user.cid, + }, + }, + { + $project: { + length: { + $divide: [ + { $subtract: ["$timeEnd", "$timeStart"] }, + 60 * 1000 * 60, // Convert to hours. + ], + }, + }, + }, + { + $group: { + _id: "$cid", + total: { $sum: "$length" }, + }, + }, + ]); + const userTotalHoursInPeriod = + userHourSums && userHourSums.length > 0 ? userHourSums[0].total : 0; + const userTrainingRequestCount = await TrainingRequest.count({ + studentCid: user.cid, + startTime: { $gt: minActivityDate }, + }); - await User.updateOne( - { "cid": user.cid }, - { - removalWarningDeliveryDate: null - } - ) + await User.updateOne( + { cid: user.cid }, + { + removalWarningDeliveryDate: null, + } + ); - if (controllerIsInactive(user, userTotalHoursInPeriod, userTrainingRequestCount, minActivityDate)) { - transporter.sendMail({ - to: user.email, - cc: 'datm@zabartcc.org', - from: { - name: "Albuquerque ARTCC", - address: 'noreply@zabartcc.org' - }, - subject: `Controller Inactivity Notice | Albuquerque ARTCC`, - template: 'activityWarning', - context: { - name: user.fname, - requiredHours: requiredHoursPerPeriod, - activityWindow: activityWindowInDays - } - }); - } + if ( + controllerIsInactive( + user, + userTotalHoursInPeriod, + userTrainingRequestCount, + minActivityDate + ) + ) { + transporter.sendMail({ + to: user.email, + cc: "datm@zabartcc.org", + from: { + name: "Albuquerque ARTCC", + address: "noreply@zabartcc.org", + }, + subject: `Controller Inactivity Notice | Albuquerque ARTCC`, + template: "activityWarning", + context: { + name: user.fname, + requiredHours: requiredHoursPerPeriod, + activityWindow: activityWindowInDays, + }, }); - } - catch (e) { - console.error(e); - } + } + }); + } catch (e) { + console.error(e); + } } /** @@ -179,63 +192,85 @@ async function checkControllersNeedingRemoval() { * @param minActivityDate The start date of the activity period. * @return A map of inactive controllers with the amount of hours they've controlled in the current period. */ -async function getControllerInactivityData(controllersToGetStatusFor, minActivityDate) { - const controllerHoursSummary = {}; - const controllerTrainingSummary = {}; - const inactiveControllers = []; - const controllerCids = controllersToGetStatusFor.map(c => c.cid); +async function getControllerInactivityData( + controllersToGetStatusFor, + minActivityDate +) { + const controllerHoursSummary = {}; + const controllerTrainingSummary = {}; + const inactiveControllers = []; + const controllerCids = controllersToGetStatusFor.map((c) => c.cid); - (await ControllerHours.aggregate([ - { - $match: { - timeStart: { $gt: minActivityDate }, - cid: { $in: controllerCids } - } + ( + await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: minActivityDate }, + cid: { $in: controllerCids }, }, - { - $project: { - length: { - "$divide": [ - { $subtract: ['$timeEnd', '$timeStart'] }, - 60 * 1000 * 60 // Convert to hours. - ] - }, - cid: 1 - } + }, + { + $project: { + length: { + $divide: [ + { $subtract: ["$timeEnd", "$timeStart"] }, + 60 * 1000 * 60, // Convert to hours. + ], + }, + cid: 1, }, - { - $group: { - _id: "$cid", - total: { "$sum": "$length" } - } - } - ])).forEach(i => controllerHoursSummary[i._id] = i.total); + }, + { + $group: { + _id: "$cid", + total: { $sum: "$length" }, + }, + }, + ]) + ).forEach((i) => (controllerHoursSummary[i._id] = i.total)); - (await TrainingRequest.aggregate([ - { $match: { startTime: { $gt: minActivityDate }, studentCid: { $in: controllerCids } } }, - { - $group: { - _id: "$studentCid", - total: { $sum: 1 } - } - } - ])).forEach(i => controllerTrainingSummary[i._id] = i.total); + ( + await TrainingRequest.aggregate([ + { + $match: { + startTime: { $gt: minActivityDate }, + studentCid: { $in: controllerCids }, + }, + }, + { + $group: { + _id: "$studentCid", + total: { $sum: 1 }, + }, + }, + ]) + ).forEach((i) => (controllerTrainingSummary[i._id] = i.total)); - controllersToGetStatusFor.forEach(async user => { - let controllerHoursCount = controllerHoursSummary[user.cid] ?? 0; - let controllerTrainingSessions = controllerTrainingSummary[user.cid] != null ? controllerTrainingSummary[user.cid].length : 0 + controllersToGetStatusFor.forEach(async (user) => { + let controllerHoursCount = controllerHoursSummary[user.cid] ?? 0; + let controllerTrainingSessions = + controllerTrainingSummary[user.cid] != null + ? controllerTrainingSummary[user.cid].length + : 0; - if (controllerIsInactive(user, controllerHoursCount, controllerTrainingSessions, minActivityDate)) { - const inactiveControllerData = { - user: user, - hours: controllerHoursCount - }; + if ( + controllerIsInactive( + user, + controllerHoursCount, + controllerTrainingSessions, + minActivityDate + ) + ) { + const inactiveControllerData = { + user: user, + hours: controllerHoursCount, + }; - inactiveControllers.push(inactiveControllerData); - } - }); + inactiveControllers.push(inactiveControllerData); + } + }); - return inactiveControllers; + return inactiveControllers; } /** @@ -246,14 +281,26 @@ async function getControllerInactivityData(controllersToGetStatusFor, minActivit * @param minActivityDate The start date of the activity period. * @return True if controller is inactive, false otherwise. */ -function controllerIsInactive(user, hoursInPeriod, trainingSessionInPeriod, minActivityDate) { - const controllerHasLessThanTwoHours = (hoursInPeriod ?? 0) < requiredHoursPerPeriod; - const controllerJoinedMoreThan60DaysAgo = (user.joinDate ?? user.createdAt) < minActivityDate; - const controllerIsNotObserverWithTrainingSession = user.rating != observerRatingCode || trainingSessionInPeriod < 1; +function controllerIsInactive( + user, + hoursInPeriod, + trainingSessionInPeriod, + minActivityDate +) { + const controllerHasLessThanRequiredHours = + (hoursInPeriod ?? 0) < requiredHoursPerPeriod; + const controllerJoinedMoreThanActivityWindowAgo = + (user.joinDate ?? user.createdAt) < minActivityDate; + const controllerIsNotObserverWithTrainingSession = + user.rating != observerRatingCode || trainingSessionInPeriod < 1; - return controllerHasLessThanTwoHours && controllerJoinedMoreThan60DaysAgo && controllerIsNotObserverWithTrainingSession; + return ( + controllerHasLessThanRequiredHours && + controllerJoinedMoreThanActivityWindowAgo && + controllerIsNotObserverWithTrainingSession + ); } export default { - registerControllerActivityChecking: registerControllerActivityChecking -} + registerControllerActivityChecking: registerControllerActivityChecking, +}; From a08f77c1a0f19438ac7d0f60e8cbb928373270dc Mon Sep 17 00:00:00 2001 From: XDerpingxGruntX <41699998+XDerpingxGruntX@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:55:25 -0600 Subject: [PATCH 09/10] Hotfix: API 90d to Quarter --- helpers/controllerActivityHelper.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js index a87c91c..e37633d 100644 --- a/helpers/controllerActivityHelper.js +++ b/helpers/controllerActivityHelper.js @@ -20,6 +20,14 @@ const gracePeriodInDays = 15; const requiredHoursPerPeriod = 3; // Ensure this is set to 3 hours const redisActivityCheckKey = "ACTIVITYCHECKRUNNING"; +// Helper function to get the start and end dates of the current quarter +function getCurrentQuarterDates() { + const now = luxon.utc(); + const startOfQuarter = now.startOf("quarter"); + const endOfQuarter = now.endOf("quarter"); + return { startOfQuarter, endOfQuarter }; +} + /** * Registers a CRON job that sends controllers reminder emails. */ @@ -106,22 +114,19 @@ async function checkControllerActivity() { * Checks for controllers that did not maintain activity and sends a removal email. */ async function checkControllersNeedingRemoval() { - const today = luxon.utc(); + const { startOfQuarter, endOfQuarter } = getCurrentQuarterDates(); try { const usersNeedingRemovalWarningCheck = await User.find({ member: true, - removalWarningDeliveryDate: { $lte: today }, + removalWarningDeliveryDate: { $lte: endOfQuarter }, }); usersNeedingRemovalWarningCheck.forEach(async (user) => { - const minActivityDate = luxon - .fromJSDate(user.removalWarningDeliveryDate) - .minus({ days: activityWindowInDays - 1 }); const userHourSums = await ControllerHours.aggregate([ { $match: { - timeStart: { $gt: minActivityDate }, + timeStart: { $gt: startOfQuarter }, cid: user.cid, }, }, @@ -146,7 +151,7 @@ async function checkControllersNeedingRemoval() { userHourSums && userHourSums.length > 0 ? userHourSums[0].total : 0; const userTrainingRequestCount = await TrainingRequest.count({ studentCid: user.cid, - startTime: { $gt: minActivityDate }, + startTime: { $gt: startOfQuarter }, }); await User.updateOne( @@ -161,7 +166,7 @@ async function checkControllersNeedingRemoval() { user, userTotalHoursInPeriod, userTrainingRequestCount, - minActivityDate + startOfQuarter ) ) { transporter.sendMail({ From dc7d92754d6e3eced1b7af91778dce75925010be Mon Sep 17 00:00:00 2001 From: XDerpingxGruntX <41699998+XDerpingxGruntX@users.noreply.github.com> Date: Wed, 16 Oct 2024 19:07:54 -0600 Subject: [PATCH 10/10] ATM role update API --- controllers/ControllerController.js | 24 ++++++++++++------------ controllers/EventController.js | 16 ++++++++-------- controllers/FeedbackController.js | 8 ++++---- controllers/FileController.js | 12 ++++++------ controllers/NewsController.js | 6 +++--- controllers/StatsController.js | 6 +++--- controllers/TrainingController.js | 20 ++++++++++---------- helpers/controllerActivityHelper.js | 2 +- seeds/roles.json | 2 +- 9 files changed, 48 insertions(+), 48 deletions(-) diff --git a/controllers/ControllerController.js b/controllers/ControllerController.js index ddcc669..91a7a45 100644 --- a/controllers/ControllerController.js +++ b/controllers/ControllerController.js @@ -121,7 +121,7 @@ router.get("/staff", async (req, res) => { const staff = { atm: { title: "Air Traffic Manager", - code: "atm", + code: "atm1", email: "zab-atm", users: [], }, @@ -219,7 +219,7 @@ router.get("/oi", async (req, res) => { return res.json(res.stdRes); }); -router.get("/visit", getUser, auth(["atm", "datm"]), async ({ res }) => { +router.get("/visit", getUser, auth(["atm1", "datm"]), async ({ res }) => { try { const applications = await VisitApplication.find({ deletedAt: null, @@ -234,7 +234,7 @@ router.get("/visit", getUser, auth(["atm", "datm"]), async ({ res }) => { return res.json(res.stdRes); }); -router.get("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { +router.get("/absence", getUser, auth(["atm1", "datm"]), async (req, res) => { try { const absences = await Absence.find({ expirationDate: { @@ -257,7 +257,7 @@ router.get("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { return res.json(res.stdRes); }); -router.post("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { +router.post("/absence", getUser, auth(["atm1", "datm"]), async (req, res) => { try { if ( !req.body || @@ -310,7 +310,7 @@ router.post("/absence", getUser, auth(["atm", "datm"]), async (req, res) => { router.delete( "/absence/:id", getUser, - auth(["atm", "datm"]), + auth(["atm1", "datm"]), async (req, res) => { try { if (!req.params.id) { @@ -340,7 +340,7 @@ router.delete( router.get( "/log", getUser, - auth(["atm", "datm", "ta", "fe", "ec", "wm"]), + auth(["atm1", "datm", "ta", "fe", "ec", "wm"]), async (req, res) => { const page = +req.query.page || 1; const limit = +req.query.limit || 20; @@ -524,7 +524,7 @@ router.post("/visit", getUser, async (req, res) => { }, }); await transporter.sendMail({ - to: "atm@zabartcc.org, datm@zabartcc.org", + to: "zab-atm@vatusa.net, zab-datm@vatusa.net", from: { name: "Albuquerque ARTCC", address: "noreply@zabartcc.org", @@ -564,7 +564,7 @@ router.get("/visit/status", getUser, async (req, res) => { return res.json(res.stdRes); }); -router.put("/visit/:cid", getUser, auth(["atm", "datm"]), async (req, res) => { +router.put("/visit/:cid", getUser, auth(["atm1", "datm"]), async (req, res) => { try { await VisitApplication.delete({ cid: req.params.cid }); @@ -617,7 +617,7 @@ router.put("/visit/:cid", getUser, auth(["atm", "datm"]), async (req, res) => { router.delete( "/visit/:cid", getUser, - auth(["atm", "datm"]), + auth(["atm1", "datm"]), async (req, res) => { try { await VisitApplication.delete({ cid: req.params.cid }); @@ -715,7 +715,7 @@ router.post("/:cid", microAuth, async (req, res) => { ]; await transporter.sendMail({ - to: "atm@zabartcc.org; datm@zabartcc.org; ta@zabartcc.org", + to: "zab-atm@vatusa.net; zab-datm@vatusa.net; zab-ta@vatusa.net", from: { name: "Albuquerque ARTCC", address: "noreply@zabartcc.org", @@ -824,7 +824,7 @@ router.put("/:cid/visit", microAuth, async (req, res) => { router.put( "/:cid", getUser, - auth(["atm", "datm", "ta", "wm", "ins"]), + auth(["atm1", "datm", "ta", "wm", "ins"]), async (req, res) => { try { if (!req.body.form) { @@ -895,7 +895,7 @@ router.put( } ); -router.delete("/:cid", getUser, auth(["atm", "datm"]), async (req, res) => { +router.delete("/:cid", getUser, auth(["atm1", "datm"]), async (req, res) => { try { if (!req.body.reason) { throw { diff --git a/controllers/EventController.js b/controllers/EventController.js index f593f1f..1df34e4 100644 --- a/controllers/EventController.js +++ b/controllers/EventController.js @@ -178,7 +178,7 @@ router.delete('/:slug/signup', getUser, async (req, res) => { return res.json(res.stdRes); }); -router.delete('/:slug/mandelete/:cid', getUser, auth(['atm', 'datm', 'ec']), async(req, res) => { +router.delete('/:slug/mandelete/:cid', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async(req, res) => { try { const signup = await Event.findOneAndUpdate({url: req.params.slug}, { $pull: { @@ -211,7 +211,7 @@ router.delete('/:slug/mandelete/:cid', getUser, auth(['atm', 'datm', 'ec']), asy return res.json(res.stdRes); }); -router.put('/:slug/mansignup/:cid', getUser, auth(['atm', 'datm', 'ec']), async (req, res) => { +router.put('/:slug/mansignup/:cid', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async (req, res) => { try { const user = await User.findOne({cid: req.params.cid}); if(user !== null) { @@ -242,7 +242,7 @@ router.put('/:slug/mansignup/:cid', getUser, auth(['atm', 'datm', 'ec']), async return res.json(res.stdRes); }); -router.post('/', getUser, auth(['atm', 'datm', 'ec']), upload.single('banner'), async (req, res) => { +router.post('/', getUser, auth(['atm1', 'datm', 'ec', 'wm']), upload.single('banner'), async (req, res) => { try { const url = req.body.name.replace(/\s+/g, '-').toLowerCase().replace(/^-+|-+(?=-|$)/g, '').replace(/[^a-zA-Z0-9-_]/g, '') + '-' + Date.now().toString().slice(-5); const allowedTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif']; @@ -296,7 +296,7 @@ router.post('/', getUser, auth(['atm', 'datm', 'ec']), upload.single('banner'), return res.json(res.stdRes); }); -router.put('/:slug', getUser, auth(['atm', 'datm', 'ec']), upload.single('banner'), async (req, res) => { +router.put('/:slug', getUser, auth(['atm1', 'datm', 'ec', 'wm']), upload.single('banner'), async (req, res) => { try { const event = await Event.findOne({url: req.params.slug}); const {name, description, startTime, endTime, positions} = req.body; @@ -408,7 +408,7 @@ router.put('/:slug', getUser, auth(['atm', 'datm', 'ec']), upload.single('banner return res.json(res.stdRes); }); -router.delete('/:slug', getUser, auth(['atm', 'datm', 'ec']), async (req, res) => { +router.delete('/:slug', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async (req, res) => { try { const deleteEvent = await Event.findOne({url: req.params.slug}); await deleteEvent.delete(); @@ -448,7 +448,7 @@ router.delete('/:slug', getUser, auth(['atm', 'datm', 'ec']), async (req, res) = // return res.json(res.stdRes); // }); -router.put('/:slug/assign', getUser, auth(['atm', 'datm', 'ec']), async (req, res) => { +router.put('/:slug/assign', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async (req, res) => { try { const {position, cid} = req.body; @@ -484,7 +484,7 @@ router.put('/:slug/assign', getUser, auth(['atm', 'datm', 'ec']), async (req, re return res.json(res.stdRes); }); -router.put('/:slug/notify', getUser, auth(['atm', 'datm', 'ec']), async (req, res) => { +router.put('/:slug/notify', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async (req, res) => { try { await Event.updateOne({url: req.params.slug}, { $set: { @@ -524,7 +524,7 @@ router.put('/:slug/notify', getUser, auth(['atm', 'datm', 'ec']), async (req, re return res.json(res.stdRes); }); -router.put('/:slug/close', getUser, auth(['atm', 'datm', 'ec']), async (req, res) => { +router.put('/:slug/close', getUser, auth(['atm1', 'datm', 'ec', 'wm']), async (req, res) => { try { await Event.updateOne({url: req.params.slug}, { $set: { diff --git a/controllers/FeedbackController.js b/controllers/FeedbackController.js index 2eba6ed..869bb83 100644 --- a/controllers/FeedbackController.js +++ b/controllers/FeedbackController.js @@ -6,7 +6,7 @@ import Notification from '../models/Notification.js'; import getUser from '../middleware/getUser.js'; import auth from '../middleware/auth.js'; -router.get('/', getUser, auth(['atm', 'datm', 'ta', 'ec']), async (req, res) => { // All feedback +router.get('/', getUser, auth(['atm1', 'datm', 'ta', 'ec','wm']), async (req, res) => { // All feedback try { const page = +req.query.page || 1; const limit = +req.query.limit || 20; @@ -85,7 +85,7 @@ router.get('/controllers', async ({res}) => { // Controller list on feedback pag return res.json(res.stdRes); }); -router.get('/unapproved', getUser, auth(['atm', 'datm', 'ta', 'ec']), async ({res}) => { // Get all unapproved feedback +router.get('/unapproved', getUser, auth(['atm1', 'datm', 'ta', 'ec', 'wm']), async ({res}) => { // Get all unapproved feedback try { const feedback = await Feedback.find({deletedAt: null, approved: false}).populate('controller', 'fname lname cid').sort({createdAt: 'desc'}).lean(); res.stdRes.data = feedback; @@ -96,7 +96,7 @@ router.get('/unapproved', getUser, auth(['atm', 'datm', 'ta', 'ec']), async ({re return res.json(res.stdRes); }); -router.put('/approve/:id', getUser, auth(['atm', 'datm', 'ta']), async (req, res) => { // Approve feedback +router.put('/approve/:id', getUser, auth(['atm1', 'datm', 'ta']), async (req, res) => { // Approve feedback try { const approved = await Feedback.findOneAndUpdate({_id: req.params.id}, { approved: true @@ -123,7 +123,7 @@ router.put('/approve/:id', getUser, auth(['atm', 'datm', 'ta']), async (req, res return res.json(res.stdRes); }); -router.put('/reject/:id', getUser, auth(['atm', 'datm', 'ta']), async (req, res) => { // Reject feedback +router.put('/reject/:id', getUser, auth(['atm1', 'datm', 'ta']), async (req, res) => { // Reject feedback try { const feedback = await Feedback.findOne({_id: req.params.id}); await feedback.delete(); diff --git a/controllers/FileController.js b/controllers/FileController.js index d04c5fa..6cfe825 100644 --- a/controllers/FileController.js +++ b/controllers/FileController.js @@ -50,7 +50,7 @@ router.get('/downloads/:id', async (req, res) => { return res.json(res.stdRes); }); -router.post('/downloads', getUser, auth(['atm', 'datm', 'ta', 'fe']), upload.single('download'), async (req, res) => { +router.post('/downloads', getUser, auth(['atm1', 'datm', 'ta', 'fe']), upload.single('download'), async (req, res) => { try { if(!req.body.category) { throw { @@ -95,7 +95,7 @@ router.post('/downloads', getUser, auth(['atm', 'datm', 'ta', 'fe']), upload.sin return res.json(res.stdRes); }); -router.put('/downloads/:id', upload.single('download'), getUser, auth(['atm', 'datm', 'ta', 'fe']), async (req, res) => { +router.put('/downloads/:id', upload.single('download'), getUser, auth(['atm1', 'datm', 'ta', 'fe']), async (req, res) => { try { if(!req.file) { // no updated file provided await Downloads.findByIdAndUpdate(req.params.id, { @@ -140,7 +140,7 @@ router.put('/downloads/:id', upload.single('download'), getUser, auth(['atm', 'd return res.json(res.stdRes); }); -router.delete('/downloads/:id', getUser, auth(['atm', 'datm', 'ta', 'fe']), async (req, res) => { +router.delete('/downloads/:id', getUser, auth(['atm1', 'datm', 'ta', 'fe']), async (req, res) => { try { const download = await Downloads.findByIdAndDelete(req.params.id).lean(); await req.app.dossier.create({ @@ -181,7 +181,7 @@ router.get('/documents/:slug', async (req, res) => { return res.json(res.stdRes); }); -router.post('/documents', getUser, auth(['atm', 'datm', 'ta', 'fe']), upload.single('download'), async (req, res) => { +router.post('/documents', getUser, auth(['atm1', 'datm', 'ta', 'fe']), upload.single('download'), async (req, res) => { try { const {name, category, description, content, type} = req.body; if(!category) { @@ -252,7 +252,7 @@ router.post('/documents', getUser, auth(['atm', 'datm', 'ta', 'fe']), upload.sin return res.json(res.stdRes); }); -router.put('/documents/:slug', upload.single('download'), getUser, auth(['atm', 'datm', 'ta', 'fe']), async (req, res) => { +router.put('/documents/:slug', upload.single('download'), getUser, auth(['atm1', 'datm', 'ta', 'fe']), async (req, res) => { try { const document = await Document.findOne({slug: req.params.slug}); const {name, category, description, content, type} = req.body; @@ -318,7 +318,7 @@ router.put('/documents/:slug', upload.single('download'), getUser, auth(['atm', return res.json(res.stdRes); }) -router.delete('/documents/:id', getUser, auth(['atm', 'datm', 'ta', 'fe']), async (req, res) => { +router.delete('/documents/:id', getUser, auth(['atm1', 'datm', 'ta', 'fe']), async (req, res) => { try { const doc = await Document.findByIdAndDelete(req.params.id); await req.app.dossier.create({ diff --git a/controllers/NewsController.js b/controllers/NewsController.js index 1706e0f..6db8219 100644 --- a/controllers/NewsController.js +++ b/controllers/NewsController.js @@ -18,7 +18,7 @@ router.get('/', async (req, res) => { return res.json(res.stdRes); }); -router.post('/', getUser, auth(['atm', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) => { +router.post('/', getUser, auth(['atm1', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) => { try { if(!req.body || !req.body.title || !req.body.content) { throw { @@ -74,7 +74,7 @@ router.get('/:slug', async (req, res) =>{ return res.json(res.stdRes); }); -router.put('/:slug', getUser, auth(['atm', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) => { +router.put('/:slug', getUser, auth(['atm1', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) => { try { const {title, content} = req.body; const newsItem = await News.findOne({uriSlug: req.params.slug}); @@ -98,7 +98,7 @@ router.put('/:slug', getUser, auth(['atm', 'datm', 'ta', 'ec', 'fe', 'wm']), asy return res.json(res.stdRes); }); -router.delete('/:slug', getUser, auth(['atm', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) =>{ +router.delete('/:slug', getUser, auth(['atm1', 'datm', 'ta', 'ec', 'fe', 'wm']), async (req, res) =>{ try { const newsItem = await News.findOne({uriSlug: req.params.slug}); const status = await newsItem.delete(); diff --git a/controllers/StatsController.js b/controllers/StatsController.js index 40cd7d3..07d49fd 100644 --- a/controllers/StatsController.js +++ b/controllers/StatsController.js @@ -16,7 +16,7 @@ import { DateTime as L } from 'luxon'; const months = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const ratings = ["Unknown", "OBS", "S1", "S2", "S3", "C1", "C2", "C3", "I1", "I2", "I3", "SUP", "ADM"]; -router.get('/admin', getUser, auth(['atm', 'datm', 'ta', 'fe', 'ec', 'wm']), async (req, res) => { +router.get('/admin', getUser, auth(['atm1', 'datm', 'ta', 'fe', 'ec', 'wm']), async (req, res) => { try { const d = new Date(); const thisMonth = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); @@ -117,7 +117,7 @@ router.get('/admin', getUser, auth(['atm', 'datm', 'ta', 'fe', 'ec', 'wm']), asy return res.json(res.stdRes); }) -router.get('/ins', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { +router.get('/ins', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { try { let lastTraining = await TrainingSession.aggregate([ {$group: { @@ -170,7 +170,7 @@ router.get('/ins', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (re return res.json(res.stdRes); }) -router.get('/activity', getUser, auth(['atm', 'datm', 'ta', 'wm']), async (req, res) => { +router.get('/activity', getUser, auth(['atm1', 'datm', 'ta', 'wm']), async (req, res) => { try { const today = L.utc(); const chkDate = today.minus({days: 91}); diff --git a/controllers/TrainingController.js b/controllers/TrainingController.js index 02fb734..1388a31 100644 --- a/controllers/TrainingController.js +++ b/controllers/TrainingController.js @@ -129,7 +129,7 @@ router.get('/milestones', getUser, async (req, res) => { return res.json(res.stdRes); }); -router.get('/request/open', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { +router.get('/request/open', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { try { const days = +req.query.period || 21; // days from start of CURRENT week const d = new Date(Date.now()), @@ -155,7 +155,7 @@ router.get('/request/open', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), return res.json(res.stdRes); }); -router.post('/request/take/:id', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { +router.post('/request/take/:id', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { try { if(new Date(req.body.startTime) >= new Date(req.body.endTime)) { throw { @@ -206,7 +206,7 @@ router.post('/request/take/:id', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr return res.json(res.stdRes); }); -router.delete('/request/:id', getUser, auth(['atm', 'datm', 'ta']), async (req, res) => { +router.delete('/request/:id', getUser, auth(['atm1', 'datm', 'ta']), async (req, res) => { try { const request = await TrainingRequest.findById(req.params.id); request.delete(); @@ -224,7 +224,7 @@ router.delete('/request/:id', getUser, auth(['atm', 'datm', 'ta']), async (req, return res.json(res.stdRes); }); -router.get('/request/:date', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { +router.get('/request/:date', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { try { const d = new Date(`${req.params.date.slice(0,4)}-${req.params.date.slice(4,6)}-${req.params.date.slice(6,8)}`); const dayAfter = new Date(d); @@ -248,7 +248,7 @@ router.get('/request/:date', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), return res.json(res.stdRes); }); -router.get('/session/open', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { +router.get('/session/open', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async (req, res) => { try { const sessions = await TrainingSession.find({ instructorCid: res.user.cid, @@ -266,7 +266,7 @@ router.get('/session/open', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), router.get('/session/:id', getUser, async(req, res) => { try { - const isIns = ['ta', 'ins', 'mtr', 'atm', 'datm'].some(r => res.user.roleCodes.includes(r)); + const isIns = ['ta', 'ins', 'mtr', 'atm1', 'datm'].some(r => res.user.roleCodes.includes(r)); if(isIns) { const session = await TrainingSession.findById( @@ -303,7 +303,7 @@ router.get('/session/:id', getUser, async(req, res) => { return res.json(res.stdRes); }); -router.get('/sessions', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { +router.get('/sessions', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { try { const page = +req.query.page || 1; const limit = +req.query.limit || 20; @@ -404,7 +404,7 @@ router.get('/sessions/past', getUser, async (req, res) => { return res.json(res.stdRes); }); -router.get('/sessions/:cid', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { +router.get('/sessions/:cid', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { try { const controller = await User.findOne({cid: req.params.cid}).select('fname lname').lean(); if(!controller) { @@ -441,7 +441,7 @@ router.get('/sessions/:cid', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), return res.json(res.stdRes); }); -router.put('/session/save/:id', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { +router.put('/session/save/:id', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { try { await TrainingSession.findByIdAndUpdate(req.params.id, req.body); } catch(e) { @@ -452,7 +452,7 @@ router.put('/session/save/:id', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr' return res.json(res.stdRes); }); -router.put('/session/submit/:id', getUser, auth(['atm', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { +router.put('/session/submit/:id', getUser, auth(['atm1', 'datm', 'ta', 'ins', 'mtr']), async(req, res) => { try { if(req.body.position === '' || req.body.progress === null || req.body.movements === null || req.body.location === null || req.body.ots === null || req.body.studentNotes === null || (req.body.studentNotes && req.body.studentNotes.length > 3000) || (req.body.insNotes && req.body.insNotes.length > 3000)) { throw { diff --git a/helpers/controllerActivityHelper.js b/helpers/controllerActivityHelper.js index e37633d..7b91b7d 100644 --- a/helpers/controllerActivityHelper.js +++ b/helpers/controllerActivityHelper.js @@ -171,7 +171,7 @@ async function checkControllersNeedingRemoval() { ) { transporter.sendMail({ to: user.email, - cc: "datm@zabartcc.org", + cc: "zab-datm@vatusa.net", from: { name: "Albuquerque ARTCC", address: "noreply@zabartcc.org", diff --git a/seeds/roles.json b/seeds/roles.json index ffb26fc..089bcae 100644 --- a/seeds/roles.json +++ b/seeds/roles.json @@ -1,6 +1,6 @@ [{ "name": "Air Traffic Manager", - "code": "atm", + "code": "atm1", "order": 1, "class": "senior" },{