From bef6458bb262a96ddbe9b4f9e1ed532304f745fb Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Sat, 2 Aug 2025 20:51:05 -0400 Subject: [PATCH 1/6] feat: add OPT analytics controller, model, and routes --- src/controllers/optAnalyticsController.js | 36 +++++++++++++++++++++++ src/models/CandidateOPTStatus.js | 23 +++++++++++++++ src/routes/optanalyticsRoutes.js | 10 +++++++ src/startup/routes.js | 4 ++- 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/controllers/optAnalyticsController.js create mode 100644 src/models/CandidateOPTStatus.js create mode 100644 src/routes/optanalyticsRoutes.js diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js new file mode 100644 index 000000000..53773aa56 --- /dev/null +++ b/src/controllers/optAnalyticsController.js @@ -0,0 +1,36 @@ +const CandidateOPTStatus = require('../models/CandidateOPTStatus'); + +module.exports = () => ({ + getOPTStatusBreakdown: async (req, res, next) => { + try { + console.log(req.query); + console.log('in getOPTStatusBreakdown'); + const { startDate, endDate, role } = req.query; + const query = {}; + if (startDate || endDate) { + query.applicationDate = {}; + if (startDate) query.applicationDate.$gte = new Date(startDate); + if (endDate) query.applicationDate.$lte = new Date(endDate); + } + if (role) query.role = role; + const result = await CandidateOPTStatus.find(query); + const totalCandidates = result.length; + const breakDownMap = {}; + result.forEach((candidate) => { + const { optStatus } = candidate; + breakDownMap[optStatus] = (breakDownMap[optStatus] || 0) + 1; + }); + const breakDown = Object.entries(breakDownMap).map(([optStatus, count]) => ({ + optStatus, + count, + percentage: parseFloat(((count / totalCandidates) * 100).toFixed(2)), + })); + res.json({ + totalCandidates, + breakDown, + }); + } catch (err) { + next(err); + } + }, +}); diff --git a/src/models/CandidateOPTStatus.js b/src/models/CandidateOPTStatus.js new file mode 100644 index 000000000..c1e61fbe6 --- /dev/null +++ b/src/models/CandidateOPTStatus.js @@ -0,0 +1,23 @@ +const mongoose = require('mongoose'); + +const CandidateOPTStatusSchema = new mongoose.Schema({ + candidateId: { + type: Number, + required: true, + }, + role: { + type: String, + required: true, + }, + optStatus: { + type: String, + enum: ['OPT started', 'CPT not eligible', 'Citizen', 'OPT not yet started', 'N/A'], + required: true, + }, + applicationDate: { + type: Date, + required: true, + }, +}); + +module.exports = mongoose.model('CandidateOPTStatus', CandidateOPTStatusSchema); diff --git a/src/routes/optanalyticsRoutes.js b/src/routes/optanalyticsRoutes.js new file mode 100644 index 000000000..c9d111c2d --- /dev/null +++ b/src/routes/optanalyticsRoutes.js @@ -0,0 +1,10 @@ +const express = require('express'); + +module.exports = function () { + console.log('in opt analytics routes'); + const router = express.Router(); + const optAnalyticsController = require('../controllers/optAnalyticsController')(); + router.get('/opt-status', optAnalyticsController.getOPTStatusBreakdown); + + return router; +}; diff --git a/src/startup/routes.js b/src/startup/routes.js index b828af6f8..9c46e8afc 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -351,7 +351,7 @@ const bidNotificationsRouter = require('../routes/lbdashboard/bidNotificationsRo const bidDeadlinesRouter = require('../routes/lbdashboard/bidDeadlinesRouter'); const SMSRouter = require('../routes/lbdashboard/SMSRouter')(); -const applicantVolunteerRatioRouter = require('../routes/applicantVolunteerRatioRouter'); +const analyticsRouter = require('../routes/optanalyticsRoutes')(); const applicationRoutes = require('../routes/applications'); const announcementRouter = require('../routes/announcementRouter')(); @@ -565,4 +565,6 @@ module.exports = function (app) { app.use('/api', materialCostRouter); app.use('/api/lp', lessonPlanSubmissionRouter); + + app.use('/api/analytics', analyticsRouter); }; From ac0ab77bd14b4a7a46a3568a705a375c1333515a Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Sat, 16 Aug 2025 14:22:04 -0400 Subject: [PATCH 2/6] fix: routes issue and debug logs removal --- src/controllers/optAnalyticsController.js | 2 -- src/routes/optanalyticsRoutes.js | 1 - src/startup/routes.js | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js index 53773aa56..c9e61bf29 100644 --- a/src/controllers/optAnalyticsController.js +++ b/src/controllers/optAnalyticsController.js @@ -3,8 +3,6 @@ const CandidateOPTStatus = require('../models/CandidateOPTStatus'); module.exports = () => ({ getOPTStatusBreakdown: async (req, res, next) => { try { - console.log(req.query); - console.log('in getOPTStatusBreakdown'); const { startDate, endDate, role } = req.query; const query = {}; if (startDate || endDate) { diff --git a/src/routes/optanalyticsRoutes.js b/src/routes/optanalyticsRoutes.js index c9d111c2d..3ac85f555 100644 --- a/src/routes/optanalyticsRoutes.js +++ b/src/routes/optanalyticsRoutes.js @@ -1,7 +1,6 @@ const express = require('express'); module.exports = function () { - console.log('in opt analytics routes'); const router = express.Router(); const optAnalyticsController = require('../controllers/optAnalyticsController')(); router.get('/opt-status', optAnalyticsController.getOPTStatusBreakdown); diff --git a/src/startup/routes.js b/src/startup/routes.js index 9c46e8afc..3047d9078 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -337,7 +337,7 @@ console.log('PlannedCostRouter loaded:', plannedCostRouter ? 'success' : 'failed const projectCostRouter = require('../routes/bmdashboard/projectCostRouter')(projectCost); const tagRouter = require('../routes/tagRouter')(tag); -const educationTaskRouter = require('../routes/educationTaskRouter'); +const applicantVolunteerRatioRouter = require('../routes/applicantVolunteerRatioRouter');const educationTaskRouter = require('../routes/educationTaskRouter'); const educatorRouter = require('../routes/educatorRouter'); const atomRouter = require('../routes/atomRouter'); const intermediateTaskRouter = require('../routes/intermediateTaskRouter'); From 888b65970c5e91a1bf0b7286b2995c01749d8484 Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Fri, 26 Dec 2025 23:46:14 -0500 Subject: [PATCH 3/6] fix(analytics): add validation for date range filters in OPT status API --- src/controllers/optAnalyticsController.js | 59 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js index c9e61bf29..3278acea9 100644 --- a/src/controllers/optAnalyticsController.js +++ b/src/controllers/optAnalyticsController.js @@ -5,25 +5,68 @@ module.exports = () => ({ try { const { startDate, endDate, role } = req.query; const query = {}; - if (startDate || endDate) { + + let start; + let end; + + if (startDate) { + start = new Date(startDate); + if (Number.isNaN(start.getTime())) { + return res.status(400).json({ + message: 'Invalid startDate. Use ISO format (YYYY-MM-DD).', + }); + } + } + + if (endDate) { + end = new Date(endDate); + if (Number.isNaN(end.getTime())) { + return res.status(400).json({ + message: 'Invalid endDate. Use ISO format (YYYY-MM-DD).', + }); + } + } + + if (start && end && start > end) { + return res.status(400).json({ + message: 'startDate cannot be greater than endDate.', + }); + } + + if (start || end) { query.applicationDate = {}; - if (startDate) query.applicationDate.$gte = new Date(startDate); - if (endDate) query.applicationDate.$lte = new Date(endDate); + if (start) query.applicationDate.$gte = start; + if (end) query.applicationDate.$lte = end; } - if (role) query.role = role; + + if (role) { + query.role = role; + } + const result = await CandidateOPTStatus.find(query); + + if (!result.length) { + return res.json({ + totalCandidates: 0, + breakDown: [], + message: 'No records found for the given filters.', + }); + } + const totalCandidates = result.length; const breakDownMap = {}; - result.forEach((candidate) => { - const { optStatus } = candidate; + + result.forEach(({ optStatus }) => { breakDownMap[optStatus] = (breakDownMap[optStatus] || 0) + 1; }); + const breakDown = Object.entries(breakDownMap).map(([optStatus, count]) => ({ optStatus, count, - percentage: parseFloat(((count / totalCandidates) * 100).toFixed(2)), + percentage: Number(((count / totalCandidates) * 100).toFixed(2)), })); - res.json({ + + return res.json({ totalCandidates, breakDown, }); From c6c73be00b3727a6829822fa085bf8531c850804 Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Sat, 24 Jan 2026 18:16:40 -0500 Subject: [PATCH 4/6] fix(analytics): enforce ISO date format and date range validation --- src/controllers/optAnalyticsController.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js index 3278acea9..2be09364b 100644 --- a/src/controllers/optAnalyticsController.js +++ b/src/controllers/optAnalyticsController.js @@ -1,5 +1,12 @@ const CandidateOPTStatus = require('../models/CandidateOPTStatus'); +const isValidDate = (date) => { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) return false; + const parsedDate = new Date(date); + return !Number.isNaN(parsedDate.getTime()); +}; + module.exports = () => ({ getOPTStatusBreakdown: async (req, res, next) => { try { @@ -10,6 +17,11 @@ module.exports = () => ({ let end; if (startDate) { + if (!isValidDate(startDate)) { + return res.status(400).json({ + message: 'Invalid startDate. Use ISO format (YYYY-MM-DD).', + }); + } start = new Date(startDate); if (Number.isNaN(start.getTime())) { return res.status(400).json({ @@ -19,6 +31,11 @@ module.exports = () => ({ } if (endDate) { + if (!isValidDate(endDate)) { + return res.status(400).json({ + message: 'Invalid endDate. Use ISO format (YYYY-MM-DD).', + }); + } end = new Date(endDate); if (Number.isNaN(end.getTime())) { return res.status(400).json({ @@ -33,6 +50,12 @@ module.exports = () => ({ }); } + if (startDate && endDate && start.toDateString() === end.toDateString()) { + return res.status(400).json({ + message: 'startDate and endDate cannot be the same.', + }); + } + if (start || end) { query.applicationDate = {}; if (start) query.applicationDate.$gte = start; From 31876c6590e4f1e9259dc50b9528de4bf7bdf3c6 Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Sat, 7 Mar 2026 17:17:51 -0500 Subject: [PATCH 5/6] fix: resolve merge conflicts --- package-lock.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 47c641aab..a045f5ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1390,7 +1390,6 @@ "node_modules/@babel/core": { "version": "7.28.5", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4153,7 +4152,6 @@ "node_modules/@redis/client": { "version": "1.6.1", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -5229,7 +5227,6 @@ "node_modules/@types/node-fetch": { "version": "2.6.13", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -5374,7 +5371,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5400,7 +5396,6 @@ "node_modules/ajv": { "version": "6.12.6", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6505,7 +6500,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7574,8 +7568,7 @@ "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -8179,7 +8172,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8343,7 +8335,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8395,7 +8386,6 @@ "version": "6.10.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -8424,7 +8414,6 @@ "version": "7.37.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8456,7 +8445,6 @@ "version": "4.6.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -17455,7 +17443,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, From 4a540b9d8b93651b925fd2c07595b082b457d526 Mon Sep 17 00:00:00 2001 From: ALISHA WALUNJ Date: Sat, 7 Mar 2026 23:43:15 -0500 Subject: [PATCH 6/6] fix: resolve SonarQube issues in optAnalyticsController and optanalyticsRoutes --- src/controllers/optAnalyticsController.js | 165 ++++++++++++---------- src/routes/optanalyticsRoutes.js | 3 +- 2 files changed, 88 insertions(+), 80 deletions(-) diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js index 2be09364b..90fcf1e20 100644 --- a/src/controllers/optAnalyticsController.js +++ b/src/controllers/optAnalyticsController.js @@ -7,94 +7,103 @@ const isValidDate = (date) => { return !Number.isNaN(parsedDate.getTime()); }; -module.exports = () => ({ - getOPTStatusBreakdown: async (req, res, next) => { - try { - const { startDate, endDate, role } = req.query; - const query = {}; +const parseDateParam = (dateStr, fieldName, res) => { + if (!isValidDate(dateStr)) { + res.status(400).json({ message: `Invalid ${fieldName}. Use ISO format (YYYY-MM-DD).` }); + return null; + } + const parsed = new Date(dateStr); + if (Number.isNaN(parsed.getTime())) { + res.status(400).json({ message: `Invalid ${fieldName}. Use ISO format (YYYY-MM-DD).` }); + return null; + } + return parsed; +}; - let start; - let end; +const validateDateRange = (start, end, startDate, endDate, res) => { + if (start && end && start > end) { + res.status(400).json({ message: 'startDate cannot be greater than endDate.' }); + return false; + } + if (startDate && endDate && start.toDateString() === end.toDateString()) { + res.status(400).json({ message: 'startDate and endDate cannot be the same.' }); + return false; + } + return true; +}; - if (startDate) { - if (!isValidDate(startDate)) { - return res.status(400).json({ - message: 'Invalid startDate. Use ISO format (YYYY-MM-DD).', - }); - } - start = new Date(startDate); - if (Number.isNaN(start.getTime())) { - return res.status(400).json({ - message: 'Invalid startDate. Use ISO format (YYYY-MM-DD).', - }); - } - } +const sanitizeRole = (role) => { + if (!role) return null; + const str = String(role); + return str.replace(/[^a-zA-Z0-9 _-]/g, ''); +}; - if (endDate) { - if (!isValidDate(endDate)) { - return res.status(400).json({ - message: 'Invalid endDate. Use ISO format (YYYY-MM-DD).', - }); - } - end = new Date(endDate); - if (Number.isNaN(end.getTime())) { - return res.status(400).json({ - message: 'Invalid endDate. Use ISO format (YYYY-MM-DD).', - }); - } - } +const buildQuery = (start, end, role) => { + const query = {}; + if (start || end) { + query.applicationDate = {}; + if (start) query.applicationDate.$gte = start; + if (end) query.applicationDate.$lte = end; + } + const safeRole = sanitizeRole(role); + if (safeRole) { + query.role = safeRole; + } + return query; +}; - if (start && end && start > end) { - return res.status(400).json({ - message: 'startDate cannot be greater than endDate.', - }); - } +const computeBreakdown = (result) => { + const totalCandidates = result.length; + const breakDownMap = {}; + result.forEach(({ optStatus }) => { + breakDownMap[optStatus] = (breakDownMap[optStatus] || 0) + 1; + }); + const breakDown = Object.entries(breakDownMap).map(([optStatus, count]) => ({ + optStatus, + count, + percentage: Number(((count / totalCandidates) * 100).toFixed(2)), + })); + return { totalCandidates, breakDown }; +}; - if (startDate && endDate && start.toDateString() === end.toDateString()) { - return res.status(400).json({ - message: 'startDate and endDate cannot be the same.', - }); - } +module.exports = function optAnalyticsController() { + return { + getOPTStatusBreakdown: async function getOPTStatusBreakdown(req, res, next) { + try { + const { startDate, endDate, role } = req.query; - if (start || end) { - query.applicationDate = {}; - if (start) query.applicationDate.$gte = start; - if (end) query.applicationDate.$lte = end; - } + let start; + let end; - if (role) { - query.role = role; - } + if (startDate) { + start = parseDateParam(startDate, 'startDate', res); + if (start === null) return null; + } - const result = await CandidateOPTStatus.find(query); + if (endDate) { + end = parseDateParam(endDate, 'endDate', res); + if (end === null) return null; + } - if (!result.length) { - return res.json({ - totalCandidates: 0, - breakDown: [], - message: 'No records found for the given filters.', - }); - } + if (!validateDateRange(start, end, startDate, endDate, res)) return null; - const totalCandidates = result.length; - const breakDownMap = {}; + const query = buildQuery(start, end, role); - result.forEach(({ optStatus }) => { - breakDownMap[optStatus] = (breakDownMap[optStatus] || 0) + 1; - }); + const result = await CandidateOPTStatus.find(query); - const breakDown = Object.entries(breakDownMap).map(([optStatus, count]) => ({ - optStatus, - count, - percentage: Number(((count / totalCandidates) * 100).toFixed(2)), - })); + if (!result.length) { + return res.json({ + totalCandidates: 0, + breakDown: [], + message: 'No records found for the given filters.', + }); + } - return res.json({ - totalCandidates, - breakDown, - }); - } catch (err) { - next(err); - } - }, -}); + return res.json(computeBreakdown(result)); + } catch (err) { + next(err); + } + return null; + }, + }; +}; diff --git a/src/routes/optanalyticsRoutes.js b/src/routes/optanalyticsRoutes.js index 3ac85f555..c4967bf77 100644 --- a/src/routes/optanalyticsRoutes.js +++ b/src/routes/optanalyticsRoutes.js @@ -1,9 +1,8 @@ const express = require('express'); -module.exports = function () { +module.exports = function optAnalyticsRoutes() { const router = express.Router(); const optAnalyticsController = require('../controllers/optAnalyticsController')(); router.get('/opt-status', optAnalyticsController.getOPTStatusBreakdown); - return router; };