diff --git a/controllers/ControllerController.js b/controllers/ControllerController.js index 5a2cbe0..049f575 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: "zab-atm@vatusa.net; zab-datm@vatusa.net; zab-ta@vatusa.net", + 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..7b91b7d 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,180 @@ 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"; +// 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. */ 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 { startOfQuarter, endOfQuarter } = getCurrentQuarterDates(); - try { - const usersNeedingRemovalWarningCheck = await User.find( - { - member: true, - removalWarningDeliveryDate: { $lte: today } - }); + try { + const usersNeedingRemovalWarningCheck = await User.find({ + member: true, + 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 }, - 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 userHourSums = await ControllerHours.aggregate([ + { + $match: { + timeStart: { $gt: startOfQuarter }, + 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: startOfQuarter }, + }); - 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, + startOfQuarter + ) + ) { + transporter.sendMail({ + to: user.email, + cc: "zab-datm@vatusa.net", + 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 +197,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 +286,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, +};