diff --git a/src/controllers/v1/services/index.js b/src/controllers/v1/services/index.js index 9538944..7856a8b 100644 --- a/src/controllers/v1/services/index.js +++ b/src/controllers/v1/services/index.js @@ -38,7 +38,8 @@ module.exports = { const { results, count } = await getServices.executeQuery( query, parameters.perPage, - parameters.page + parameters.page, + parameters ) const content = getServices.buildContent( results, diff --git a/src/controllers/v1/services/routes/get-services.js b/src/controllers/v1/services/routes/get-services.js index 3b1352f..79bb3b7 100644 --- a/src/controllers/v1/services/routes/get-services.js +++ b/src/controllers/v1/services/routes/get-services.js @@ -10,6 +10,7 @@ module.exports = { * @returns */ parseRequestParameters: async queryParams => { + let searchType = undefined const perPage = parseInt(queryParams.per_page) || 50 const page = parseInt(queryParams.page) || 1 const keywords = queryParams.keywords @@ -65,6 +66,14 @@ module.exports = { } } + if (keywords && !lat && !lng) { + searchType = "keyword" + } else if (keywords === undefined && lat && lng) { + searchType = "location" + } else if (keywords && lat && lng) { + searchType = "keyword_location" + } + return { perPage, page, @@ -82,6 +91,7 @@ module.exports = { minAge, maxAge, interpreted_location, + searchType, } }, /** @@ -93,8 +103,28 @@ module.exports = { let query = {} query.$and = [] - const filterKeywords = await filters.filterKeywords(parameters.keywords) - query = { ...filterKeywords, ...query } + // add in keywords + if (parameters.searchType === "keyword") { + const filterKeywords = await filters.filterKeywords(parameters.keywords) + query = { ...filterKeywords, ...query } + } else if (parameters.searchType === "keyword_location") { + const filterLocationKeywords = await filters.filterLocationKeywords( + parameters.keywords + ) + query = { ...filterLocationKeywords, ...query } + } + + // add in locations + if ( + parameters.searchType === "location" || + parameters.searchType === "keyword_location" + ) { + const locationGeometry = filters.locationGeometry( + parameters.lat, + parameters.lng + ) + query = { ...locationGeometry, ...query } + } // add filtering for ages const ages = filters.filterAges(parameters.minAge, parameters.maxAge) @@ -110,11 +140,6 @@ module.exports = { // add filtering query.$and.push( - filters.filterLocation( - parameters.lat, - parameters.lng, - query?.$text ?? false - ), filters.filterDirectories(parameters.directories), filters.filterTaxonomies(parameters.taxonomies), filters.filterNeeds(parameters.needs), @@ -129,6 +154,40 @@ module.exports = { return query }, + /** + * this is done because of the $nearSphere method in locationGeometry. + * This is because The $nearSphere operator cannot be used with the + * countDocuments() method in MongoDB because countDocuments() + * uses an aggregation pipeline under the hood, and $nearSphere is not + * allowed in an aggregation pipeline. + * so as a workaround if we're using nearsphere we update the count + * query to prevent errors + * the result of nearsphere will include all services with a location + * so this query is a good substitute to get the totalElements value + * @TODO test this doesn't affect query object + * http://localhost:3001/api/v1/services?lat=51.2107714&lng=0.31105&per_page=10&suitabilities=physical-disabilities + * @param {*} query + * @returns + */ + createCountQuery: query => { + // "budget deep clone" we spread $and so can modify it for countQuery only + const countQuery = { ...query, $and: [...query.$and] } + if ("service_at_locations.location.geometry" in countQuery) { + delete countQuery["service_at_locations.location.geometry"] + + countQuery["$and"].push({ + "service_at_locations.location.geometry": { + $exists: true, + $ne: null, + }, + }) + } + logger.debug("countQuery") + logger.debug(countQuery) + logger.debug(JSON.stringify(countQuery)) + return countQuery + }, + /** * * @param {*} query @@ -136,42 +195,71 @@ module.exports = { * @param {*} page * @returns */ - async executeQuery(query, perPage, page) { + async executeQuery(query, perPage, page, parameters) { const Service = db().collection("indexed_services") - const queryProjection = query.$text - ? { + let queryProjection = {} + let sort = {} + + switch (parameters.searchType) { + case "keyword": + sort = { + score: { $meta: "textScore" }, + updated_at: -1, + } + queryProjection = { ...projection, score: { $meta: "textScore" }, } - : { + break + case "location": + case "keyword_location": + sort = {} + queryProjection = { ...projection, } - - const sort = query.$text - ? { - score: { $meta: "textScore" }, + break + default: + sort = { updated_at: -1, } - : { - updated_at: -1, + queryProjection = { + ...projection, } + break + } logger.debug("query") logger.debug(query) logger.debug(JSON.stringify(query)) - const [results, count] = await Promise.all([ - Service.find(query) - .project(queryProjection) - .sort(sort) - .limit(perPage) - .skip((page - 1) * perPage) - .toArray(), - Service.countDocuments(query), - ]) - - return { results, count } + if ( + parameters.searchType === "location" || + parameters.searchType === "keyword_location" + ) { + const countQuery = this.createCountQuery(query) + const [results, count] = await Promise.all([ + Service.find(query) + .project(queryProjection) + .sort(sort) + .limit(perPage) + .skip((page - 1) * perPage) + .toArray(), + Service.countDocuments(countQuery), + ]) + return { results, count } + } else { + const [results, count] = await Promise.all([ + Service.find(query) + .project(queryProjection) + .sort(sort) + .limit(perPage) + .skip((page - 1) * perPage) + .toArray(), + Service.countDocuments(query), + ]) + return { results, count } + } }, /** diff --git a/src/lib/filters.js b/src/lib/filters.js index 1583c64..a599513 100644 --- a/src/lib/filters.js +++ b/src/lib/filters.js @@ -2,6 +2,26 @@ const { db } = require("../db") const logger = require("../../utils/logger") module.exports = { + locationGeometry: (lat, lng) => { + let query = {} + if (lat && lng) { + query["service_at_locations.location.geometry"] = { + $nearSphere: { + $geometry: { + type: "Point", + coordinates: [parseFloat(lng), parseFloat(lat)], + }, + $maxDistance: 20 * 1609.34, // miles x 1609.34 = Distance in meters + }, + } + } + return query + }, + + /** + * @deprecated because we've gone back to nearSphere for now + * @returns + */ filterLocation: (lat, lng, keywordSearch) => { if (lat !== undefined && lng !== undefined) { logger.debug( @@ -67,6 +87,29 @@ module.exports = { return query }, + /** + * if there is a location or lat or lng value then we do a search first for keyword to refine the location search query + * $text performs a text search on the content of the fields indexed with a text index. + * In this case it will search the name_text_description_text index + * @TODO test http://localhost:3001/api/v1/services + * @TODO test http://localhost:3001/api/v1/services?location=London + * @TODO test http://localhost:3001/api/v1/services?lat=51.2107714&lng=0.31105&per_page=10 + * @param {*} keywords + * @param {...any} args + * @returns + */ + filterLocationKeywords: async keywords => { + let query = {} + if (keywords) { + const Service = db().collection("indexed_services") + const docs = await Service.find({ + $text: { $search: keywords }, + }).toArray() + query._id = { $in: docs.map(doc => doc._id) } + } + return query + }, + // This filter returns all services with an age range overlapping with the // range supplied by the user. // min_age=0&max_age=18