diff --git a/k8s/analytics/values-prod.yaml b/k8s/analytics/values-prod.yaml index 71693499d2..95a2d215e8 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 api: name: airqo-analytics-api label: analytics-api diff --git a/k8s/device-registry/values-prod.yaml b/k8s/device-registry/values-prod.yaml index 2e7c93671f..d63a9d8eac 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-stage.yaml b/k8s/device-registry/values-stage.yaml index a7db8ac00a..c24cd08854 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-a67d93c7-1733998369 + tag: stage-04fa3ebc-1734023297 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/exceedance/values-prod-airqo.yaml b/k8s/exceedance/values-prod-airqo.yaml index 363a966dd5..7ad3db6bb6 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' diff --git a/k8s/exceedance/values-prod-kcca.yaml b/k8s/exceedance/values-prod-kcca.yaml index 785b4582de..8a9cbded63 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' diff --git a/k8s/predict/values-prod.yaml b/k8s/predict/values-prod.yaml index 354e448b2f..81a044d332 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 api: name: airqo-prediction-api label: prediction-api diff --git a/k8s/spatial/values-prod.yaml b/k8s/spatial/values-prod.yaml index 1d2410ee20..c387dd85dc 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/website/values-prod.yaml b/k8s/website/values-prod.yaml index b43069acfc..5fa30b847f 100644 --- a/k8s/website/values-prod.yaml +++ b/k8s/website/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-website-api - tag: prod-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/workflows/values-prod.yaml b/k8s/workflows/values-prod.yaml index 3e839b93ad..d975324084 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-3cb7d03d-1734014240 + tag: prod-48e4e72c-1734023346 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/src/device-registry/controllers/create-site.js b/src/device-registry/controllers/create-site.js index 8405801f11..610959c094 100644 --- a/src/device-registry/controllers/create-site.js +++ b/src/device-registry/controllers/create-site.js @@ -492,6 +492,59 @@ const manageSite = { return; } }, + updateManySites: async (req, res, next) => { + try { + logText("the BULK update operation starts...."); + 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; + + const result = await createSiteUtil.updateManySites(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + return res.status(status).json({ + message: result.message, + success: true, + bulk_update_notes: result.data, + metadata: result.metadata, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + return res.status(status).json({ + message: result.message, + success: false, + errors: result.errors ? result.errors : { message: "" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, refresh: async (req, res, next) => { try { logText("refreshing site details................"); diff --git a/src/device-registry/models/Site.js b/src/device-registry/models/Site.js index 3e0946db40..8e58617e34 100644 --- a/src/device-registry/models/Site.js +++ b/src/device-registry/models/Site.js @@ -12,6 +12,15 @@ const { getModelByTenant } = require("@config/database"); const log4js = require("log4js"); const logger = log4js.getLogger(`${constants.ENVIRONMENT} -- site-model`); +function sanitizeObject(obj, invalidKeys) { + invalidKeys.forEach((key) => { + if (Object.hasOwn(obj, key)) { + delete obj[key]; + } + }); + return obj; +} + const categorySchema = new Schema( { area_name: { type: String }, @@ -804,6 +813,61 @@ siteSchema.statics = { ); } }, + async bulkModify({ filter = {}, update = {}, opts = {} }, next) { + try { + const invalidKeys = [ + "_id", + "longitude", + "latitude", + "lat_long", + "generated_name", + ]; + const sanitizedUpdate = sanitizeObject(update, invalidKeys); + + // Perform bulk update with additional options + const bulkUpdateResult = await this.updateMany( + filter, + { $set: sanitizedUpdate }, + { + new: true, + runValidators: true, + ...opts, + } + ); + + if (bulkUpdateResult.nModified > 0) { + return { + success: true, + message: `Successfully modified ${bulkUpdateResult.nModified} sites`, + data: { + modifiedCount: bulkUpdateResult.nModified, + matchedCount: bulkUpdateResult.n, + }, + status: httpStatus.OK, + }; + } else { + return { + success: true, + message: "No sites were updated", + data: { + modifiedCount: 0, + matchedCount: bulkUpdateResult.n, + }, + status: httpStatus.OK, + }; + } + } catch (error) { + const stringifiedMessage = JSON.stringify(error ? error : ""); + logger.error(`🐛🐛 Bulk Modify Sites Error -- ${stringifiedMessage}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, async remove({ filter = {} } = {}, next) { try { let options = { diff --git a/src/device-registry/routes/v2/sites.js b/src/device-registry/routes/v2/sites.js index 8117e500d0..2cd5fe0c5a 100644 --- a/src/device-registry/routes/v2/sites.js +++ b/src/device-registry/routes/v2/sites.js @@ -1,14 +1,21 @@ const express = require("express"); const router = express.Router(); const siteController = require("@controllers/create-site"); -const { oneOf, query, body } = require("express-validator"); -const constants = require("@config/constants"); -const mongoose = require("mongoose"); -const ObjectId = mongoose.Types.ObjectId; -const numeral = require("numeral"); -const createSiteUtil = require("@utils/create-site"); -const { logText, logObject } = require("@utils/log"); -const decimalPlaces = require("decimal-places"); + +const { + validateTenant, + validateSiteQueryParams, + validateMandatorySiteIdentifier, + validateCreateSite, + validateSiteMetadata, + validateUpdateSite, + validateRefreshSite, + validateDeleteSite, + validateCreateApproximateCoordinates, + validateGetApproximateCoordinates, + validateNearestSite, + validateBulkUpdateDevices, +} = require("@validators/site.validators"); const validatePagination = (req, res, next) => { let limit = parseInt(req.query.limit, 10); @@ -48,1062 +55,56 @@ const headers = (req, res, next) => { } }; -function validateCategoryField(value) { - const requiredFields = ["category", "search_radius", "tags"]; - - // Check if all required fields exist - if (!requiredFields.every((field) => field in value)) { - return false; - } - - // Validate numeric fields - const numericFields = ["latitude", "longitude", "search_radius"]; - let isValid = true; - - numericFields.forEach((field) => { - if (!(field in value)) { - isValid = false; - return; - } - const numValue = parseFloat(value[field]); - if (isNaN(numValue)) { - isValid = false; - return; - } else if (field === "latitude" || field === "longitude") { - if (Math.abs(numValue) > 90) { - isValid = false; - return; - } - } else if (field === "search_radius") { - if (numValue <= 0) { - isValid = false; - return; - } - } - }); - - // Validate tags array - if ("tags" in value && !Array.isArray(value.tags)) { - return false; - } - value.tags.forEach((tag) => { - if (typeof tag !== "string" || tag.trim() === "") { - return false; - } - }); - - // All validations passed - return isValid; -} - router.use(headers); router.use(validatePagination); /****************************** create sites use-case *************** */ -router.get( - "/", - 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([ - [ - query("id") - .optional() - .notEmpty() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("site_id") - .optional() - .notEmpty() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("name") - .optional() - .notEmpty() - .trim(), - query("online_status") - .optional() - .notEmpty() - .withMessage("the online_status should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["online", "offline"]) - .withMessage( - "the online_status value is not among the expected ones which include: online, offline" - ), - query("category") - .optional() - .notEmpty() - .withMessage("the category should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bam", "lowcost", "gas"]) - .withMessage( - "the category value is not among the expected ones which include: lowcost, gas and bam" - ), - query("site_category") - .optional() - .notEmpty() - .withMessage("the site_category should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["category", "search_radius", "tags"]) - .withMessage( - "the site_category value is not among the expected ones which include: category, search_radius, tags" - ), - query("last_active_before") - .optional() - .notEmpty() - .withMessage("last_active_before date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active_before date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - query("last_active_after") - .optional() - .notEmpty() - .withMessage("last_active_after date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active_after date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - query("last_active") - .optional() - .notEmpty() - .withMessage("last_active date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - ], - ]), - siteController.list -); +router.get("/", validateTenant, validateSiteQueryParams, siteController.list); router.get( "/summary", - 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([ - [ - query("id") - .optional() - .notEmpty() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("site_id") - .optional() - .notEmpty() - .trim() - .isMongoId() - .withMessage("site_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("name") - .optional() - .notEmpty() - .trim(), - query("online_status") - .optional() - .notEmpty() - .withMessage("the online_status should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["online", "offline"]) - .withMessage( - "the online_status value is not among the expected ones which include: online, offline" - ), - query("category") - .optional() - .notEmpty() - .withMessage("the category should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["bam", "lowcost", "gas"]) - .withMessage( - "the category value is not among the expected ones which include: lowcost, gas and bam" - ), - query("site_category") - .optional() - .notEmpty() - .withMessage("the site_category should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["category", "search_radius", "tags"]) - .withMessage( - "the site_category value is not among the expected ones which include: category, search_radius, tags" - ), - query("last_active_before") - .optional() - .notEmpty() - .withMessage("last_active_before date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active_before date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - query("last_active_after") - .optional() - .notEmpty() - .withMessage("last_active_after date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active_after date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - query("last_active") - .optional() - .notEmpty() - .withMessage("last_active date cannot be empty IF provided") - .bail() - .trim() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage( - "last_active date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." - ) - .bail() - .toDate(), - ], - ]), + validateTenant, + validateSiteQueryParams, siteController.listSummary ); -router.get("/weather", siteController.listWeatherStations); +router.get("/weather", validateTenant, siteController.listWeatherStations); router.get( "/weather/nearest", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - query("id") - .exists() - .withMessage( - "the site identifier is missing in request, consider using id" - ) - .bail() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("lat_long") - .exists() - .withMessage( - "the site identifier is missing in request, consider using lat_long" - ) - .bail() - .trim(), - query("generated_name") - .exists() - .withMessage( - "the site identifier is missing in request, consider using generated_name" - ) - .bail() - .trim(), - ]), + validateTenant, + validateMandatorySiteIdentifier, siteController.listNearestWeatherStation ); router.get( "/airqlouds/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - query("id") - .exists() - .withMessage( - "the site identifier is missing in request, consider using id" - ) - .bail() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("lat_long") - .exists() - .withMessage( - "the site identifier is missing in request, consider using lat_long" - ) - .bail() - .trim(), - query("generated_name") - .exists() - .withMessage( - "the site identifier is missing in request, consider using generated_name" - ) - .bail() - .trim(), - ]), + validateTenant, + validateMandatorySiteIdentifier, siteController.findAirQlouds ); -router.post( - "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("latitude") - .exists() - .withMessage("the latitude is is missing in your request") - .bail() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("the latitude provided is not valid") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 5) { - return Promise.reject( - "the latitude must have 5 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - body("longitude") - .exists() - .withMessage("the longitude is is missing in your request") - .bail() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("the longitude provided is not valid") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 5) { - return Promise.reject( - "the longitude must have 5 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - body("name") - .exists() - .withMessage("the name is is missing in your request") - .bail() - .trim() - .custom((value) => { - return createSiteUtil.validateSiteName(value); - }) - .withMessage( - "The name should be greater than 5 and less than 50 in length" - ), - body("site_tags") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_tags should be an array") - .bail() - .notEmpty() - .withMessage("the site_tags should not be empty"), - body("groups") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the groups should be an array") - .bail() - .notEmpty() - .withMessage("the groups should not be empty"), - body("airqlouds") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the airqlouds should be an array") - .bail() - .notEmpty() - .withMessage("the airqlouds should not be empty"), - body("airqlouds.*") - .optional() - .isMongoId() - .withMessage("each airqloud should be a mongo ID"), - body("site_category") - .optional() - .custom(validateCategoryField) - .withMessage( - "Invalid site_category format, crosscheck the types or content of all the provided nested fields. latitude, longitude & search_radius should be numbers. tags should be an array of strings. category, search_tags & search_radius are required fields" - ), - ], - ]), - siteController.register -); +router.post("/", validateTenant, validateCreateSite, siteController.register); router.post( "/metadata", - oneOf([ - [ - body("latitude") - .exists() - .withMessage("the latitude should be provided") - .bail() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("the latitude provided is not valid") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the latitude must have 2 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - body("longitude") - .exists() - .withMessage("the longitude is is missing in your request") - .bail() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("the longitude should be provided") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the longitude must have 2 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - ], - ]), + validateTenant, + validateSiteMetadata, siteController.generateMetadata ); router.put( "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - query("id") - .exists() - .withMessage( - "the site identifier is missing in request, consider using id" - ) - .bail() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("lat_long") - .exists() - .withMessage( - "the site identifier is missing in request, consider using lat_long" - ) - .bail() - .trim(), - query("generated_name") - .exists() - .withMessage( - "the site identifier is missing in request, consider using generated_name" - ) - .bail() - .trim(), - ]), - oneOf([ - [ - body("status") - .optional() - .notEmpty() - .trim() - .toLowerCase() - .isIn(["active", "decommissioned"]) - .withMessage( - "the status value is not among the expected ones which include: decommissioned, active" - ), - body("visibility") - .optional() - .notEmpty() - .withMessage("visibility cannot be empty IF provided") - .bail() - .trim() - .isBoolean() - .withMessage("visibility must be Boolean"), - body("nearest_tahmo_station") - .optional() - .notEmpty() - .custom((value) => { - return typeof value === "object"; - }) - .bail() - .withMessage("the nearest_tahmo_station should be an object"), - body("createdAt") - .optional() - .notEmpty() - .withMessage("createdAt cannot be empty when provided") - .bail() - .trim() - .toDate() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("createdAt date must be a valid datetime."), - body("location_id") - .optional() - .notEmpty() - .trim() - .isMongoId() - .withMessage("the location_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - body("distance_to_nearest_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_road must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_primary_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_primary_road must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_secondary_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_secondary_road must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_tertiary_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_tertiary_road must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_unclassified_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_unclassified_road must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_residential_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_residential_road must be a number") - .bail() - .toFloat(), - body("bearing_to_kampala_center") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("bearing_to_kampala_center must be a number") - .bail() - .toFloat(), - body("distance_to_kampala_center") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_kampala_center must be a number") - .bail() - .toFloat(), - body("bearing_to_capital_city_center") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("bearing_to_capital_city_center must be a number") - .bail() - .toFloat(), - body("distance_to_capital_city_center") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_capital_city_center must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_residential_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_residential_road must be a number") - .bail() - .toFloat(), - body(" distance_to_nearest_city") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage(" distance_to_nearest_city must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_motorway") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_motorway must be a number") - .bail() - .toFloat(), - body("distance_to_nearest_road") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("distance_to_nearest_road must be a number") - .bail() - .toFloat(), - body("landform_270") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("landform_270 must be a number") - .bail() - .toFloat(), - body("landform_90") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("landform_90 must be a number") - .bail() - .toFloat(), - body("greenness") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("greenness must be a number") - .bail() - .toFloat(), - body("altitude") - .optional() - .notEmpty() - .trim() - .isFloat() - .withMessage("altitude must be a number") - .bail() - .toFloat(), - body("city") - .optional() - .notEmpty() - .trim(), - body("street") - .optional() - .notEmpty() - .trim(), - body("latitude") - .optional() - .notEmpty() - .trim() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("please provide valid latitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 5) { - return Promise.reject( - "the latitude must have 5 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - body("longitude") - .optional() - .notEmpty() - .trim() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("please provide valid longitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 5) { - return Promise.reject( - "the longitude must have 5 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - body("description") - .optional() - .notEmpty() - .trim(), - body("data_provider") - .optional() - .notEmpty() - .withMessage("the data_provider should not be empty") - .trim(), - body("site_tags") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the site_tags should be an array") - .bail() - .notEmpty() - .withMessage("the site_tags should not be empty"), - body("airqlouds") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the airqlouds should be an array") - .bail() - .notEmpty() - .withMessage("the airqlouds should not be empty"), - body("airqlouds.*") - .optional() - .isMongoId() - .withMessage("each airqloud should be a mongo ID"), - body("site_category") - .optional() - .custom(validateCategoryField) - .withMessage( - "Invalid site_category format, crosscheck the types or content of all the provided nested fields. latitude, longitude & search_radius should be numbers. tags should be an array of strings. category, search_tags & search_radius are required fields" - ), - body("groups") - .optional() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the groups should be an array") - .bail() - .notEmpty() - .withMessage("the groups should not be empty"), - ], - ]), + validateTenant, + validateMandatorySiteIdentifier, + validateUpdateSite, siteController.update ); -router.put( - "/refresh", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - query("id") - .exists() - .withMessage( - "the site identifier is missing in request, consider using id" - ) - .bail() - .trim() - .isMongoId() - .withMessage("id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - query("lat_long") - .exists() - .withMessage( - "the site identifier is missing in request, consider using lat_long" - ) - .bail() - .trim(), - query("generated_name") - .exists() - .withMessage( - "the site identifier is missing in request, consider using generated_name" - ) - .bail() - .trim(), - ]), - siteController.refresh -); -router.delete( - "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - query("id") - .exists() - .withMessage( - "the site identifier is missing in request, consider using id" - ), - query("lat_long") - .exists() - .withMessage( - "the site identifier is missing in request, consider using lat_long" - ), - query("generated_name") - .exists() - .withMessage( - "the site identifier is missing in request, consider using generated_name" - ), - ]), - siteController.delete -); +router.put("/refresh", validateRefreshSite, siteController.refresh); +router.delete("/", validateDeleteSite, siteController.delete); router.post( "/approximate", - oneOf([ - [ - body("latitude") - .exists() - .withMessage("the latitude should be provided") - .bail() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("please provide valid latitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the latitude must have 2 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - body("longitude") - .exists() - .withMessage("the longitude is is missing in your request") - .bail() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("please provide valid longitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the longitude must have 2 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - ], - ]), + validateCreateApproximateCoordinates, siteController.createApproximateCoordinates ); router.get( "/approximate", - oneOf([ - [ - query("latitude") - .exists() - .withMessage("the latitude should be provided") - .bail() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("please provide valid latitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the latitude must have 2 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - query("longitude") - .exists() - .withMessage("the longitude is is missing in your request") - .bail() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("please provide valid longitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 2) { - return Promise.reject( - "the longitude must have 2 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - ], - ]), + validateGetApproximateCoordinates, siteController.createApproximateCoordinates ); -router.get( - "/nearest", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant cannot be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(constants.NETWORKS) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - query("longitude") - .exists() - .withMessage("the longitude is missing in request") - .bail() - .trim() - .matches(constants.LONGITUDE_REGEX, "i") - .withMessage("please provide valid longitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 1) { - return Promise.reject( - "the longitude must have 1 or more decimal places" - ); - } - return Promise.resolve("longitude validation test has passed"); - }), - query("radius") - .exists() - .withMessage("the radius is missing in request") - .bail() - .trim() - .isFloat() - .withMessage("the radius must be a number") - .bail() - .toFloat(), - query("latitude") - .exists() - .withMessage("the latitude is missing in the request") - .bail() - .trim() - .matches(constants.LATITUDE_REGEX, "i") - .withMessage("please provide valid latitude value") - .bail() - .custom((value) => { - let dp = decimalPlaces(value); - if (dp < 1) { - return Promise.reject( - "the latitude must have 1 or more decimal places" - ); - } - return Promise.resolve("latitude validation test has passed"); - }), - ], - ]), - siteController.findNearestSite -); +router.get("/nearest", validateNearestSite, siteController.findNearestSite); +router.put("/bulk", validateBulkUpdateDevices, siteController.updateManySites); module.exports = router; diff --git a/src/device-registry/utils/create-site.js b/src/device-registry/utils/create-site.js index 19c24384f3..64c5a7dac0 100644 --- a/src/device-registry/utils/create-site.js +++ b/src/device-registry/utils/create-site.js @@ -488,6 +488,89 @@ const createSite = { ); } }, + updateManySites: async (request, next) => { + try { + const { tenant } = request.query; + const { siteIds, updateData } = request.body; + + // Find existing sites + const existingSites = await SiteModel(tenant) + .find({ + _id: { $in: siteIds }, + }) + .select("_id"); + + // Create sets for comparison + const existingSiteIds = new Set( + existingSites.map((site) => site._id.toString()) + ); + const providedSiteIds = new Set(siteIds.map((id) => id.toString())); + + // Identify non-existent site IDs + const nonExistentSiteIds = siteIds.filter( + (id) => !existingSiteIds.has(id.toString()) + ); + + // If there are non-existent sites, prepare a detailed error + if (nonExistentSiteIds.length > 0) { + return next( + new HttpError("Bad Request", httpStatus.BAD_REQUEST, { + message: "Some provided site IDs do not exist", + nonExistentSiteIds: nonExistentSiteIds, + existingSiteIds: Array.from(existingSiteIds), + totalProvidedSiteIds: siteIds.length, + existingSiteCount: existingSites.length, + }) + ); + } + + // Prepare filter + const filter = { + _id: { $in: Array.from(providedSiteIds) }, + }; + + // Additional filtering from generateFilter if needed + const additionalFilter = generateFilter.sites(request, next); + Object.assign(filter, additionalFilter); + + // Optimize options for bulk update + const opts = { + new: true, + multi: true, + runValidators: true, + context: "query", + }; + + // Perform bulk update + const responseFromBulkModifySites = await SiteModel(tenant).bulkModify( + { + filter, + update: updateData, + opts, + }, + next + ); + + // Attach additional metadata to the response + return { + ...responseFromBulkModifySites, + metadata: { + totalSitesUpdated: responseFromBulkModifySites.data.modifiedCount, + requestedSiteIds: Array.from(providedSiteIds), + existingSiteIds: Array.from(existingSiteIds), + }, + }; + } catch (error) { + logger.error(`🐛🐛 Bulk Update Error: ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, sanitiseName: (name, next) => { try { let nameWithoutWhiteSpaces = name.replace(/\s/g, ""); diff --git a/src/device-registry/validators/site.validators.js b/src/device-registry/validators/site.validators.js new file mode 100644 index 0000000000..70a17d98da --- /dev/null +++ b/src/device-registry/validators/site.validators.js @@ -0,0 +1,506 @@ +const { query, body, oneOf } = require("express-validator"); +const constants = require("@config/constants"); +const mongoose = require("mongoose"); +const ObjectId = mongoose.Types.ObjectId; +const decimalPlaces = require("decimal-places"); +const createSiteUtil = require("@utils/create-site"); + +// Utility Functions +const validateDecimalPlaces = ( + value, + minPlaces = 5, + fieldName = "coordinate" +) => { + let dp = decimalPlaces(value); + if (dp < minPlaces) { + return Promise.reject( + `the ${fieldName} must have ${minPlaces} or more decimal places` + ); + } + return Promise.resolve(`${fieldName} validation test has passed`); +}; + +const createCoordinateValidation = (type, options = {}) => { + const { + minPlaces = 5, + isOptional = false, + existsMessage = `the ${type} is missing in your request`, + isQuery = true, + } = options; + + const validationChain = isQuery + ? isOptional + ? query(type).optional() + : query(type) + .exists() + .withMessage(existsMessage) + : isOptional + ? body(type).optional() + : body(type) + .exists() + .withMessage(existsMessage); + + return validationChain + .notEmpty() + .withMessage(`the ${type} should not be empty`) + .trim() + .matches( + type === "latitude" + ? constants.LATITUDE_REGEX + : constants.LONGITUDE_REGEX, + "i" + ) + .withMessage(`please provide valid ${type} value`) + .bail() + .custom((value) => validateDecimalPlaces(value, minPlaces, type)); +}; + +const createMongoIdValidation = (field, options = {}) => { + const { + isOptional = false, + existsMessage = `the ${field} identifier is missing in request`, + isQuery = true, + } = options; + + const validationChain = isQuery + ? isOptional + ? query(field).optional() + : query(field) + .exists() + .withMessage(existsMessage) + : isOptional + ? body(field).optional() + : body(field) + .exists() + .withMessage(existsMessage); + + return validationChain + .notEmpty() + .withMessage(`${field} cannot be empty`) + .trim() + .isMongoId() + .withMessage(`${field} must be an object ID`) + .bail() + .customSanitizer((value) => ObjectId(value)); +}; + +const createTenantValidation = (options = {}) => { + const { isOptional = false, isQuery = true } = options; + + const validationChain = isQuery + ? isOptional + ? query("tenant").optional() + : query("tenant").exists() + : isOptional + ? body("tenant").optional() + : body("tenant").exists(); + + return validationChain + .notEmpty() + .withMessage("tenant cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(constants.NETWORKS) + .withMessage("the tenant value is not among the expected ones"); +}; + +function validateCategoryField(value) { + const requiredFields = ["category", "search_radius", "tags"]; + + // Check if all required fields exist + if (!requiredFields.every((field) => field in value)) { + return false; + } + + // Validate numeric fields + const numericFields = ["latitude", "longitude", "search_radius"]; + let isValid = true; + + numericFields.forEach((field) => { + if (!(field in value)) { + isValid = false; + return; + } + const numValue = parseFloat(value[field]); + if (Number.isNaN(numValue)) { + isValid = false; + return; + } else if (field === "latitude") { + if (Math.abs(numValue) > 90) { + isValid = false; + return; + } + } else if (field === "longitude") { + if (numValue < -180 || numValue > 180) { + isValid = false; + return; + } + } else if (field === "search_radius") { + if (numValue <= 0) { + isValid = false; + return; + } + } + }); + + // Validate tags array + if ("tags" in value && !Array.isArray(value.tags)) { + return false; + } + value.tags.forEach((tag) => { + if ( + !value.tags.every((tag) => typeof tag === "string" && tag.trim() !== "") + ) { + return false; + } + }); + + // All validations passed + return isValid; +} + +// Composed Validation Middleware +const validateSiteIdentifier = oneOf([ + createMongoIdValidation("id"), + createMongoIdValidation("site_id", { isOptional: true }), + query("name") + .optional() + .notEmpty() + .trim(), +]); + +const validateSiteQueryParams = oneOf([ + [ + ...[ + createTenantValidation({ isOptional: true }), + validateSiteIdentifier, + query("online_status") + .optional() + .notEmpty() + .withMessage("the online_status should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["online", "offline"]) + .withMessage( + "the online_status value is not among the expected ones which include: online, offline" + ), + query("category") + .optional() + .notEmpty() + .withMessage("the category should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["bam", "lowcost", "gas"]) + .withMessage( + "the category value is not among the expected ones which include: lowcost, gas and bam" + ), + query("site_category") + .optional() + .notEmpty() + .withMessage("the site_category should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["category", "search_radius", "tags"]) + .withMessage( + "the site_category value is not among the expected ones which include: category, search_radius, tags" + ), + query("last_active_before") + .optional() + .notEmpty() + .withMessage("last_active_before date cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage( + "last_active_before date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." + ) + .bail() + .toDate(), + query("last_active_after") + .optional() + .notEmpty() + .withMessage("last_active_after date cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage( + "last_active_after date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." + ) + .bail() + .toDate(), + query("last_active") + .optional() + .notEmpty() + .withMessage("last_active date cannot be empty IF provided") + .bail() + .trim() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage( + "last_active date must be a valid ISO8601 datetime (YYYY-MM-DDTHH:mm:ss.sssZ)." + ) + .bail() + .toDate(), + ], + ], +]); + +const validateMandatorySiteIdentifier = oneOf([ + createMongoIdValidation("id"), + query("lat_long") + .exists() + .trim(), + query("generated_name") + .exists() + .trim(), +]); + +const validateCreateSite = [ + oneOf([ + [ + createCoordinateValidation("latitude", { isQuery: false }), + createCoordinateValidation("longitude", { isQuery: false }), + body("name") + .exists() + .withMessage("the name is is missing in your request") + .bail() + .trim() + .custom((value) => createSiteUtil.validateSiteName(value)) + .withMessage( + "The name should be greater than 5 and less than 50 in length" + ), + body("site_tags") + .optional() + .custom((value) => Array.isArray(value)) + .withMessage("the site_tags should be an array") + .bail() + .notEmpty() + .withMessage("the site_tags should not be empty"), + body("groups") + .optional() + .custom((value) => Array.isArray(value)) + .withMessage("the groups should be an array") + .bail() + .notEmpty() + .withMessage("the groups should not be empty"), + body("airqlouds") + .optional() + .custom((value) => Array.isArray(value)) + .withMessage("the airqlouds should be an array") + .bail() + .notEmpty() + .withMessage("the airqlouds should not be empty"), + body("airqlouds.*") + .optional() + .isMongoId() + .withMessage("each airqloud should be a mongo ID"), + body("site_category") + .optional() + .custom(validateCategoryField) + .withMessage( + "Invalid site_category format, crosscheck the types or content of all the provided nested fields. latitude, longitude & search_radius should be numbers. tags should be an array of strings. category, search_tags & search_radius are required fields" + ), + ], + ]), +]; + +const validateSiteMetadata = [ + oneOf([ + [ + createCoordinateValidation("latitude", { isQuery: false, minPlaces: 2 }), + createCoordinateValidation("longitude", { isQuery: false, minPlaces: 2 }), + ], + ]), +]; + +const validateUpdateSite = [ + createTenantValidation({ isOptional: true }), + oneOf([ + [ + body("status") + .optional() + .notEmpty() + .trim() + .toLowerCase() + .isIn(["active", "decommissioned"]) + .withMessage( + "the status value is not among the expected ones which include: decommissioned, active" + ), + body("visibility") + .optional() + .notEmpty() + .withMessage("visibility cannot be empty IF provided") + .bail() + .trim() + .isBoolean() + .withMessage("visibility must be Boolean"), + createCoordinateValidation("latitude", { + isOptional: true, + isQuery: false, + }), + createCoordinateValidation("longitude", { + isOptional: true, + isQuery: false, + }), + body("site_tags") + .optional() + .custom((value) => Array.isArray(value)) + .withMessage("the site_tags should be an array") + .bail() + .notEmpty() + .withMessage("the site_tags should not be empty"), + body("site_category") + .optional() + .custom(validateCategoryField) + .withMessage( + "Invalid site_category format, crosscheck the types or content of all the provided nested fields. latitude, longitude & search_radius should be numbers. tags should be an array of strings. category, search_tags & search_radius are required fields" + ), + ], + ]), +]; + +const validateRefreshSite = [ + createTenantValidation({ isOptional: true }), + validateMandatorySiteIdentifier, +]; + +const validateDeleteSite = [ + createTenantValidation({ isOptional: true }), + oneOf([ + createMongoIdValidation("id"), + query("lat_long") + .exists() + .trim(), + query("generated_name") + .exists() + .trim(), + ]), +]; + +const validateCreateApproximateCoordinates = [ + oneOf([ + [ + createCoordinateValidation("latitude", { isQuery: false, minPlaces: 2 }), + createCoordinateValidation("longitude", { isQuery: false, minPlaces: 2 }), + ], + ]), +]; + +const validateGetApproximateCoordinates = [ + oneOf([ + [ + createCoordinateValidation("latitude", { minPlaces: 2 }), + createCoordinateValidation("longitude", { minPlaces: 2 }), + ], + ]), +]; + +const validateNearestSite = [ + createTenantValidation({ isOptional: true }), + oneOf([ + [ + createCoordinateValidation("longitude"), + createCoordinateValidation("latitude"), + query("radius") + .exists() + .withMessage("the radius is missing in request") + .bail() + .trim() + .isFloat() + .withMessage("the radius must be a number") + .bail() + .toFloat(), + ], + ]), +]; + +const validateBulkUpdateDevices = [ + createTenantValidation({ isOptional: true }), + body("siteIds") + .exists() + .withMessage("siteIds must be provided in the request body") + .bail() + .isArray() + .withMessage("siteIds must be an array") + .bail() + .custom((value) => { + if (value.length === 0) { + throw new Error("siteIds array cannot be empty"); + } + return true; + }) + .bail() + .custom((value) => { + const MAX_BULK_UPDATE_SITES = 30; + if (value.length > MAX_BULK_UPDATE_SITES) { + throw new Error( + `Cannot update more than ${MAX_BULK_UPDATE_SITES} devices in a single request` + ); + } + return true; + }) + .bail() + .custom((value) => { + const invalidIds = value.filter( + (id) => !mongoose.Types.ObjectId.isValid(id) + ); + if (invalidIds.length > 0) { + throw new Error("All siteIds must be valid MongoDB ObjectIds"); + } + return true; + }), + + body("updateData") + .exists() + .withMessage("updateData must be provided in the request body") + .bail() + .custom((value) => { + if (typeof value !== "object" || Array.isArray(value) || value === null) { + throw new Error("updateData must be an object"); + } + return true; + }) + .bail() + .custom((value) => { + if (Object.keys(value).length === 0) { + throw new Error("updateData cannot be an empty object"); + } + return true; + }) + .bail() + .custom((value) => { + const allowedFields = ["groups", "site_category"]; + + const invalidFields = Object.keys(value).filter( + (field) => !allowedFields.includes(field) + ); + if (invalidFields.length > 0) { + throw new Error( + `Invalid fields in updateData: ${invalidFields.join(", ")}` + ); + } + + return true; + }), + ...validateUpdateSite, +]; + +module.exports = { + validateTenant: createTenantValidation({ isOptional: true }), + validateSiteIdentifier, + validateSiteQueryParams, + validateMandatorySiteIdentifier, + validateCreateSite, + validateSiteMetadata, + validateUpdateSite, + validateRefreshSite, + validateDeleteSite, + validateCreateApproximateCoordinates, + validateGetApproximateCoordinates, + validateNearestSite, + validateBulkUpdateDevices, + validateCategoryField, +};