diff --git a/k8s/analytics/values-prod.yaml b/k8s/analytics/values-prod.yaml index 6a4a3b578f..7e43522cf8 100644 --- a/k8s/analytics/values-prod.yaml +++ b/k8s/analytics/values-prod.yaml @@ -8,7 +8,7 @@ images: celeryWorker: eu.gcr.io/airqo-250220/airqo-analytics-celery-worker reportJob: eu.gcr.io/airqo-250220/airqo-analytics-report-job devicesSummaryJob: eu.gcr.io/airqo-250220/airqo-analytics-devices-summary-job - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 api: name: airqo-analytics-api label: analytics-api diff --git a/k8s/auth-service/values-prod.yaml b/k8s/auth-service/values-prod.yaml index 99e323559a..f5ff13d618 100644 --- a/k8s/auth-service/values-prod.yaml +++ b/k8s/auth-service/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-auth-api - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/auth-service/values-stage.yaml b/k8s/auth-service/values-stage.yaml index 062c71b6ab..0e9193e3f7 100644 --- a/k8s/auth-service/values-stage.yaml +++ b/k8s/auth-service/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-auth-api - tag: stage-6fb34054-1729313663 + tag: stage-03191d9c-1729435343 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-prod.yaml b/k8s/device-registry/values-prod.yaml index b69e0542f1..1de030eab1 100644 --- a/k8s/device-registry/values-prod.yaml +++ b/k8s/device-registry/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-device-registry-api - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-stage.yaml b/k8s/device-registry/values-stage.yaml index 7620590876..fb5ba44781 100644 --- a/k8s/device-registry/values-stage.yaml +++ b/k8s/device-registry/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-device-registry-api - tag: stage-f5ce3fc9-1729339366 + tag: stage-42690b37-1729375485 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/exceedance/values-prod-airqo.yaml b/k8s/exceedance/values-prod-airqo.yaml index 6ba80bd405..c1ceae4f44 100644 --- a/k8s/exceedance/values-prod-airqo.yaml +++ b/k8s/exceedance/values-prod-airqo.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/airqo-exceedance-job - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' diff --git a/k8s/exceedance/values-prod-kcca.yaml b/k8s/exceedance/values-prod-kcca.yaml index 014b759366..8fb197a32c 100644 --- a/k8s/exceedance/values-prod-kcca.yaml +++ b/k8s/exceedance/values-prod-kcca.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/kcca-exceedance-job - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' diff --git a/k8s/predict/values-prod.yaml b/k8s/predict/values-prod.yaml index d487bbba76..22a8e7f60c 100644 --- a/k8s/predict/values-prod.yaml +++ b/k8s/predict/values-prod.yaml @@ -7,7 +7,7 @@ images: predictJob: eu.gcr.io/airqo-250220/airqo-predict-job trainJob: eu.gcr.io/airqo-250220/airqo-train-job predictPlaces: eu.gcr.io/airqo-250220/airqo-predict-places-air-quality - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 api: name: airqo-prediction-api label: prediction-api diff --git a/k8s/spatial/values-prod.yaml b/k8s/spatial/values-prod.yaml index b9a4d60b87..dee4fa929c 100644 --- a/k8s/spatial/values-prod.yaml +++ b/k8s/spatial/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-spatial-api - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/workflows/values-prod.yaml b/k8s/workflows/values-prod.yaml index ef4b0a6bb4..91823c5349 100644 --- a/k8s/workflows/values-prod.yaml +++ b/k8s/workflows/values-prod.yaml @@ -10,7 +10,7 @@ images: initContainer: eu.gcr.io/airqo-250220/airqo-workflows-xcom redisContainer: eu.gcr.io/airqo-250220/airqo-redis containers: eu.gcr.io/airqo-250220/airqo-workflows - tag: prod-d0d63dbd-1729340566 + tag: prod-c73a134b-1729375524 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/src/auth-service/middleware/test/ut_setDefaultTenant.js b/src/auth-service/middleware/test/ut_setDefaultTenant.js new file mode 100644 index 0000000000..83f460b1da --- /dev/null +++ b/src/auth-service/middleware/test/ut_setDefaultTenant.js @@ -0,0 +1,57 @@ +require("module-alias/register"); +const { expect } = require("chai"); +const sinon = require("sinon"); +const setDefaultTenant = require("@middleware/setDefaultTenant"); +const constants = require("@config/constants"); + +describe("setDefaultTenant Middleware", () => { + let req, res, next; + + beforeEach(() => { + req = { + query: {}, + }; + res = {}; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); // Restore the original functionality of stubbed methods + }); + + it("should set the default tenant if tenant is empty", () => { + // Set up the constant for testing + constants.DEFAULT_TENANT = "defaultTenant"; + + const middleware = setDefaultTenant; + middleware(req, res, next); + + expect(req.query.tenant).to.equal("defaultTenant"); + expect(next.calledOnce).to.be.true; // Ensure next() is called + }); + + it("should keep the existing tenant if provided", () => { + req.query.tenant = "customTenant"; + + const middleware = setDefaultTenant; + middleware(req, res, next); + + expect(req.query.tenant).to.equal("customTenant"); + expect(next.calledOnce).to.be.true; // Ensure next() is called + }); + + it("should use 'airqo' as the default tenant if no constant is defined", () => { + // Temporarily remove DEFAULT_TENANT for this test + const originalDefaultTenant = constants.DEFAULT_TENANT; + delete constants.DEFAULT_TENANT; + + const middleware = setDefaultTenant; + middleware(req, res, next); + + expect(req.query.tenant).to.equal("airqo"); + expect(next.calledOnce).to.be.true; // Ensure next() is called + + // Restore the original constant value after test + constants.DEFAULT_TENANT = originalDefaultTenant; + }); +}); diff --git a/src/auth-service/middleware/test/ut_validateSelectedSites.js b/src/auth-service/middleware/test/ut_validateSelectedSites.js new file mode 100644 index 0000000000..fabc3945d5 --- /dev/null +++ b/src/auth-service/middleware/test/ut_validateSelectedSites.js @@ -0,0 +1,382 @@ +require("module-alias/register"); +const { expect } = require("chai"); +const sinon = require("sinon"); +const validateSelectedSites = require("@middleware/validateSelectedSites"); +const { logText, logObject } = require("@utils/log"); + +describe("validateSelectedSites Middleware", () => { + let req, res, next; + + beforeEach(() => { + req = { + body: {}, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logText); + sinon.stub(logObject); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("when selected_sites is not defined", () => { + it("should call next() if selected_sites is undefined", () => { + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(next.calledOnce).to.be.true; + }); + }); + + describe("when selected_sites is not an array or object", () => { + it("should return a 400 error if selected_sites is neither an array nor an object", () => { + req.body.selected_sites = "not an array or object"; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + message: + "Request body(field) for Selected Sites should contain either an array of Site objects or be omitted.", + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + }); + + describe("when a site is null or undefined", () => { + it("should return a 400 error for null site value", () => { + req.body.selected_sites = [null]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["Site value must not be null or undefined"], + }, + message: "bad request errors", + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + }); + + describe("when required fields are missing", () => { + it("should return a 400 error for missing required fields", () => { + req.body.selected_sites = [{}]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": [ + 'Field "site_id" is missing', + 'Field "name" is missing', + 'Field "search_name" is missing', + ], + }, + message: "bad request errors", + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + }); + + describe("when fields are of invalid types", () => { + it("should return a 400 error for invalid site_id format", () => { + req.body.selected_sites = [{ site_id: 123 }]; + + const middleware = validateSelectedSites(["site_id"]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["site_id must be a non-empty string"], + }, + message: "bad request errors", + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should return a 400 error for invalid latitude value", () => { + req.body.selected_sites = [{ latitude: "invalid" }]; + + const middleware = validateSelectedSites(["latitude"]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["latitude must be between -90 and 90"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + + it("should return a 400 error for invalid longitude value", () => { + req.body.selected_sites = [{ longitude: "invalid" }]; + + const middleware = validateSelectedSites(["longitude"]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["longitude must be between -180 and 180"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + + it("should return a 400 error for non-array site_tags", () => { + req.body.selected_sites = [{ site_tags: "not_an_array" }]; + + const middleware = validateSelectedSites(["site_tags"]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["site_tags must be an array"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + + it("should return a 400 error for invalid isFeatured value", () => { + req.body.selected_sites = [{ isFeatured: "not_a_boolean" }]; + + const middleware = validateSelectedSites(["isFeatured"]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["isFeatured must be a boolean"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + }); + + describe("when duplicate values are found", () => { + it("should return a 400 error for duplicate site_id", () => { + req.body.selected_sites = [ + { site_id: "site1", name: "Site 1", search_name: "Search 1" }, + { site_id: "site1", name: "Site 2", search_name: "Search 2" }, + ]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[1]": ["Duplicate site_id: site1"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + + it("should return a 400 error for duplicate search_name", () => { + req.body.selected_sites = [ + { site_id: "site1", name: "Site 1", search_name: "Search" }, + { site_id: "site2", name: "Site 2", search_name: "Search" }, + ]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[1]": ["Duplicate search_name: Search"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + + it("should return a 400 error for duplicate name", () => { + req.body.selected_sites = [ + { site_id: "site1", name: "Site", search_name: "Search 1" }, + { site_id: "site2", name: "Site", search_name: "Search 2" }, + ]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[1]": ["Duplicate name: Site"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + }); + + describe("when all validations pass", () => { + it("should call next() when all validations are successful", () => { + req.body.selected_sites = [ + { + site_id: "site1", + name: "Valid Site", + search_name: "Valid Search", + latitude: -1.2345, + longitude: 34.5678, + approximate_latitude: -1.2345, + approximate_longitude: 34.5678, + site_tags: ["tag1", "tag2"], + country: "Country Name", + isFeatured: true, + }, + ]; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(next.calledOnce).to.be.true; + }); + }); + + describe("when allowId is true", () => { + it("should allow _id field and validate it as a MongoDB ObjectId", () => { + req.body.selected_sites = [ + { + _id: "507f1f77bcf86cd799439011", + site_id: "site1", + name: "Valid Site", + search_name: "Valid Search", + }, + ]; + + const middleware = validateSelectedSites( + ["site_id", "name", "search_name"], + true + ); + middleware(req, res, next); + + expect(next.calledOnce).to.be.true; + }); + + it("should return a 400 error for invalid _id format", () => { + req.body.selected_sites = [ + { + _id: "invalid_id", + site_id: "site1", + name: "Valid Site", + search_name: "Valid Search", + }, + ]; + + const middleware = validateSelectedSites( + ["site_id", "name", "search_name"], + true + ); + middleware(req, res, next); + + expect(res.status.calledWith(400)).to.be.true; + expect( + res.json.calledWith({ + success: false, + errors: { + "selected_sites[0]": ["_id must be a valid MongoDB ObjectId"], + }, + message: "bad request errors", + }) + ).to.be.true; + }); + }); + + describe("when selected_sites is a single object", () => { + it("should validate a single object when selected_sites is not an array", () => { + req.body.selected_sites = { + site_id: "site1", + name: "Valid Site", + search_name: "Valid Search", + }; + + const middleware = validateSelectedSites([ + "site_id", + "name", + "search_name", + ]); + middleware(req, res, next); + + expect(next.calledOnce).to.be.true; + }); + }); +}); diff --git a/src/auth-service/middleware/validatePagination.js b/src/auth-service/middleware/validatePagination.js new file mode 100644 index 0000000000..d85c00a7a5 --- /dev/null +++ b/src/auth-service/middleware/validatePagination.js @@ -0,0 +1,26 @@ +const validatePagination = (defaultLimit = 100, maxLimit = 1000) => { + return (req, res, next) => { + let limit = parseInt(req.query.limit || req.body.limit, 10); + const skip = parseInt(req.query.skip || req.body.skip, 10) || 0; + + // Set default limit if not provided or invalid + if (Number.isNaN(limit) || limit < 1) { + limit = defaultLimit; + } + + // Cap the limit at maxLimit + if (limit > maxLimit) { + limit = maxLimit; + } + + // Set the validated limit and skip values in the request object + req.pagination = { + limit, + skip, + }; + + next(); + }; +}; + +module.exports = validatePagination; diff --git a/src/auth-service/middleware/validatePreferences.js b/src/auth-service/middleware/validatePreferences.js new file mode 100644 index 0000000000..3f39142cf3 --- /dev/null +++ b/src/auth-service/middleware/validatePreferences.js @@ -0,0 +1,218 @@ +const { body, oneOf, query } = require("express-validator"); +const { ObjectId } = require("mongoose").Types; + +const validateRequestBody = () => { + return oneOf([ + [ + body("user_id") + .optional() + .notEmpty() + .withMessage("the provided user_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the user_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + body("pollutant") + .optional() + .notEmpty() + .withMessage("the provided pollutant should not be empty IF provided") + .bail() + .trim() + .isIn(["no2", "pm2_5", "pm10", "pm1"]) + .withMessage( + "the pollutant value is not among the expected ones which include: no2, pm2_5, pm10, pm1" + ), + body("frequency") + .optional() + .notEmpty() + .withMessage("the provided frequency should not be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["daily", "hourly", "monthly"]) + .withMessage( + "the frequency value is not among the expected ones which include: daily, hourly and monthly" + ), + body("chartType") + .optional() + .notEmpty() + .withMessage("the provided chartType should not be empty IF provided") + .bail() + .trim() + .toLowerCase() + .isIn(["bar", "line", "pie"]) + .withMessage( + "the chartType value is not among the expected ones which include: bar, line and pie" + ), + body("startDate") + .optional() + .notEmpty() + .withMessage("the provided startDate should not be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("startDate must be a valid datetime."), + body("endDate") + .optional() + .notEmpty() + .withMessage("the provided endDate should not be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("endDate must be a valid datetime."), + body("airqloud_id") + .optional() + .notEmpty() + .withMessage("the provided airqloud_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the airqloud_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + body("cohort_id") + .optional() + .notEmpty() + .withMessage("the provided cohort_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the cohort_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + body("grid_id") + .optional() + .notEmpty() + .withMessage("the provided grid_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the grid_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + body("chartTitle") + .optional() + .notEmpty() + .withMessage("the provided chartTitle should not be empty IF provided") + .bail() + .trim(), + body("period") + .optional() + .notEmpty() + .withMessage("the provided period should not be empty IF provided") + .bail() + .custom((value) => typeof value === "object") + .withMessage("the period should be an object"), + body("chartSubTitle") + .optional() + .notEmpty() + .withMessage( + "the provided chartSubTitle should not be empty IF provided" + ) + .bail() + .trim(), + body("site_ids") + .optional() + .notEmpty() + .withMessage("the provided site_ids should not be empty IF provided") + .bail() + .custom((value) => Array.isArray(value)) + .withMessage("the site_ids should be an array"), + body("site_ids.*") + .optional() + .notEmpty() + .withMessage("the provided site_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("site_id must be an object ID"), + body("device_ids") + .optional() + .notEmpty() + .withMessage("the provided device_ids should not be empty IF provided") + .bail() + .custom((value) => Array.isArray(value)) + .withMessage("the device_ids should be an array"), + body("device_ids.*") + .optional() + .notEmpty() + .withMessage("the provided device_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("device_id must be an object ID"), + query("id") + .optional() + .notEmpty() + .withMessage("the provided id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("user_id") + .optional() + .notEmpty() + .withMessage("the provided user_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the user_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("airqloud_id") + .optional() + .notEmpty() + .withMessage("the provided airqloud_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the airqloud_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("cohort_id") + .optional() + .notEmpty() + .withMessage("the provided cohort_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the cohort_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("grid_id") + .optional() + .notEmpty() + .withMessage("the provided grid_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the grid_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("site_id") + .optional() + .notEmpty() + .withMessage("the provided site_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the site_id must be an object ID"), + ], + ]); +}; + +module.exports = validateRequestBody; diff --git a/src/auth-service/middleware/validateSelectedSites.js b/src/auth-service/middleware/validateSelectedSites.js new file mode 100644 index 0000000000..ffdb67711e --- /dev/null +++ b/src/auth-service/middleware/validateSelectedSites.js @@ -0,0 +1,232 @@ +const { isMongoId } = require("validator"); +const { logText, logObject } = require("@utils/log"); +const isEmpty = require("is-empty"); + +const validateSelectedSites = (requiredFields, allowId = false) => { + return (req, res, next) => { + const selectedSites = req.body.selected_sites || req.body; // Get selected_sites directly + const errors = {}; // Object to hold error messages + + if (allowId && !req.body.selected_sites) { + return next(); + } + // If selectedSites is not defined, we can skip validation + if (selectedSites === undefined) { + return next(); // Proceed to the next middleware or route handler + } + + // Early check for selectedSites type + if ( + !Array.isArray(selectedSites) && + (typeof selectedSites !== "object" || selectedSites === null) + ) { + return res.status(400).json({ + success: false, + message: + "Request body(field) for Selected Sites should contain either an array of Site objects or be omitted.", + }); + } + + // Helper function to validate a single site + const validateSite = (site, index) => { + const siteErrors = []; // Array to hold errors for the current site + + if (!site) { + siteErrors.push("Site value must not be null or undefined"); + } + + // Edge case: Check if _id field is present + if (!allowId && "_id" in site) { + siteErrors.push("_id field is not allowed"); + } + + // Validate required fields directly + requiredFields.forEach((field) => { + if (!(field in site)) { + siteErrors.push(`Field "${field}" is missing`); + } + }); + + // Validate _id if allowed + if (allowId && site._id && !isMongoId(site._id)) { + siteErrors.push("_id must be a valid MongoDB ObjectId"); + } + + // Validate site_id + if (typeof site.site_id !== "string" || site.site_id.trim() === "") { + siteErrors.push("site_id must be a non-empty string"); + } + + // Validate latitude + if (site.latitude !== undefined) { + const latValue = parseFloat(site.latitude); + if (Number.isNaN(latValue) || latValue < -90 || latValue > 90) { + siteErrors.push("latitude must be between -90 and 90"); + } + } + + // Validate longitude + if (site.longitude !== undefined) { + const longValue = parseFloat(site.longitude); + if (Number.isNaN(longValue) || longValue < -180 || longValue > 180) { + siteErrors.push("longitude must be between -180 and 180"); + } + } + + // Validate approximate_latitude + if (site.approximate_latitude !== undefined) { + const approxLatValue = parseFloat(site.approximate_latitude); + if ( + Number.isNaN(approxLatValue) || + approxLatValue < -90 || + approxLatValue > 90 + ) { + siteErrors.push("approximate_latitude must be between -90 and 90"); + } + } + + // Validate approximate_longitude + if (site.approximate_longitude !== undefined) { + const approxLongValue = parseFloat(site.approximate_longitude); + if ( + Number.isNaN(approxLongValue) || + approxLongValue < -180 || + approxLongValue > 180 + ) { + siteErrors.push("approximate_longitude must be between -180 and 180"); + } + } + + // Validate site_tags + const tags = site.site_tags; + if (!isEmpty(tags)) { + if (!Array.isArray(tags)) { + siteErrors.push("site_tags must be an array"); + } + + tags.forEach((tag, tagIndex) => { + if (typeof tag !== "string") { + siteErrors.push(`site_tags[${tagIndex}] must be a string`); + } + }); + } + + // Validate optional string fields only when they are present + const optionalStringFields = [ + "country", + "district", + "sub_county", + "parish", + "county", + "city", + "generated_name", + "lat_long", + "formatted_name", + "region", + "search_name", + ]; + + optionalStringFields.forEach((field) => { + if (field in site) { + // Only check if the field is provided + if (typeof site[field] !== "string" || site[field].trim() === "") { + siteErrors.push(`${field} must be a non-empty string`); + } + } + }); + + // Validate isFeatured field + if ("isFeatured" in site) { + // Check only if provided + if (typeof site.isFeatured !== "boolean") { + siteErrors.push(`isFeatured must be a boolean`); + } + } + + return siteErrors; // Return collected errors for this site + }; + + // If selectedSites is defined as an array, validate each item. + if (Array.isArray(selectedSites)) { + selectedSites.forEach((site, index) => { + const siteErrors = validateSite(site, index); + if (siteErrors.length > 0) { + errors[`selected_sites[${index}]`] = + errors[`selected_sites[${index}]`] || []; + errors[`selected_sites[${index}]`].push(...siteErrors); + } + }); + + // Unique checks after validating each item + const uniqueSiteIds = new Set(); + const uniqueSearchNames = new Set(); + const uniqueNames = new Set(); + + selectedSites.forEach((item, idx) => { + // Check for duplicate site_id + if (item.site_id !== undefined) { + if (uniqueSiteIds.has(item.site_id)) { + errors[`selected_sites[${idx}]`] = + errors[`selected_sites[${idx}]`] || []; + errors[`selected_sites[${idx}]`].push( + `Duplicate site_id: ${item.site_id}` + ); + } else { + uniqueSiteIds.add(item.site_id); + } + } + + // Check for duplicate search_name + if (item.search_name !== undefined) { + if (uniqueSearchNames.has(item.search_name)) { + errors[`selected_sites[${idx}]`] = + errors[`selected_sites[${idx}]`] || []; + errors[`selected_sites[${idx}]`].push( + `Duplicate search_name: ${item.search_name}` + ); + } else { + uniqueSearchNames.add(item.search_name); + } + } + + // Check for duplicate name + if (item.name !== undefined) { + if (uniqueNames.has(item.name)) { + errors[`selected_sites[${idx}]`] = + errors[`selected_sites[${idx}]`] || []; + errors[`selected_sites[${idx}]`].push( + `Duplicate name: ${item.name}` + ); + } else { + uniqueNames.add(item.name); + } + } + }); + } else if (typeof selectedSites === "object" && selectedSites !== null) { + const siteErrors = validateSite(selectedSites, 0); // Treat as single object with index 0 + if (siteErrors.length > 0) { + errors[`selected_sites[0]`] = errors[`selected_sites[0]`] || []; + errors[`selected_sites[0]`].push(...siteErrors); + } + } else { + return res.status(400).json({ + success: false, + message: + "Request body(field) for Selected Sites should contain either an array of Site objects or a single Site object", + }); + } + + // If any errors were collected, respond with them + if (Object.keys(errors).length > 0) { + return res.status(400).json({ + success: false, + message: "bad request errors", + errors, + }); + } + + next(); // Proceed to the next middleware or route handler + }; +}; + +module.exports = validateSelectedSites; diff --git a/src/auth-service/middleware/validateTenant.js b/src/auth-service/middleware/validateTenant.js new file mode 100644 index 0000000000..0430807974 --- /dev/null +++ b/src/auth-service/middleware/validateTenant.js @@ -0,0 +1,16 @@ +const { query } = require("express-validator"); +const ALLOWED_TENANTS = ["kcca", "airqo"]; + +const validateTenant = () => { + return query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(ALLOWED_TENANTS) + .withMessage("the tenant value is not among the expected ones"); +}; + +module.exports = validateTenant; diff --git a/src/auth-service/models/Preference.js b/src/auth-service/models/Preference.js index 03ec254cd0..65eb5bca30 100644 --- a/src/auth-service/models/Preference.js +++ b/src/auth-service/models/Preference.js @@ -45,6 +45,7 @@ const siteSchema = new mongoose.Schema( sub_county: { type: String }, grid_id: { type: ObjectId }, createdAt: { type: Date }, + isFeatured: { type: Boolean, default: false }, }, { _id: false } ); diff --git a/src/auth-service/routes/v2/preferences.js b/src/auth-service/routes/v2/preferences.js index 8590b84320..febcbb2888 100644 --- a/src/auth-service/routes/v2/preferences.js +++ b/src/auth-service/routes/v2/preferences.js @@ -10,14 +10,10 @@ const isEmpty = require("is-empty"); const { logText, logObject } = require("@utils/log"); const { isMongoId } = require("validator"); // const stringify = require("@utils/stringify"); - -const validatePagination = (req, res, next) => { - const limit = parseInt(req.query.limit, 10); - const skip = parseInt(req.query.skip, 10); - req.query.limit = Number.isNaN(limit) || limit < 1 ? 100 : limit; - req.query.skip = Number.isNaN(skip) || skip < 0 ? 0 : skip; - next(); -}; +const validateSelectedSites = require("@middleware/validateSelectedSites"); +const validatePreferences = require("@middleware/validatePreferences"); +const validateTenant = require("@middleware/validateTenant"); +const validatePagination = require("@middleware/validatePagination"); const headers = (req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); @@ -28,1288 +24,173 @@ const headers = (req, res, next) => { res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH"); next(); }; - -function createValidateSelectedSitesField(requiredFields, allowId = false) { - return function (value) { - if (!value) { - throw new Error("Value must not be null or undefined"); - } - // Edge case: Check if _id field is present - if (!allowId && "_id" in value) { - throw new Error("_id field is not allowed"); - } - - if (!requiredFields.every((field) => field in value)) { - throw new Error( - `Missing required fields: ${requiredFields - .filter((field) => !(field in value)) - .join(", ")}` - ); - } - - const isValidId = isMongoId(value._id); - if (!isValidId && allowId) { - throw new Error("_id must be a valid MongoDB ObjectId"); - } - - const isValidSiteId = isMongoId(value.site_id); - if (!isValidSiteId && !allowId) { - throw new Error("site_id must be a valid MongoDB ObjectId"); - } - - function validateNumericFields(fields) { - for (const field of fields) { - if (field in value) { - const numValue = parseFloat(value[field]); - if (Number.isNaN(numValue)) { - throw new Error(`${field} must be a valid number`); - } else if ( - field === "latitude" || - field === "longitude" || - field === "approximate_latitude" || - field === "approximate_longitude" - ) { - if (Math.abs(numValue) > 90) { - throw new Error(`${field} must be between -90 and 90`); - } - } else if (field === "search_radius") { - if (numValue <= 0) { - throw new Error(`${field} must be greater than 0`); - } - } - } - } - return true; - } - - function validateStringFields(fields) { - for (const field of fields) { - if ( - field in value && - (typeof value[field] !== "string" || value[field].trim() === "") - ) { - throw new Error(`${field} must be a non-empty string`); - } else if (!(field in value)) { - // Log missing field - throw new Error(`Field "${field}" is missing`); - } - } - return true; - } - - function validateTags(tags) { - if (isEmpty(tags)) { - return true; - } else if (!Array.isArray(tags)) { - throw new Error("site_tags must be an array"); - } else { - tags.forEach((tag, index) => { - if (typeof tag !== "string") { - throw new Error(`site_tags[${index}] must be a string`); - } - }); - } - } - - const numericValid = validateNumericFields([ - "latitude", - "longitude", - "approximate_latitude", - "approximate_longitude", - ]); - - const stringValid = validateStringFields(["name", "search_name"]); - const tags = value && value.site_tags; - const tagValid = validateTags(tags); - - return numericValid && stringValid && tagValid; - }; -} - -const validateUniqueFieldsInSelectedSites = (req, res, next) => { - const selectedSites = req.body.selected_sites; - - // Create Sets to track unique values for each field - const uniqueSiteIds = new Set(); - const uniqueSearchNames = new Set(); - const uniqueNames = new Set(); - - const duplicateSiteIds = []; - const duplicateSearchNames = []; - const duplicateNames = []; - - selectedSites.forEach((item) => { - // Check for duplicate site_id if it exists - if (item.site_id !== undefined) { - if (uniqueSiteIds.has(item.site_id)) { - duplicateSiteIds.push(item.site_id); - } else { - uniqueSiteIds.add(item.site_id); - } - } - - // Check for duplicate search_name if it exists - if (item.search_name !== undefined) { - if (uniqueSearchNames.has(item.search_name)) { - duplicateSearchNames.push(item.search_name); - } else { - uniqueSearchNames.add(item.search_name); - } - } - - // Check for duplicate name if it exists - if (item.name !== undefined) { - if (uniqueNames.has(item.name)) { - duplicateNames.push(item.name); - } else { - uniqueNames.add(item.name); - } - } - }); - - // Prepare error messages based on duplicates found - let errorMessage = ""; - if (duplicateSiteIds.length > 0) { - errorMessage += - "Duplicate site_ids found: " + - [...new Set(duplicateSiteIds)].join(", ") + - ". "; - } - if (duplicateSearchNames.length > 0) { - errorMessage += - "Duplicate search_names found: " + - [...new Set(duplicateSearchNames)].join(", ") + - ". "; - } - if (duplicateNames.length > 0) { - errorMessage += - "Duplicate names found: " + - [...new Set(duplicateNames)].join(", ") + - ". "; - } - - // If any duplicates were found, respond with an error - if (errorMessage) { - return res.status(400).json({ - success: false, - message: errorMessage.trim(), - }); - } - - next(); -}; - router.use(headers); -router.use(validatePagination); +router.use(validatePagination(100, 1000)); router.post( "/upsert", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("user_id") - .exists() - .withMessage("the user_id should be provided in the request body") - .bail() - .notEmpty() - .withMessage("the provided user_id should not be empty") - .bail() - .trim() - .isMongoId() - .withMessage("the user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("pollutant") - .optional() - .notEmpty() - .withMessage("the provided pollutant should not be empty IF provided") - .bail() - .trim() - .isIn(["no2", "pm2_5", "pm10", "pm1"]) - .withMessage( - "the pollutant value is not among the expected ones which include: no2, pm2_5, pm10, pm1" - ), - body("frequency") - .optional() - .notEmpty() - .withMessage("the provided frequency should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["daily", "hourly", "monthly"]) - .withMessage( - "the frequency value is not among the expected ones which include: daily, hourly and monthly" - ), - body("chartType") - .optional() - .notEmpty() - .withMessage("the provided chartType should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bar", "line", "pie"]) - .withMessage( - "the chartType value is not among the expected ones which include: bar, line and pie" - ), - body("startDate") - .optional() - .notEmpty() - .withMessage("the provided startDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("startDate must be a valid datetime."), - body("endDate") - .optional() - .notEmpty() - .withMessage("the provided endDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("endDate must be a valid datetime."), - - body("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("cohort_id") - .optional() - .notEmpty() - .withMessage("the provided cohort_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the cohort_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("grid_id") - .optional() - .notEmpty() - .withMessage("the provided grid_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the grid_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("period") - .optional() - .notEmpty() - .withMessage("the provided period should not be empty IF provided") - .bail() - .custom((value) => { - return typeof value === "object"; - }) - .withMessage("the period should be an object"), - body("chartSubTitle") - .optional() - .notEmpty() - .withMessage( - "the provided chartSubTitle should not be empty IF provided" - ) - .bail() - .trim(), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("site_ids") - .optional() - .notEmpty() - .withMessage("the provided site_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_ids should be an array"), - body("site_ids.*") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID"), - body("device_ids") - .optional() - .notEmpty() - .withMessage("the provided device_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the device_ids should be an array"), - body("device_ids.*") - .optional() - .notEmpty() - .withMessage("the provided device_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("device_id must be an object ID"), - body("selected_sites") - .optional() - .notEmpty() - .withMessage("the selected_sites should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the selected_sites should be an array"), - // body("selected_sites.*") - // .optional() - // .custom( - // createValidateSelectedSitesField(["_id", "search_name", "name"], true) - // ) - // .withMessage( - // "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." - // ), - ], - ]), + validateTenant(), + body("user_id") + .exists() + .withMessage("the user_id should be provided in the request body") + .bail() + .notEmpty() + .withMessage("the provided user_id should not be empty") + .bail() + .trim() + .isMongoId() + .withMessage("the user_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + validatePreferences(), + validateSelectedSites(["_id", "search_name", "name"], true), createPreferenceController.upsert ); router.patch( "/replace", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("user_id") - .exists() - .withMessage("the user_id should be provided in the request body") - .bail() - .notEmpty() - .withMessage("the provided user_id should not be empty") - .bail() - .trim() - .isMongoId() - .withMessage("the user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("pollutant") - .optional() - .notEmpty() - .withMessage("the provided pollutant should not be empty IF provided") - .bail() - .trim() - .isIn(["no2", "pm2_5", "pm10", "pm1"]) - .withMessage( - "the pollutant value is not among the expected ones which include: no2, pm2_5, pm10, pm1" - ), - body("frequency") - .optional() - .notEmpty() - .withMessage("the provided frequency should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["daily", "hourly", "monthly"]) - .withMessage( - "the frequency value is not among the expected ones which include: daily, hourly and monthly" - ), - body("chartType") - .optional() - .notEmpty() - .withMessage("the provided chartType should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bar", "line", "pie"]) - .withMessage( - "the chartType value is not among the expected ones which include: bar, line and pie" - ), - body("startDate") - .optional() - .notEmpty() - .withMessage("the provided startDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("startDate must be a valid datetime."), - body("endDate") - .optional() - .notEmpty() - .withMessage("the provided endDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("endDate must be a valid datetime."), - - body("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("cohort_id") - .optional() - .notEmpty() - .withMessage("the provided cohort_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the cohort_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("grid_id") - .optional() - .notEmpty() - .withMessage("the provided grid_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the grid_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("period") - .optional() - .notEmpty() - .withMessage("the provided period should not be empty IF provided") - .bail() - .custom((value) => { - return typeof value === "object"; - }) - .withMessage("the period should be an object"), - body("chartSubTitle") - .optional() - .notEmpty() - .withMessage( - "the provided chartSubTitle should not be empty IF provided" - ) - .bail() - .trim(), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("site_ids") - .optional() - .notEmpty() - .withMessage("the provided site_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_ids should be an array"), - body("site_ids.*") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID"), - body("device_ids") - .optional() - .notEmpty() - .withMessage("the provided device_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the device_ids should be an array"), - body("device_ids.*") - .optional() - .notEmpty() - .withMessage("the provided device_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("device_id must be an object ID"), - body("selected_sites") - .optional() - .notEmpty() - .withMessage("the selected_sites should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the selected_sites should be an array"), - // body("selected_sites.*") - // .optional() - // .custom( - // createValidateSelectedSitesField(["_id", "search_name", "name"], true) - // ), - ], - ]), + validateTenant(), + body("user_id") + .exists() + .withMessage("the user_id should be provided in the request body") + .bail() + .notEmpty() + .withMessage("the provided user_id should not be empty") + .bail() + .trim() + .isMongoId() + .withMessage("the user_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + validatePreferences(), + validateSelectedSites(["_id", "search_name", "name"], true), createPreferenceController.replace ); router.put( "/:user_id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("user_id") - .exists() - .withMessage( - "the record's identifier is missing in request, consider using the user_id" - ) - .bail() - .trim() - .isMongoId() - .withMessage("user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), - oneOf([ - [ - body("pollutant") - .optional() - .notEmpty() - .withMessage("the provided pollutant should not be empty IF provided") - .bail() - .trim() - .isIn(["no2", "pm2_5", "pm10", "pm1"]) - .withMessage( - "the pollutant value is not among the expected ones which include: no2, pm2_5, pm10, pm1" - ), - body("frequency") - .optional() - .notEmpty() - .withMessage("the provided frequency should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["daily", "hourly", "monthly"]) - .withMessage( - "the frequency value is not among the expected ones which include: daily, hourly and monthly" - ), - body("chartType") - .optional() - .notEmpty() - .withMessage("the provided chartType should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bar", "line", "pie"]) - .withMessage( - "the chartType value is not among the expected ones which include: bar, line and pie" - ), - body("startDate") - .optional() - .notEmpty() - .withMessage("the provided startDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("startDate must be a valid datetime."), - body("endDate") - .optional() - .notEmpty() - .withMessage("the provided endDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("endDate must be a valid datetime."), - - body("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("cohort_id") - .optional() - .notEmpty() - .withMessage("the provided cohort_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the cohort_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("grid_id") - .optional() - .notEmpty() - .withMessage("the provided grid_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the grid_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("period") - .optional() - .notEmpty() - .withMessage("the provided period should not be empty IF provided") - .bail() - .custom((value) => { - return typeof value === "object"; - }) - .withMessage("the period should be an object"), - body("chartSubTitle") - .optional() - .notEmpty() - .withMessage( - "the provided chartSubTitle should not be empty IF provided" - ) - .bail() - .trim(), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .bail() - .trim(), - body("site_ids") - .optional() - .notEmpty() - .withMessage("the provided site_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_ids should be an array"), - body("site_ids.*") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID"), - body("device_ids") - .optional() - .notEmpty() - .withMessage("the provided device_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the device_ids should be an array"), - body("device_ids.*") - .optional() - .notEmpty() - .withMessage("the provided device_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("device_id must be an object ID"), - body("selected_sites") - .optional() - .notEmpty() - .withMessage("the selected_sites should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the selected_sites should be an array"), - // body("selected_sites.*") - // .optional() - // .custom( - // createValidateSelectedSitesField(["_id", "search_name", "name"], true) - // ) - // .withMessage( - // "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." - // ), - ], - ]), + validateTenant(), + param("user_id") + .exists() + .withMessage( + "the record's identifier is missing in request, consider using the user_id" + ) + .bail() + .trim() + .isMongoId() + .withMessage("user_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + validatePreferences(), + validateSelectedSites(["_id", "search_name", "name"], true), createPreferenceController.update ); router.post( "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("pollutant") - .optional() - .notEmpty() - .withMessage("the provided pollutant should not be empty IF provided") - .bail() - .trim() - .isIn(["no2", "pm2_5", "pm10", "pm1"]) - .withMessage( - "the pollutant value is not among the expected ones which include: no2, pm2_5, pm10, pm1" - ), - body("frequency") - .optional() - .notEmpty() - .withMessage("the provided frequently should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["daily", "hourly", "monthly"]) - .withMessage( - "the frequency value is not among the expected ones which include: daily, hourly and monthly" - ), - body("chartType") - .optional() - .notEmpty() - .withMessage("the provided chartType should not be empty IF provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bar", "line", "pie"]) - .withMessage( - "the chartType value is not among the expected ones which include: bar, line and pie" - ), - body("startDate") - .optional() - .notEmpty() - .withMessage("the provided startDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("startDate must be a valid datetime."), - body("endDate") - .optional() - .notEmpty() - .withMessage("the provided endDate should not be empty IF provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("endDate must be a valid datetime."), - body("user_id") - .exists() - .withMessage("the user_id should be provided in the request body") - .bail() - .notEmpty() - .withMessage("the provided user_id should not be empty") - .bail() - .trim() - .isMongoId() - .withMessage("the user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("chartTitle") - .optional() - .notEmpty() - .withMessage("the provided chartTitle should not be empty IF provided") - .trim(), - body("period") - .optional() - .notEmpty() - .withMessage("the provided period should not be empty IF provided") - .bail() - .custom((value) => { - return typeof value === "object"; - }) - .bail() - .withMessage("the period should be an object"), - body("chartSubTitle") - .optional() - .notEmpty() - .withMessage( - "the provided chartSubTitle should not be empty IF provided" - ) - .trim(), - body("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("site_ids") - .optional() - .notEmpty() - .withMessage("the provided site_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_ids should be an array"), - body("site_ids.*") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID"), - body("device_ids") - .optional() - .notEmpty() - .withMessage("the provided device_ids should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the device_ids should be an array"), - body("device_ids.*") - .optional() - .notEmpty() - .withMessage("the provided device_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("device_id must be an object ID"), - body("selected_sites") - .optional() - .notEmpty() - .withMessage("the selected_sites should not be empty IF provided") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the selected_sites should be an array"), - // body("selected_sites.*") - // .optional() - // .custom( - // createValidateSelectedSitesField(["_id", "search_name", "name"], true) - // ) - // .withMessage( - // "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." - // ), - ], - ]), + validateTenant(), + body("user_id") + .exists() + .withMessage("the user_id should be provided in the request body") + .bail() + .notEmpty() + .withMessage("the provided user_id should not be empty") + .bail() + .trim() + .isMongoId() + .withMessage("the user_id must be an object ID") + .bail() + .customSanitizer((value) => ObjectId(value)), + validatePreferences(), + validateSelectedSites(["_id", "search_name", "name"], true), createPreferenceController.create ); router.get( "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - query("id") - .optional() - .notEmpty() - .withMessage("the provided id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("user_id") - .optional() - .notEmpty() - .withMessage("the provided user_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("cohort_id") - .optional() - .notEmpty() - .withMessage("the provided cohort_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the cohort_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("grid_id") - .optional() - .notEmpty() - .withMessage("the provided grid_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the grid_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("site_id") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the site_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ], - ]), + validateTenant(), + validatePreferences(), createPreferenceController.list ); router.delete( "/:user_id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("user_id") - .exists() - .withMessage("the the user_id is missing in request") - .bail() - .trim() - .isMongoId() - .withMessage("user_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), + validateTenant(), + param("user_id") + .exists() + .withMessage("the the user_id is missing in request") + .bail() + .trim() + .isMongoId() + .withMessage("user_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), setJWTAuth, authJWT, createPreferenceController.delete ); router.get( "/selected-sites", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - query("airqloud_id") - .optional() - .notEmpty() - .withMessage("the provided airqloud_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the airqloud_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("cohort_id") - .optional() - .notEmpty() - .withMessage("the provided cohort_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the cohort_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("grid_id") - .optional() - .notEmpty() - .withMessage("the provided grid_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the grid_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("site_id") - .optional() - .notEmpty() - .withMessage("the provided site_id should not be empty IF provided") - .bail() - .trim() - .isMongoId() - .withMessage("the site_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ], - ]), + validateTenant(), + validatePreferences(), createPreferenceController.listSelectedSites ); router.post( "/selected-sites", - validateUniqueFieldsInSelectedSites, - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("selected_sites") - .exists() - .withMessage("selected_sites should be provided") - .bail() - .isArray() - .withMessage("selected_sites should be an array") - .bail() - .notEmpty() - .withMessage("selected_sites should not be empty"), - // body("selected_sites.*").custom( - // createValidateSelectedSitesField( - // ["site_id", "search_name", "name"], - // false - // ) - // ), - ], - ]), + validateTenant(), + validateSelectedSites(["site_id", "search_name", "name"], false), setJWTAuth, authJWT, createPreferenceController.addSelectedSites ); router.put( "/selected-sites/:site_id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - param("site_id") - .exists() - .withMessage("the site_id parameter is required") - .bail() - .isMongoId() - .withMessage("site_id must be a valid MongoDB ObjectId") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - // body("selected_site") - // .custom(createValidateSelectedSitesField([], false)) - // .withMessage( - // "Invalid selected site data. Verify required fields and data types." - // ), - ], - ]), + validateTenant(), + param("site_id") + .exists() + .withMessage("the site_id parameter is required") + .bail() + .isMongoId() + .withMessage("site_id must be a valid MongoDB ObjectId") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + validateSelectedSites([], false), setJWTAuth, authJWT, createPreferenceController.updateSelectedSite ); router.delete( "/selected-sites/:site_id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - param("site_id") - .exists() - .withMessage("the site_id parameter is required") - .bail() - .isMongoId() - .withMessage("site_id must be a valid MongoDB ObjectId") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ], - ]), + validateTenant(), + param("site_id") + .exists() + .withMessage("the site_id parameter is required") + .bail() + .isMongoId() + .withMessage("site_id must be a valid MongoDB ObjectId") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), setJWTAuth, authJWT, createPreferenceController.deleteSelectedSite ); router.get( "/:user_id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - param("user_id") - .exists() - .withMessage("the user ID param is missing in the request") - .bail() - .trim() - .isMongoId() - .withMessage("the user ID must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ], - ]), + validateTenant(), + param("user_id") + .exists() + .withMessage("the user ID param is missing in the request") + .bail() + .trim() + .isMongoId() + .withMessage("the user ID must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), setJWTAuth, authJWT, createPreferenceController.list diff --git a/src/device-registry/bin/server.js b/src/device-registry/bin/server.js index 1c31b52a66..cae0c11909 100644 --- a/src/device-registry/bin/server.js +++ b/src/device-registry/bin/server.js @@ -13,7 +13,7 @@ connectToMongoDB(); const morgan = require("morgan"); const compression = require("compression"); const helmet = require("helmet"); -const { HttpError } = require("@utils/errors"); +const { HttpError, BadRequestError } = require("@utils/errors"); const isDev = process.env.NODE_ENV === "development"; const isProd = process.env.NODE_ENV === "production"; const options = { mongooseConnection: mongoose.connection }; @@ -83,6 +83,12 @@ app.use(function(err, req, res, next) { message: err.message, errors: err.errors, }); + } else if (err instanceof BadRequestError) { + return res.status(err.statusCode).json({ + success: false, + message: err.message, + errors: err.errors, + }); } else if (err instanceof SyntaxError) { res.status(400).json({ success: false, @@ -136,9 +142,9 @@ app.use(function(err, req, res, next) { logger.error(`Internal Server Error --- ${stringify(err)}`); logObject("Internal Server Error", err); logger.error(`Stack Trace: ${err.stack}`); - res.status(err.status || 500).json({ + res.status(err.statusCode || err.status || 500).json({ success: false, - message: "Internal Server Error - app entry", + message: err.message || "Internal Server Error", errors: { message: err.message }, }); } diff --git a/src/device-registry/middleware/test/ut_validateOptionalObjectId.js b/src/device-registry/middleware/test/ut_validateOptionalObjectId.js new file mode 100644 index 0000000000..5b5d62eb44 --- /dev/null +++ b/src/device-registry/middleware/test/ut_validateOptionalObjectId.js @@ -0,0 +1,100 @@ +require("module-alias/register"); +const { expect } = require("chai"); +const sinon = require("sinon"); +const mongoose = require("mongoose"); +const { BadRequestError } = require("@utils/errors"); +const validateOptionalObjectId = require("@middleware/validateOptionalObjectId"); + +describe("validateOptionalObjectId", () => { + let req, res, next; + + beforeEach(() => { + req = { + query: {}, + }; + res = {}; + next = sinon.spy(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call next() if the field is not present in the query", () => { + const middleware = validateOptionalObjectId("testField"); + middleware(req, res, next); + expect(next.calledOnce).to.be.true; + expect(next.args[0]).to.be.empty; + }); + + it("should validate a single valid ObjectId", () => { + const validObjectId = new mongoose.Types.ObjectId().toString(); + req.query.testField = validObjectId; + const middleware = validateOptionalObjectId("testField"); + middleware(req, res, next); + expect(next.calledOnce).to.be.true; + expect(next.args[0]).to.be.empty; + }); + + it("should validate multiple valid ObjectIds", () => { + const validObjectIds = [ + new mongoose.Types.ObjectId().toString(), + new mongoose.Types.ObjectId().toString(), + ]; + req.query.testField = validObjectIds.join(","); + const middleware = validateOptionalObjectId("testField"); + middleware(req, res, next); + expect(next.calledOnce).to.be.true; + expect(next.args[0]).to.be.empty; + }); + + it("should throw BadRequestError for a single invalid ObjectId", () => { + req.query.testField = "invalidObjectId"; + const middleware = validateOptionalObjectId("testField"); + expect(() => middleware(req, res, next)).to.throw(BadRequestError); + expect(next.called).to.be.false; + try { + middleware(req, res, next); + } catch (error) { + expect(error).to.be.instanceOf(BadRequestError); + expect(error.message).to.equal("Validation failed for testField"); + expect(error.errors).to.deep.equal([ + "Invalid testField format: invalidObjectId", + ]); + } + }); + + it("should throw BadRequestError for multiple ObjectIds with some invalid", () => { + const validObjectId = new mongoose.Types.ObjectId().toString(); + req.query.testField = `${validObjectId},invalidObjectId`; + const middleware = validateOptionalObjectId("testField"); + expect(() => middleware(req, res, next)).to.throw(BadRequestError); + expect(next.called).to.be.false; + try { + middleware(req, res, next); + } catch (error) { + expect(error).to.be.instanceOf(BadRequestError); + expect(error.message).to.equal("Validation failed for testField"); + expect(error.errors).to.deep.equal([ + "Invalid testField format: invalidObjectId", + ]); + } + }); + + it("should handle an array of ObjectIds in the query", () => { + const validObjectId = new mongoose.Types.ObjectId().toString(); + req.query.testField = [validObjectId, "invalidObjectId"]; + const middleware = validateOptionalObjectId("testField"); + expect(() => middleware(req, res, next)).to.throw(BadRequestError); + expect(next.called).to.be.false; + try { + middleware(req, res, next); + } catch (error) { + expect(error).to.be.instanceOf(BadRequestError); + expect(error.message).to.equal("Validation failed for testField"); + expect(error.errors).to.deep.equal([ + "Invalid testField format: invalidObjectId", + ]); + } + }); +}); diff --git a/src/device-registry/middleware/validateOptionalObjectId.js b/src/device-registry/middleware/validateOptionalObjectId.js new file mode 100644 index 0000000000..d6c49023c8 --- /dev/null +++ b/src/device-registry/middleware/validateOptionalObjectId.js @@ -0,0 +1,32 @@ +const { isValidObjectId } = require("mongoose"); +const { BadRequestError } = require("@utils/errors"); + +const validateOptionalObjectId = (field) => { + return (req, res, next) => { + if (req.query[field]) { + let values; + if (Array.isArray(req.query[field])) { + values = req.query[field]; + } else { + values = req.query[field].toString().split(","); + } + + const errors = []; + for (const value of values) { + if (!isValidObjectId(value)) { + errors.push(`Invalid ${field} format: ${value}`); + } + } + + if (errors.length > 0) { + throw new BadRequestError({ + message: `Validation failed for ${field}`, + errors: errors, + }); + } + } + next(); + }; +}; + +module.exports = validateOptionalObjectId; diff --git a/src/device-registry/routes/v2/readings.js b/src/device-registry/routes/v2/readings.js index 689727b1db..19c2d4cea4 100644 --- a/src/device-registry/routes/v2/readings.js +++ b/src/device-registry/routes/v2/readings.js @@ -4,41 +4,8 @@ const eventController = require("@controllers/create-event"); const constants = require("@config/constants"); const mongoose = require("mongoose"); const ObjectId = mongoose.Types.ObjectId; -const { logElement, logText, logObject } = require("@utils/log"); -const NetworkModel = require("@models/Network"); -const { - check, - oneOf, - query, - body, - param, - validationResult, -} = require("express-validator"); - -const decimalPlaces = require("decimal-places"); -const numeral = require("numeral"); - -// Define a custom function to check if a value is a valid ObjectId -const isValidObjectId = (value) => { - return mongoose.Types.ObjectId.isValid(value); -}; - -const addCategoryQueryParam = (req, res, next) => { - req.query.path = "public"; - next(); -}; - -const validNetworks = async () => { - const networks = await NetworkModel("airqo").distinct("name"); - return networks.map((network) => network.toLowerCase()); -}; - -const validateNetwork = async (value) => { - const networks = await validNetworks(); - if (!networks.includes(value.toLowerCase())) { - throw new Error("Invalid network"); - } -}; +const { oneOf, query, validationResult } = require("express-validator"); +const validateOptionalObjectId = require("@middleware/validateOptionalObjectId"); const validatePagination = (req, res, next) => { let limit = parseInt(req.query.limit, 10); @@ -57,40 +24,6 @@ const validatePagination = (req, res, next) => { next(); }; -// Custom validation function to check if values are valid MongoDB ObjectIds -const isValidObjectIds = (value) => { - const ids = value.split(","); - return ids.every((id) => /^[0-9a-fA-F]{24}$/.test(id)); // Check if each ID is a valid ObjectId -}; - -// Middleware for validation -const validateObjectId = (paramName) => { - return [ - query(paramName) - .custom(isValidObjectIds) - .withMessage(`Invalid ${paramName}`), - ]; -}; - -const validateOptionalObjectId = (field) => { - return (req, res, next) => { - if (req.query[field]) { - let values; - if (Array.isArray(req.query[field])) { - values = req.query[field]; - } else { - values = req.query[field].toString().split(","); - } - for (const value of values) { - if (!isValidObjectId(value)) { - throw new Error(`Invalid ${field} format: ${value}`); - } - } - } - next(); - }; -}; - const headers = (req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.header( @@ -244,7 +177,7 @@ router.get( query("device_id") .optional() .notEmpty() - .withMessage("the provided device_id cannot be empty IF provided") + .withMessage("device_id cannot be empty IF provided") .trim(), query("lat_long") .optional() @@ -277,8 +210,7 @@ router.get( query("site_id") .optional() .notEmpty() - .withMessage("the provided site_id cannot be empty IF provided") - .trim(), + .withMessage("site_id cannot be empty IF provided"), query("primary") .optional() .notEmpty() diff --git a/src/device-registry/utils/errors.js b/src/device-registry/utils/errors.js index d664fd1d60..19a4582b02 100644 --- a/src/device-registry/utils/errors.js +++ b/src/device-registry/utils/errors.js @@ -12,6 +12,15 @@ class HttpError extends Error { } } +class BadRequestError extends Error { + constructor({ message, errors }) { + super(message); + this.name = "BadRequestError"; + this.statusCode = 400; + this.errors = errors; + } +} + const convertErrorArrayToObject = (arrays) => { const initialValue = {}; return arrays.reduce((obj, item) => { @@ -42,5 +51,6 @@ const extractErrorsFromRequest = (req) => { module.exports = { HttpError, + BadRequestError, extractErrorsFromRequest, };