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" }, diff --git a/src/controllers/optAnalyticsController.js b/src/controllers/optAnalyticsController.js new file mode 100644 index 000000000..90fcf1e20 --- /dev/null +++ b/src/controllers/optAnalyticsController.js @@ -0,0 +1,109 @@ +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()); +}; + +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; +}; + +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; +}; + +const sanitizeRole = (role) => { + if (!role) return null; + const str = String(role); + return str.replace(/[^a-zA-Z0-9 _-]/g, ''); +}; + +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; +}; + +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 }; +}; + +module.exports = function optAnalyticsController() { + return { + getOPTStatusBreakdown: async function getOPTStatusBreakdown(req, res, next) { + try { + const { startDate, endDate, role } = req.query; + + let start; + let end; + + if (startDate) { + start = parseDateParam(startDate, 'startDate', res); + if (start === null) return null; + } + + if (endDate) { + end = parseDateParam(endDate, 'endDate', res); + if (end === null) return null; + } + + if (!validateDateRange(start, end, startDate, endDate, res)) return null; + + const query = buildQuery(start, end, role); + + const result = await CandidateOPTStatus.find(query); + + if (!result.length) { + return res.json({ + totalCandidates: 0, + breakDown: [], + message: 'No records found for the given filters.', + }); + } + + return res.json(computeBreakdown(result)); + } catch (err) { + next(err); + } + return null; + }, + }; +}; 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..c4967bf77 --- /dev/null +++ b/src/routes/optanalyticsRoutes.js @@ -0,0 +1,8 @@ +const express = require('express'); + +module.exports = function optAnalyticsRoutes() { + 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..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'); @@ -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); };