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"
},{