diff --git a/src/device-registry/controllers/create-event.js b/src/device-registry/controllers/create-event.js index 38d99a108b..5c1fb30951 100644 --- a/src/device-registry/controllers/create-event.js +++ b/src/device-registry/controllers/create-event.js @@ -938,6 +938,58 @@ const createEvent = { return; } }, + listReadingAverages: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + + const request = { + ...req, + query: { + ...req.query, + tenant: isEmpty(req.query.tenant) ? "airqo" : req.query.tenant, + averages: "readings", + }, + }; + + const result = await createEventUtil.listReadingAverages(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + + const status = result.status || httpStatus.OK; + if (result.success === true) { + res.status(status).json({ + success: true, + message: result.message, + measurements: result.data, + }); + } else { + const errorStatus = result.status || httpStatus.INTERNAL_SERVER_ERROR; + res.status(errorStatus).json({ + success: false, + errors: result.errors || { message: "" }, + message: result.message, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, signalsForMap: async (req, res, next) => { try { logText("the signals for the AirQo Map..."); @@ -1195,6 +1247,90 @@ const createEvent = { return; } }, + listAverages: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + request.query.recent = "no"; + request.query.metadata = "site_id"; + request.query.averages = "events"; + request.query.brief = "yes"; + const { cohort_id, grid_id } = { ...req.query, ...req.params }; + + let locationErrors = 0; + + if (cohort_id) { + await processCohortIds(cohort_id, request); + if (isEmpty(request.query.device_id)) { + locationErrors++; + } + } else if (grid_id) { + await processGridIds(grid_id, request); + if (isEmpty(request.query.site_id)) { + locationErrors++; + } + } + + if (locationErrors === 0) { + const result = await createEventUtil.listAverages(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + + res.status(status).json({ + success: true, + isCache: result.isCache, + message: result.message, + measurements: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + const errors = result.errors ? result.errors : { message: "" }; + res.status(status).json({ + success: false, + errors, + message: result.message, + }); + } + } else { + res.status(httpStatus.BAD_REQUEST).json({ + success: false, + errors: { + message: `Unable to process measurements for the provided measurement IDs`, + }, + message: "Bad Request", + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, listHistorical: async (req, res, next) => { try { const errors = extractErrorsFromRequest(req); diff --git a/src/device-registry/models/Event.js b/src/device-registry/models/Event.js index 84d921a310..682031e718 100644 --- a/src/device-registry/models/Event.js +++ b/src/device-registry/models/Event.js @@ -2541,6 +2541,135 @@ eventSchema.statics.signal = async function(filter) { } }; +eventSchema.statics.getAirQualityAverages = async function(siteId, next) { + try { + // Get current date and date 2 weeks ago + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const twoWeeksAgo = new Date(today); + twoWeeksAgo.setDate(today.getDate() - 14); + + // Base query to match site and time range + const baseMatch = { + "values.site_id": mongoose.Types.ObjectId(siteId), + "values.time": { + $gte: twoWeeksAgo, + $lte: now, + }, + }; + + const result = await this.aggregate([ + // Unwind the values array to work with individual readings + { $unwind: "$values" }, + + // Match site and time range + { $match: baseMatch }, + + // Project only needed fields and add date fields for grouping + { + $project: { + time: "$values.time", + pm2_5: "$values.pm2_5.value", + dayOfYear: { $dayOfYear: "$values.time" }, + year: { $year: "$values.time" }, + week: { $week: "$values.time" }, + }, + }, + + // Group by day to get daily averages + { + $group: { + _id: { + year: "$year", + dayOfYear: "$dayOfYear", + }, + dailyAverage: { $avg: "$pm2_5" }, + date: { $first: "$time" }, + week: { $first: "$week" }, + }, + }, + + // Sort by date + { $sort: { date: -1 } }, + + // Group again to calculate weekly averages + { + $group: { + _id: "$week", + weeklyAverage: { $avg: "$dailyAverage" }, + days: { + $push: { + date: "$date", + average: "$dailyAverage", + }, + }, + }, + }, + + // Sort by week + { $sort: { _id: -1 } }, + + // Limit to get only last 2 weeks + { $limit: 2 }, + ]).allowDiskUse(true); + + // If we don't have enough data + if (result.length < 2) { + return { + success: false, + message: "Insufficient data for comparison", + status: httpStatus.NOT_FOUND, + }; + } + + // Get current week and previous week data + const currentWeek = result[0]; + const previousWeek = result[1]; + + // Calculate percentage difference + + const percentageDifference = + previousWeek.weeklyAverage !== 0 + ? ((currentWeek.weeklyAverage - previousWeek.weeklyAverage) / + previousWeek.weeklyAverage) * + 100 + : 0; + + // Get today's date string in YYYY-MM-DD format + const todayStr = today.toISOString().split("T")[0]; + + // Find today's average from the current week's days + const todayAverage = + currentWeek.days.find( + (day) => day.date.toISOString().split("T")[0] === todayStr + )?.average || null; + + return { + success: true, + data: { + dailyAverage: todayAverage ? parseFloat(todayAverage.toFixed(2)) : null, + percentageDifference: parseFloat(percentageDifference.toFixed(2)), + weeklyAverages: { + currentWeek: parseFloat(currentWeek.weeklyAverage.toFixed(2)), + previousWeek: parseFloat(previousWeek.weeklyAverage.toFixed(2)), + }, + }, + message: "Successfully retrieved air quality averages", + status: httpStatus.OK, + }; + } catch (error) { + logger.error( + `Internal Server Error --- getAirQualityAverages --- ${error.message}` + ); + logObject("error", error); + next( + new HttpError("Internal Server Error", httpStatus.INTERNAL_SERVER_ERROR, { + message: error.message, + }) + ); + } +}; + const eventsModel = (tenant) => { try { const events = mongoose.model("events"); diff --git a/src/device-registry/models/Reading.js b/src/device-registry/models/Reading.js index 2c707708d2..8e42727c89 100644 --- a/src/device-registry/models/Reading.js +++ b/src/device-registry/models/Reading.js @@ -297,7 +297,6 @@ ReadingsSchema.statics.recent = async function( return; } }; - ReadingsSchema.statics.getBestAirQualityLocations = async function( { threshold = 10, pollutant = "pm2_5", limit = 100, skip = 0 } = {}, next @@ -360,6 +359,344 @@ ReadingsSchema.statics.getBestAirQualityLocations = async function( return; } }; +ReadingsSchema.statics.getAirQualityAnalytics = async function(siteId, next) { + try { + // Validate input + if (!siteId) { + next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Site ID is required", + }) + ); + return; + } + + // Get current date boundaries + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Calculate time boundaries + const currentWeekStart = new Date(today); + currentWeekStart.setDate(currentWeekStart.getDate() - 7); + const lastWeekStart = new Date(currentWeekStart); + lastWeekStart.setDate(lastWeekStart.getDate() - 7); + const monthStart = new Date(today); + monthStart.setDate(1); + + // WHO Air Quality Guidelines (annual mean) + const WHO_GUIDELINES = { + pm2_5: 5, // μg/m³ + pm10: 15, // μg/m³ + no2: 10, // μg/m³ + }; + + // Define peak hours (e.g., morning and evening rush hours) + const PEAK_HOURS = { + morning: { start: 6, end: 9 }, + evening: { start: 16, end: 19 }, + }; + + // Pipeline for daily average and hourly breakdown + const dailyAnalysisPipeline = this.aggregate([ + { + $match: { + site_id: siteId, + time: { + $gte: today, + $lt: tomorrow, + }, + }, + }, + { + $group: { + _id: { $hour: "$time" }, + pm2_5_avg: { $avg: "$pm2_5.value" }, + pm10_avg: { $avg: "$pm10.value" }, + no2_avg: { $avg: "$no2.value" }, + count: { $sum: 1 }, + }, + }, + { + $sort: { _id: 1 }, + }, + ]); + + // Pipeline for monthly trend analysis + const monthlyTrendPipeline = this.aggregate([ + { + $match: { + site_id: siteId, + time: { + $gte: monthStart, + }, + }, + }, + { + $group: { + _id: { $dayOfMonth: "$time" }, + pm2_5_avg: { $avg: "$pm2_5.value" }, + pm10_avg: { $avg: "$pm10.value" }, + no2_avg: { $avg: "$no2.value" }, + }, + }, + { + $sort: { _id: 1 }, + }, + ]); + + // Pipeline for weekly comparisons + const weeklyComparisonPipeline = this.aggregate([ + { + $match: { + site_id: siteId, + time: { + $gte: lastWeekStart, + }, + }, + }, + { + $addFields: { + isCurrentWeek: { + $gte: ["$time", currentWeekStart], + }, + isPeakHour: { + $or: [ + { + $and: [ + { $gte: [{ $hour: "$time" }, PEAK_HOURS.morning.start] }, + { $lte: [{ $hour: "$time" }, PEAK_HOURS.morning.end] }, + ], + }, + { + $and: [ + { $gte: [{ $hour: "$time" }, PEAK_HOURS.evening.start] }, + { $lte: [{ $hour: "$time" }, PEAK_HOURS.evening.end] }, + ], + }, + ], + }, + }, + }, + { + $group: { + _id: { + isCurrentWeek: "$isCurrentWeek", + isPeakHour: "$isPeakHour", + }, + pm2_5_avg: { $avg: "$pm2_5.value" }, + pm10_avg: { $avg: "$pm10.value" }, + no2_avg: { $avg: "$no2.value" }, + readings_count: { $sum: 1 }, + }, + }, + ]); + + // Execute all pipelines + const [dailyAnalysis, monthlyTrend, weeklyComparison] = await Promise.all([ + dailyAnalysisPipeline, + monthlyTrendPipeline, + weeklyComparisonPipeline, + ]); + + // Process daily averages and identify peak pollution hours + const hourlyData = dailyAnalysis.reduce((acc, hour) => { + acc[hour._id] = { + pm2_5: hour.pm2_5_avg, + pm10: hour.pm10_avg, + no2: hour.no2_avg, + readings_count: hour.count, + }; + return acc; + }, {}); + + // Calculate peak pollution hours + const findPeakHours = (hourlyData) => { + const peaks = { + pm2_5: { hour: 0, value: 0 }, + pm10: { hour: 0, value: 0 }, + no2: { hour: 0, value: 0 }, + }; + + Object.entries(hourlyData).forEach(([hour, data]) => { + if (data.pm2_5 > peaks.pm2_5.value) { + peaks.pm2_5 = { hour: parseInt(hour), value: data.pm2_5 }; + } + if (data.pm10 > peaks.pm10.value) { + peaks.pm10 = { hour: parseInt(hour), value: data.pm10 }; + } + if (data.no2 > peaks.no2.value) { + peaks.no2 = { hour: parseInt(hour), value: data.no2 }; + } + }); + + return peaks; + }; + + // Calculate WHO guidelines compliance + const calculateCompliance = (averages) => { + return { + pm2_5: { + compliant: averages.pm2_5 <= WHO_GUIDELINES.pm2_5, + percentage: (averages.pm2_5 / WHO_GUIDELINES.pm2_5) * 100, + }, + pm10: { + compliant: averages.pm10 <= WHO_GUIDELINES.pm10, + percentage: (averages.pm10 / WHO_GUIDELINES.pm10) * 100, + }, + no2: { + compliant: averages.no2 <= WHO_GUIDELINES.no2, + percentage: (averages.no2 / WHO_GUIDELINES.no2) * 100, + }, + }; + }; + + // Process weekly comparisons + const processWeeklyData = (weeklyComparison) => { + const current = { + normal: weeklyComparison.find( + (w) => w._id.isCurrentWeek === true && w._id.isPeakHour === false + ) || { pm2_5_avg: 0, pm10_avg: 0, no2_avg: 0 }, + peak: weeklyComparison.find( + (w) => w._id.isCurrentWeek === true && w._id.isPeakHour === true + ) || { pm2_5_avg: 0, pm10_avg: 0, no2_avg: 0 }, + }; + + const last = { + normal: weeklyComparison.find( + (w) => w._id.isCurrentWeek === false && w._id.isPeakHour === false + ) || { pm2_5_avg: 0, pm10_avg: 0, no2_avg: 0 }, + peak: weeklyComparison.find( + (w) => w._id.isCurrentWeek === false && w._id.isPeakHour === true + ) || { pm2_5_avg: 0, pm10_avg: 0, no2_avg: 0 }, + }; + + return { current, last }; + }; + + // Calculate trends + const calculateTrend = (data) => { + if (data.length < 2) return "insufficient_data"; + + const lastValue = data[data.length - 1]; + const previousValue = data[data.length - 2]; + const percentChange = + previousValue === 0 + ? 0 + : ((lastValue - previousValue) / previousValue) * 100; + + if (percentChange > 5) return "increasing"; + if (percentChange < -5) return "decreasing"; + return "stable"; + }; + + // Process all data + const weeklyData = processWeeklyData(weeklyComparison); + // Denominator: replaced 24 with Object.keys(hourlyData).length + const dailyAverages = { + pm2_5: + Object.values(hourlyData).reduce((sum, hour) => sum + hour.pm2_5, 0) / + Object.keys(hourlyData).length, + pm10: + Object.values(hourlyData).reduce((sum, hour) => sum + hour.pm10, 0) / + Object.keys(hourlyData).length, + no2: + Object.values(hourlyData).reduce((sum, hour) => sum + hour.no2, 0) / + Object.keys(hourlyData).length, + }; + + // Calculate percentage differences + const calculatePercentageDiff = (current, previous) => { + if (previous === 0) return 0; + return ((current - previous) / previous) * 100; + }; + + const percentageDifferences = { + normal: { + pm2_5: calculatePercentageDiff( + weeklyData.current.normal.pm2_5_avg, + weeklyData.last.normal.pm2_5_avg + ), + pm10: calculatePercentageDiff( + weeklyData.current.normal.pm10_avg, + weeklyData.last.normal.pm10_avg + ), + no2: calculatePercentageDiff( + weeklyData.current.normal.no2_avg, + weeklyData.last.normal.no2_avg + ), + }, + peak: { + pm2_5: calculatePercentageDiff( + weeklyData.current.peak.pm2_5_avg, + weeklyData.last.peak.pm2_5_avg + ), + pm10: calculatePercentageDiff( + weeklyData.current.peak.pm10_avg, + weeklyData.last.peak.pm10_avg + ), + no2: calculatePercentageDiff( + weeklyData.current.peak.no2_avg, + weeklyData.last.peak.no2_avg + ), + }, + }; + + // Round all numeric values to 2 decimal places + const roundObject = (obj) => { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (typeof value === "object" && value !== null) { + acc[key] = roundObject(value); + } else { + acc[key] = + typeof value === "number" ? Number(value.toFixed(2)) : value; + } + return acc; + }, {}); + }; + + const response = { + success: true, + message: "Successfully retrieved air quality analytics", + data: { + dailyAverage: roundObject(dailyAverages), + hourlyBreakdown: roundObject(hourlyData), + peakPollutionHours: roundObject(findPeakHours(hourlyData)), + weeklyComparison: { + current: { + normal: roundObject(weeklyData.current.normal), + peak: roundObject(weeklyData.current.peak), + }, + previous: { + normal: roundObject(weeklyData.last.normal), + peak: roundObject(weeklyData.last.peak), + }, + }, + percentageChange: roundObject(percentageDifferences), + compliance: roundObject(calculateCompliance(dailyAverages)), + monthlyTrend: { + data: roundObject(monthlyTrend), + trends: { + pm2_5: calculateTrend(monthlyTrend.map((d) => d.pm2_5_avg)), + pm10: calculateTrend(monthlyTrend.map((d) => d.pm10_avg)), + no2: calculateTrend(monthlyTrend.map((d) => d.no2_avg)), + }, + }, + }, + status: httpStatus.OK, + }; + return response; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); + next( + new HttpError("Internal Server Error", httpStatus.INTERNAL_SERVER_ERROR, { + message: error.message, + }) + ); + return; + } +}; const ReadingModel = (tenant) => { try { diff --git a/src/device-registry/package-lock.json b/src/device-registry/package-lock.json index 2cc8e624c3..a6b0918a53 100644 --- a/src/device-registry/package-lock.json +++ b/src/device-registry/package-lock.json @@ -32,6 +32,7 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-cluster": "0.0.5", + "express-rate-limit": "^7.4.1", "express-session": "^1.17.1", "express-validation": "^1.0.3", "express-validator": "^6.12.0", @@ -5152,6 +5153,20 @@ "resolved": "https://registry.npmjs.org/express-cluster/-/express-cluster-0.0.5.tgz", "integrity": "sha512-Wo0i4YFdj1341nkCxXO9zcO6ZGgiTiFEi+KMwE8bZexGg1sY2GmeDyHHEUDI+zSUJsQDSlT1EQSBnh5XaPIFVQ==" }, + "node_modules/express-rate-limit": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", + "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express-session": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz", @@ -21492,6 +21507,12 @@ "resolved": "https://registry.npmjs.org/express-cluster/-/express-cluster-0.0.5.tgz", "integrity": "sha512-Wo0i4YFdj1341nkCxXO9zcO6ZGgiTiFEi+KMwE8bZexGg1sY2GmeDyHHEUDI+zSUJsQDSlT1EQSBnh5XaPIFVQ==" }, + "express-rate-limit": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", + "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==", + "requires": {} + }, "express-session": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz", diff --git a/src/device-registry/package.json b/src/device-registry/package.json index b55cfa1598..3a4e58aa60 100644 --- a/src/device-registry/package.json +++ b/src/device-registry/package.json @@ -54,6 +54,7 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-cluster": "0.0.5", + "express-rate-limit": "^7.4.1", "express-session": "^1.17.1", "express-validation": "^1.0.3", "express-validator": "^6.12.0", diff --git a/src/device-registry/routes/v2/measurements.js b/src/device-registry/routes/v2/measurements.js index f19e88290c..cdab50de56 100644 --- a/src/device-registry/routes/v2/measurements.js +++ b/src/device-registry/routes/v2/measurements.js @@ -16,6 +16,11 @@ const { logElement, logText, logObject } = require("@utils/log"); const NetworkModel = require("@models/Network"); const decimalPlaces = require("decimal-places"); const numeral = require("numeral"); +const rateLimit = require("express-rate-limit"); +const averagesLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs +}); // Define a custom function to check if a value is a valid ObjectId const isValidObjectId = (value) => { @@ -1195,6 +1200,134 @@ router.get( ]), eventController.listRecent ); +/** + * @route GET /sites/:site_id/averages + * @description Get average measurements for a specific site + * @param {string} site_id - MongoDB ObjectId of the site + * @query {string} [tenant] - Optional tenant identifier + * @query {string} [startTime] - ISO8601 start time + * @query {string} [endTime] - ISO8601 end time + * @query {string} [frequency] - Data frequency (hourly|daily|raw|minute) + * @returns {object} Average measurements data + */ +router.get( + "/sites/:site_id/averages", + oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(constants.NETWORKS) + .withMessage("the tenant value is not among the expected ones"), + ]), + oneOf([ + [ + param("site_id") + .exists() + .withMessage("the site_id should be provided") + .bail() + .notEmpty() + .withMessage("the provided site_id cannot be empty") + .bail() + .trim() + .isMongoId() + .withMessage("the site_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + oneOf([ + [ + query("startTime") + .optional() + .notEmpty() + .withMessage("startTime cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("startTime must be a valid datetime."), + query("endTime") + .optional() + .notEmpty() + .withMessage("endTime cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("endTime must be a valid datetime."), + query("frequency") + .optional() + .notEmpty() + .withMessage("the frequency cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["hourly", "daily", "raw", "minute"]) + .withMessage( + "the frequency value is not among the expected ones which include: hourly, daily, minute and raw" + ), + query("format") + .optional() + .notEmpty() + .withMessage("the format cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["json", "csv"]) + .withMessage( + "the format value is not among the expected ones which include: csv and json" + ), + query("external") + .optional() + .notEmpty() + .withMessage("external cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage( + "the external value is not among the expected ones which include: no and yes" + ), + query("recent") + .optional() + .notEmpty() + .withMessage("recent cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage( + "the recent value is not among the expected ones which include: no and yes" + ), + query("metadata") + .optional() + .notEmpty() + .withMessage("metadata cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["site", "site_id", "device", "device_id"]) + .withMessage( + "valid values include: site, site_id, device and device_id" + ), + query("test") + .optional() + .notEmpty() + .withMessage("test cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage("valid values include: YES and NO"), + ], + ]), + averagesLimiter, + eventController.listAverages +); router.get( "/sites/:site_id", oneOf([ diff --git a/src/device-registry/routes/v2/readings.js b/src/device-registry/routes/v2/readings.js index 19c2d4cea4..30b50e3214 100644 --- a/src/device-registry/routes/v2/readings.js +++ b/src/device-registry/routes/v2/readings.js @@ -4,7 +4,7 @@ const eventController = require("@controllers/create-event"); const constants = require("@config/constants"); const mongoose = require("mongoose"); const ObjectId = mongoose.Types.ObjectId; -const { oneOf, query, validationResult } = require("express-validator"); +const { oneOf, query, validationResult, param } = require("express-validator"); const validateOptionalObjectId = require("@middleware/validateOptionalObjectId"); const validatePagination = (req, res, next) => { @@ -281,6 +281,123 @@ router.get( }, eventController.recentReadings ); +router.get( + "/sites/:site_id/averages", + oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(constants.NETWORKS) + .withMessage("the tenant value is not among the expected ones"), + ]), + oneOf([ + [ + param("site_id") + .exists() + .withMessage("the site_id should be provided") + .bail() + .notEmpty() + .withMessage("the provided site_id cannot be empty") + .bail() + .trim() + .isMongoId() + .withMessage("the site_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + oneOf([ + [ + query("startTime") + .optional() + .notEmpty() + .withMessage("startTime cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("startTime must be a valid datetime."), + query("endTime") + .optional() + .notEmpty() + .withMessage("endTime cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("endTime must be a valid datetime."), + query("frequency") + .optional() + .notEmpty() + .withMessage("the frequency cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["hourly", "daily", "raw", "minute"]) + .withMessage( + "the frequency value is not among the expected ones which include: hourly, daily, minute and raw" + ), + query("format") + .optional() + .notEmpty() + .withMessage("the format cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["json", "csv"]) + .withMessage( + "the format value is not among the expected ones which include: csv and json" + ), + query("external") + .optional() + .notEmpty() + .withMessage("external cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage( + "the external value is not among the expected ones which include: no and yes" + ), + query("recent") + .optional() + .notEmpty() + .withMessage("recent cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage( + "the recent value is not among the expected ones which include: no and yes" + ), + query("metadata") + .optional() + .notEmpty() + .withMessage("metadata cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["site", "site_id", "device", "device_id"]) + .withMessage( + "valid values include: site, site_id, device and device_id" + ), + query("test") + .optional() + .notEmpty() + .withMessage("test cannot be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["yes", "no"]) + .withMessage("valid values include: YES and NO"), + ], + ]), + eventController.listReadingAverages +); router.get("/fetchAndStoreData", eventController.fetchAndStoreData); module.exports = router; diff --git a/src/device-registry/utils/create-event.js b/src/device-registry/utils/create-event.js index 6715419182..e379d33cdb 100644 --- a/src/device-registry/utils/create-event.js +++ b/src/device-registry/utils/create-event.js @@ -807,6 +807,128 @@ const createEvent = { ); } }, + listAverages: async (request, next) => { + try { + let missingDataMessage = ""; + const { language, site_id, tenant } = { + ...request.query, + ...request.params, + }; + + try { + const cacheResult = await Promise.race([ + createEvent.getCache(request, next), + new Promise((resolve) => + setTimeout(resolve, 60000, { + success: false, + message: "Internal Server Error", + status: httpStatus.INTERNAL_SERVER_ERROR, + errors: { message: "Cache timeout" }, + }) + ), + ]); + + logObject("Cache result", cacheResult); + + if (cacheResult.success === true) { + logText(cacheResult.message); + return cacheResult.data; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Errors -- ${stringify(error)}`); + } + + const responseFromListEvents = await EventModel( + tenant + ).getAirQualityAverages(site_id, next); + + if ( + language !== undefined && + !isEmpty(responseFromListEvents) && + responseFromListEvents.success === true && + !isEmpty(responseFromListEvents.data) + ) { + const data = responseFromListEvents.data; + for (const event of data) { + const translatedHealthTips = await translateUtil.translateTips( + { healthTips: event.health_tips, targetLanguage: language }, + next + ); + if (translatedHealthTips.success === true) { + event.health_tips = translatedHealthTips.data; + } + } + } + + if (responseFromListEvents.success === true) { + const data = !isEmpty(missingDataMessage) + ? [] + : responseFromListEvents.data; + + logText("Setting cache..."); + + try { + const resultOfCacheOperation = await Promise.race([ + createEvent.setCache(data, request, next), + new Promise((resolve) => + setTimeout(resolve, 60000, { + success: false, + message: "Internal Server Error", + status: httpStatus.INTERNAL_SERVER_ERROR, + errors: { message: "Cache timeout" }, + }) + ), + ]); + if (resultOfCacheOperation.success === false) { + const errors = resultOfCacheOperation.errors + ? resultOfCacheOperation.errors + : { message: "Internal Server Error" }; + logger.error(`🐛🐛 Internal Server Error -- ${stringify(errors)}`); + // return resultOfCacheOperation; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Errors -- ${stringify(error)}`); + } + + logText("Cache set."); + + return { + success: true, + message: !isEmpty(missingDataMessage) + ? missingDataMessage + : isEmpty(data) + ? "no measurements for this search" + : responseFromListEvents.message, + data, + status: responseFromListEvents.status || "", + isCache: false, + }; + } else { + logger.error( + `Unable to retrieve events --- ${stringify( + responseFromListEvents.errors + )}` + ); + + return { + success: false, + message: responseFromListEvents.message, + errors: responseFromListEvents.errors || { message: "" }, + status: responseFromListEvents.status || "", + isCache: false, + }; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, view: async (request, next) => { try { let missingDataMessage = ""; @@ -1261,6 +1383,123 @@ const createEvent = { return; } }, + listReadingAverages: async (request, next) => { + try { + let missingDataMessage = ""; + const { tenant, language, site_id } = { + ...request.query, + ...request.params, + }; + try { + const cacheResult = await Promise.race([ + createEvent.getCache(request, next), + new Promise((resolve) => + setTimeout(resolve, 60000, { + success: false, + message: "Internal Server Error", + status: httpStatus.INTERNAL_SERVER_ERROR, + errors: { message: "Cache timeout" }, + }) + ), + ]); + + if (cacheResult.success === true) { + logText(cacheResult.message); + return cacheResult.data; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Errors -- ${stringify(error)}`); + } + + const readingsResponse = await ReadingModel( + tenant + ).getAirQualityAnalytics(site_id, next); + + if ( + language !== undefined && + !isEmpty(readingsResponse) && + readingsResponse.success === true && + !isEmpty(readingsResponse.data) + ) { + const data = readingsResponse.data; + for (const event of data) { + const translatedHealthTips = await translateUtil.translateTips( + { healthTips: event.health_tips, targetLanguage: language }, + next + ); + if (translatedHealthTips.success === true) { + event.health_tips = translatedHealthTips.data; + } + } + } + + if (readingsResponse.success === true) { + const data = readingsResponse.data; + + logText("Setting cache..."); + + try { + const resultOfCacheOperation = await Promise.race([ + createEvent.setCache(readingsResponse, request, next), + new Promise((resolve) => + setTimeout(resolve, 60000, { + success: false, + message: "Internal Server Error", + status: httpStatus.INTERNAL_SERVER_ERROR, + errors: { message: "Cache timeout" }, + }) + ), + ]); + if (resultOfCacheOperation.success === false) { + const errors = resultOfCacheOperation.errors + ? resultOfCacheOperation.errors + : { message: "Internal Server Error" }; + logger.error(`🐛🐛 Internal Server Error -- ${stringify(errors)}`); + // return resultOfCacheOperation; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Errors -- ${stringify(error)}`); + } + + logText("Cache set."); + + return { + success: true, + message: !isEmpty(missingDataMessage) + ? missingDataMessage + : isEmpty(data) + ? "no measurements for this search" + : readingsResponse.message, + data, + status: readingsResponse.status || "", + isCache: false, + }; + } else { + logger.error( + `Unable to retrieve events --- ${stringify(readingsResponse.errors)}` + ); + + return { + success: false, + message: readingsResponse.message, + errors: readingsResponse.errors || { message: "" }, + status: readingsResponse.status || "", + isCache: false, + }; + } + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, getBestAirQuality: async (request, next) => { try { const { @@ -2006,6 +2245,7 @@ const createEvent = { longitude, network, language, + averages, } = { ...request.query, ...request.params }; const currentTime = new Date().toISOString(); const day = generateDateFormatWithoutHrs(currentTime); @@ -2029,7 +2269,9 @@ const createEvent = { latitude ? latitude : "noLatitude" }_${longitude ? longitude : "noLongitude"}_${ network ? network : "noNetwork" - }_${language ? language : "noLanguage"}`; + }_${language ? language : "noLanguage"}_${ + averages ? averages : "noAverages" + }`; } catch (error) { logger.error(`🐛🐛 Internal Server Error ${error.message}`); next( @@ -2077,7 +2319,6 @@ const createEvent = { getCache: async (request, next) => { try { const cacheID = createEvent.generateCacheID(request, next); - const result = await redisGetAsync(cacheID); // Use the promise-based version const resultJSON = JSON.parse(result);