From 7f2b7d39a924ef6dc50c9da3d06027312edf434d Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Tue, 24 Mar 2026 14:42:58 +0200 Subject: [PATCH 1/4] enhance case studies search for text and taxonomy fields --- website/modules/case-studies-page/index.js | 71 +++++++++++- .../services/NavigationService.js | 19 +++- .../services/SearchService.js | 104 +++++++++++++++--- .../case-studies-page/views/index.html | 2 +- .../case-studies-page/infinite-scroll.js | 61 +++++++++- .../case-studies-page/search-handler.js | 5 +- 6 files changed, 234 insertions(+), 28 deletions(-) diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index d3a070fe..748b3d1d 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -15,13 +15,74 @@ const buildIndexQuery = function (self, req) { .perPage(self.perPage); self.filterByIndexPage(query, req.data.page); - const searchCondition = SearchService.buildSearchCondition(searchTerm); + const resolved = req.data.searchRelationships || {}; + const searchCondition = SearchService.buildSearchCondition( + searchTerm, + resolved, + ); if (searchCondition) { query.and(searchCondition); } return query; }; +const runResolveSearchRelationships = async function (self, req) { + req.data ||= {}; + const reqData = req.data; + const searchTerm = SearchService.getSearchTerm(req.query || {}); + if (!searchTerm) { + reqData.searchRelationships = {}; + return; + } + let resolvedRelationships = {}; + try { + resolvedRelationships = await SearchService.resolveSearchRelationships( + searchTerm, + self.apos, + req, + ); + } catch (error) { + self.apos.util.error('Error resolving search relationships:', error); + } + reqData.searchRelationships = resolvedRelationships; +}; + +const runApplyEnhancedSearchResults = async function (self, req) { + const reqData = req.data; + const searchTerm = SearchService.getSearchTerm(req.query || {}); + if (!searchTerm) { + return; + } + const queryParams = { ...req.query }; + delete queryParams.search; + const resolved = reqData.searchRelationships || {}; + const hasRelationshipMatches = Object.keys(resolved).length > 0; + if (!hasRelationshipMatches) { + return; + } + const searchCondition = SearchService.buildSearchCondition( + searchTerm, + resolved, + ); + if (!searchCondition) { + return; + } + + const piecesQuery = self.pieces + .find(req, {}) + .applyBuildersSafely(queryParams); + piecesQuery.and(searchCondition); + + const totalQuery = self.pieces.find(req, {}).applyBuildersSafely(queryParams); + totalQuery.and(searchCondition); + + const pieces = await piecesQuery.toArray(); + const totalPieces = await totalQuery.toCount(); + reqData.pieces = pieces; + reqData.totalPieces = totalPieces; + reqData.totalPages = 1; +}; + const runSetupIndexData = async function (self, req) { try { const tagCounts = await TagCountService.calculateTagCounts( @@ -92,6 +153,8 @@ module.exports = { if (superBeforeIndex) { await superBeforeIndex(req); } + await self.resolveSearchRelationships(req); + await self.applyEnhancedSearchResults(req); await self.setupIndexData(req); }; @@ -109,6 +172,12 @@ module.exports = { indexQuery(req) { return buildIndexQuery(self, req); }, + async resolveSearchRelationships(req) { + return await runResolveSearchRelationships(self, req); + }, + async applyEnhancedSearchResults(req) { + return await runApplyEnhancedSearchResults(self, req); + }, async setupIndexData(req) { return await runSetupIndexData(self, req); }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index 74af551c..89944465 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -71,15 +71,22 @@ class NavigationService { } /** - * Applies search filter to query when search param is present - * Uses SearchService for safe regex (ReDoS prevention) and array handling + * Applies search filter to query when search param is present. + * Resolves relationship matches so search covers taxonomy and + * partner fields in addition to text fields. * @param {Object} filteredQuery - Query object * @param {Object} req - Request object - * @returns {Object} Modified query + * @param {Object} apos - ApostropheCMS instance + * @returns {Promise} Modified query */ - static applySearchFilter(filteredQuery, req) { + static async applySearchFilter(filteredQuery, req, apos) { const searchTerm = SearchService.getSearchTerm(req.query || {}); - const searchCondition = SearchService.buildSearchCondition(searchTerm); + const resolvedRelationships = + await SearchService.resolveSearchRelationships(searchTerm, apos, req); + const searchCondition = SearchService.buildSearchCondition( + searchTerm, + resolvedRelationships, + ); if (!searchCondition) { return filteredQuery; } @@ -133,7 +140,7 @@ class NavigationService { }); } } - return NavigationService.applySearchFilter(filteredQuery, req); + return await NavigationService.applySearchFilter(filteredQuery, req, apos); } /** diff --git a/website/modules/case-studies-page/services/SearchService.js b/website/modules/case-studies-page/services/SearchService.js index 3062c090..183536e7 100644 --- a/website/modules/case-studies-page/services/SearchService.js +++ b/website/modules/case-studies-page/services/SearchService.js @@ -1,17 +1,39 @@ /** - * SearchService - Shared search term normalization and safe regex building + * SearchService - Search term normalization, safe regex building, + * and relationship resolution for case-study search. * - * Used by case-studies-page index query and NavigationService so search - * behavior and escaping stay consistent and safe (ReDoS prevention). + * Used by case-studies-page index query and NavigationService so + * search behavior stays consistent (ReDoS prevention, escaping). */ const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu; const MAX_SEARCH_TERM_LENGTH = 200; +const TEXT_FIELDS = [ + 'title', + 'portfolioTitle', + 'descriptor', + 'objective', + 'challenge', + 'solution', + 'results', +]; + +const RELATIONSHIP_CONFIGS = [ + { + module: 'cases-tags', + caseStudyFields: ['stackIds', 'industryIds', 'caseStudyTypeIds'], + }, + { + module: 'business-partner', + caseStudyFields: ['partnerIds'], + }, +]; + /** - * Normalizes search param from query (handles missing, array, non-string) - * @param {Object} queryParams - Request query object (e.g. req.query) + * Normalizes search param from query + * @param {Object} queryParams - Request query object * @returns {string} Trimmed search string, or empty string */ const getSearchTerm = function (queryParams) { @@ -30,10 +52,10 @@ const getSearchTerm = function (queryParams) { }; /** - * Builds a safe MongoDB regex pattern from search term (escape + word match) - * Search term is capped at MAX_SEARCH_TERM_LENGTH to avoid pathologically long patterns + * Builds a safe MongoDB regex pattern from search term. + * Capped at MAX_SEARCH_TERM_LENGTH to avoid long patterns. * @param {string} searchTerm - User search string - * @returns {string|null} Pattern for $regex, or null if no search + * @returns {string|null} Pattern for $regex, or null */ const buildSearchRegexPattern = function (searchTerm) { if (!searchTerm || !searchTerm.trim()) { @@ -52,23 +74,77 @@ const buildSearchRegexPattern = function (searchTerm) { }; /** - * Builds MongoDB $or condition for case study search (single source of truth for searchable fields) + * Resolves relationship document IDs whose title or slug + * matches the search term. Returns a map of case-study + * ID-array field names to arrays of matching aposDocId values. * @param {string} searchTerm - User search string - * @returns {Object|null} Condition to pass to query.and(), or null if no search + * @param {Object} apos - ApostropheCMS instance + * @param {Object} req - Request object + * @returns {Promise} Field-name-to-IDs map */ -const buildSearchCondition = function (searchTerm) { +const resolveSearchRelationships = async function (searchTerm, apos, req) { + const regexPattern = buildSearchRegexPattern(searchTerm); + if (!regexPattern) { + return {}; + } + + const regexOpts = { $regex: regexPattern, $options: 'i' }; + const result = {}; + + const lookups = RELATIONSHIP_CONFIGS.map(async (config) => { + const docs = await apos.modules[config.module] + .find(req, {}) + .and({ + $or: [{ title: regexOpts }, { slug: regexOpts }], + }) + .toArray(); + + const ids = docs.map((doc) => doc.aposDocId); + if (ids.length > 0) { + config.caseStudyFields.forEach((field) => { + result[field] = ids; + }); + } + }); + + await Promise.all(lookups); + return result; +}; + +/** + * Builds MongoDB $or condition for case study search across + * text fields and pre-resolved relationship ID fields. + * @param {string} searchTerm - User search string + * @param {Object} [resolvedRelationships] - Pre-resolved IDs + * @returns {Object|null} Condition for query.and(), or null + */ +const buildSearchCondition = function (searchTerm, resolvedRelationships) { const regexPattern = buildSearchRegexPattern(searchTerm); if (!regexPattern) { return null; } + const regexOpts = { $regex: regexPattern, $options: 'i' }; - return { - $or: [{ title: regexOpts }, { portfolioTitle: regexOpts }], - }; + const orBranches = TEXT_FIELDS.map((field) => ({ [field]: regexOpts })); + + const relationships = resolvedRelationships || {}; + Object.keys(relationships).forEach((field) => { + const ids = relationships[field]; + if (ids && ids.length > 0) { + orBranches.push({ [field]: { $in: ids } }); + } + }); + + if (orBranches.length === 0) { + return null; + } + + return { $or: orBranches }; }; module.exports = { buildSearchCondition, buildSearchRegexPattern, getSearchTerm, + resolveSearchRelationships, }; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index dcf98a91..78368f31 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -36,7 +36,7 @@ placeholder="Search case studies" value="{{ data.query.search or '' }}" autocomplete="off" - aria-label="Search case studies by title" + aria-label="Search case studies" />