From 6305b1a794b6375ec0e66ec9e86a513a01f2393f Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sat, 15 Aug 2026 10:29:44 +1000 Subject: [PATCH] fix: search challenges by skills tags and AI track --- docs/swagger.yaml | 18 ++- src/common/helper.ts | 25 +++ src/services/ChallengeService.ts | 164 +++++++++++++++----- test/e2e/challenge.search.api.test.js | 126 +++++++++++++++ test/unit/ChallengeService.test.js | 215 ++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 40 deletions(-) create mode 100644 test/e2e/challenge.search.api.test.js diff --git a/docs/swagger.yaml b/docs/swagger.yaml index af07a8c..0bd19bb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -104,7 +104,9 @@ paths: format: UUID - name: trackIds in: query - description: Filter by multiple track IDs, exact match. + description: >- + Filter by multiple exact track IDs using OR semantics. IDs are also ORed with any + track or tracks facets supplied in the same request. required: false type: array items: @@ -119,9 +121,13 @@ paths: type: string - name: tracks in: query - description: Filter by multiple track abbreviation, exact match. If tracks is provided, trackIds will be ignored + description: >- + Filter by track facets using OR semantics. Persisted track abbreviations are exact + matches; AI is a synthetic facet that matches the exact canonical AI challenge tag. + Unknown abbreviations match no challenges. All values are ORed with trackIds. required: false type: array + collectionFormat: brackets items: type: string - name: typeId @@ -132,7 +138,9 @@ paths: format: UUID - name: trackId in: query - description: Filter by track id, exact match. If track is provided, trackId will be ignored + description: >- + Filter by one exact track ID. The ID is ORed with any track or tracks facets supplied + in the same request. required: false type: string format: UUID @@ -143,7 +151,9 @@ paths: type: string - name: track in: query - description: Filter by track, case-insensitive, partial matches are allowed. + description: >- + Filter by one exact track abbreviation. AI is a synthetic facet that matches the exact + canonical AI challenge tag. An unknown abbreviation matches no challenges. required: false type: string - name: name diff --git a/src/common/helper.ts b/src/common/helper.ts index 505ed26..6f30bc7 100644 --- a/src/common/helper.ts +++ b/src/common/helper.ts @@ -1835,6 +1835,30 @@ async function getStandSkills(ids) { return _.concat(...data); } +/** + * Finds standardized skills whose names contain a member-entered search term. + * The endpoint is public and performs a case-insensitive match, so Challenge + * API can translate display names into the skill ids stored in ChallengeSkill. + * + * @param {String} term skill-name fragment. + * @param {Number} size maximum number of matching skills to return. + * @returns {Promise>} matching standardized skill summaries. + * @throws Propagates standardized-skills API and network errors. + */ +async function searchStandSkills(term, size = 100) { + const normalizedTerm = _.toString(term).trim(); + if (!normalizedTerm) { + return []; + } + + const boundedSize = Math.max(1, Math.min(Number(size) || 100, 100)); + const requestUrl = `${config.API_BASE_URL}/v5/standardized-skills/skills/fuzzymatch` + + `?term=${encodeURIComponent(normalizedTerm)}&size=${boundedSize}`; + logger.debug(`helper.searchStandSkills: GET ${requestUrl}`); + const res = await axios.get(requestUrl); + return Array.isArray(res.data) ? res.data : []; +} + /** * Send self service notification * @param {String} type the notification type @@ -2105,6 +2129,7 @@ module.exports = { getMemberByHandle, getMembersByHandles, getStandSkills, + searchStandSkills, submitZendeskRequest, updateSelfServiceProjectInfo, getFromInternalCache, diff --git a/src/services/ChallengeService.ts b/src/services/ChallengeService.ts index a882640..056aae5 100644 --- a/src/services/ChallengeService.ts +++ b/src/services/ChallengeService.ts @@ -64,6 +64,7 @@ const CHALLENGE_APPROVAL_ACTION_STATUSES = new Set([ const DEFAULT_ESTIMATED_SUBMISSIONS_COUNT = 2; const CHECKPOINT_SUBMISSION_TYPE = "CHECKPOINT_SUBMISSION"; +const SYNTHETIC_AI_TRACK_FACET = "AI"; // Provide aliases for friendlier sortBy query params const sortByAliases = { @@ -1645,10 +1646,63 @@ async function searchChallengesViaMemberAccess({ } /** - * Search challenges - * @param {Object} currentUser the user who perform operation - * @param {Object} criteria the search criteria - * @returns {Object} the search result + * Finds distinct authored tags with a case-insensitive match to the unified + * search term. Prisma's scalar-list `has` operator is exact and case-sensitive, + * so the parameterized SQL lookup supplies the actual authored values to the + * main Prisma `hasSome` filter without materializing every matching challenge id. + * + * @param {String} searchTerm unified challenge search term. + * @returns {Promise>} authored challenge tags that match. + * @throws Propagates Challenge database query errors. + */ +async function findChallengeTagsBySearch(searchTerm) { + const normalizedTerm = _.toString(searchTerm).trim(); + if (!normalizedTerm) { + return []; + } + + const rows: Array<{ value: string }> = await prisma.$queryRaw` + SELECT DISTINCT challenge_tag."value" AS "value" + FROM "Challenge" AS challenge + CROSS JOIN LATERAL unnest(challenge."tags") AS challenge_tag("value") + WHERE challenge_tag."value" ILIKE ${`%${normalizedTerm}%`} + `; + return rows.map((row) => row.value); +} + +/** + * Resolves matching standardized skill names to the ids persisted by + * ChallengeSkill. Search remains available for title/description/tag matches + * when the external skills service is temporarily unavailable. + * + * @param {String} searchTerm unified challenge search term. + * @returns {Promise>} matching standardized skill ids. + * @throws Does not throw; skills-service failures degrade to no skill matches. + */ +async function findSkillIdsBySearch(searchTerm) { + try { + const skills = await helper.searchStandSkills(searchTerm); + return _.uniq( + (skills || []) + .map((skill) => _.toString(skill && skill.id).trim()) + .filter((skillId) => !!skillId), + ); + } catch (error) { + logger.warn(`Failed to resolve challenge search skills: ${error.message}`); + return []; + } +} + +/** + * Searches visible challenges with database-level filtering, count, sorting, + * and pagination. Unified text search covers names, descriptions, authored + * tags, and standardized skills. Track facets use OR semantics and treat `AI` + * as the exact canonical AI tag rather than a persisted track abbreviation. + * + * @param {Object} currentUser caller identity used for visibility and member filters. + * @param {Object} criteria validated search, facet, sorting, and pagination values. + * @returns {Promise} paginated challenge rows and total metadata. + * @throws Propagates validation, visibility dependency, and database errors. */ async function searchChallenges(currentUser, criteria) { const page = criteria.page || 1; @@ -1740,9 +1794,26 @@ async function searchChallenges(currentUser, criteria) { ); }; - let includedTrackIds = _.isArray(criteria.trackIds) ? criteria.trackIds : []; + let includedTrackIds = _.isArray(criteria.trackIds) ? [...criteria.trackIds] : []; let includedTypeIds = _.isArray(criteria.typeIds) ? criteria.typeIds : []; + // Opportunities exposes AI alongside the persisted challenge-track abbreviations. AI is a + // synthetic facet backed by the canonical, exact `AI` tag; all requested track facets share + // OR semantics so that selecting AI plus a real track returns the union before pagination. + const rawTrackFacets = [ + ...(!_.isNil(criteria.track) ? [criteria.track] : []), + ...(_.isArray(criteria.tracks) ? criteria.tracks : []), + ]; + const requestedTrackFacets = _.uniq( + rawTrackFacets.map((track) => _.toString(track).trim()).filter((track) => track.length > 0), + ); + const includesAiTrackFacet = requestedTrackFacets.some( + (track) => track.toUpperCase() === SYNTHETIC_AI_TRACK_FACET, + ); + const persistedTrackFacets = requestedTrackFacets.filter( + (track) => track.toUpperCase() !== SYNTHETIC_AI_TRACK_FACET, + ); + if (criteria.type) { const typeSearchRes = await prisma.challengeType.findFirst({ where: { abbreviation: criteria.type }, @@ -1751,14 +1822,6 @@ async function searchChallenges(currentUser, criteria) { criteria.typeId = _.get(typeSearchRes, "id"); } } - if (criteria.track) { - const trackSearchRes = await prisma.challengeTrack.findFirst({ - where: { abbreviation: criteria.track }, - }); - if (trackSearchRes && _.get(trackSearchRes, "id")) { - criteria.trackId = _.get(trackSearchRes, "id"); - } - } if (criteria.types) { const typeIds = await prisma.challengeType.findMany({ where: { abbreviation: { in: criteria.types } }, @@ -1771,10 +1834,10 @@ async function searchChallenges(currentUser, criteria) { ); } } - if (criteria.tracks) { + if (persistedTrackFacets.length > 0) { const trackIds = await prisma.challengeTrack.findMany({ select: { id: true }, - where: { abbreviation: { in: criteria.tracks } }, + where: { abbreviation: { in: persistedTrackFacets } }, }); if (trackIds.length > 0) { includedTrackIds = _.concat( @@ -1789,6 +1852,7 @@ async function searchChallenges(currentUser, criteria) { if (criteria.trackId) { includedTrackIds.push(criteria.trackId); } + includedTrackIds = _.uniq(includedTrackIds); _.forIn(_.pick(criteria, matchPhraseKeys), (value, key) => { if (!_.isUndefined(value)) { @@ -1842,33 +1906,61 @@ async function searchChallenges(currentUser, criteria) { }); } - if (includedTrackIds.length > 0) { - prismaFilter.where.AND.push({ - trackId: { in: includedTrackIds }, - }); + if (rawTrackFacets.length > 0) { + const trackFacetFilters: any[] = []; + if (includedTrackIds.length > 0) { + trackFacetFilters.push({ trackId: { in: includedTrackIds } }); + } + if (includesAiTrackFacet) { + trackFacetFilters.push({ tags: { has: SYNTHETIC_AI_TRACK_FACET } }); + } + + if (trackFacetFilters.length === 0) { + // An unknown abbreviation must return no matches instead of silently removing the facet. + prismaFilter.where.AND.push({ id: { in: [] } }); + } else if (trackFacetFilters.length === 1) { + prismaFilter.where.AND.push(trackFacetFilters[0]); + } else { + prismaFilter.where.AND.push({ OR: trackFacetFilters }); + } + } else if (includedTrackIds.length > 0) { + prismaFilter.where.AND.push({ trackId: { in: includedTrackIds } }); } if (criteria.search) { - prismaFilter.where.AND.push({ - OR: [ - { - name: { - contains: criteria.search, - mode: "insensitive", - }, + const normalizedSearch = _.toString(criteria.search).trim(); + const [searchSkillIds, searchTags] = await Promise.all([ + findSkillIdsBySearch(normalizedSearch), + findChallengeTagsBySearch(normalizedSearch), + ]); + const searchConditions: any[] = [ + { + name: { + contains: normalizedSearch, + mode: "insensitive", }, - { - description: { contains: criteria.search }, - // TODO: Skills doesn't have name field in db. - /* - }, { - skills: { some: { name: { contains: criteria.search } } } - */ + }, + { + description: { + contains: normalizedSearch, + mode: "insensitive", }, - { - tags: { has: criteria.search }, + }, + ]; + if (searchSkillIds.length > 0) { + searchConditions.push({ + skills: { + some: { + skillId: { in: searchSkillIds }, + }, }, - ], + }); + } + if (searchTags.length > 0) { + searchConditions.push({ tags: { hasSome: searchTags } }); + } + prismaFilter.where.AND.push({ + OR: searchConditions, }); } else { if (criteria.name) { diff --git a/test/e2e/challenge.search.api.test.js b/test/e2e/challenge.search.api.test.js new file mode 100644 index 0000000..0ef4ddf --- /dev/null +++ b/test/e2e/challenge.search.api.test.js @@ -0,0 +1,126 @@ +/* + * Focused E2E coverage for the unified challenge opportunity search contract. + */ + +require('../../app-bootstrap') +const { v4: uuid } = require('uuid') +const chai = require('chai') +const { request } = require('chai-http') +const config = require('config') +const app = require('../../app') +const helper = require('../../src/common/helper') +const testHelper = require('../testHelper') +const { getClient, ChallengeStatusEnum } = require('../../src/common/prisma') + +const should = chai.should() +const prisma = getClient() +const basePath = `/${config.API_VERSION}/challenges` + +describe('challenge unified search API E2E tests', () => { + let data + let originalGetStandSkills + let originalSearchStandSkills + + before(async () => { + originalGetStandSkills = helper.getStandSkills + originalSearchStandSkills = helper.searchStandSkills + helper.searchStandSkills = async () => [] + await testHelper.createData() + data = testHelper.getData() + }) + + after(async () => { + helper.getStandSkills = originalGetStandSkills + helper.searchStandSkills = originalSearchStandSkills + await testHelper.clearData() + }) + + it('searches tags and skill names before calculating pagination headers', async () => { + const searchToken = `unifiede2e${Date.now()}` + const skillId = uuid() + const searchableChallenges = [ + { + id: uuid(), + name: 'A Tag Only', + tags: [`prefix-${searchToken.toUpperCase()}-suffix`] + }, + { + id: uuid(), + name: 'B Skill Only', + tags: [], + skillId + }, + { + id: uuid(), + name: 'C No Match', + tags: [] + } + ] + + helper.searchStandSkills = async term => { + should.equal(term, searchToken) + return [{ id: skillId, name: searchToken }] + } + helper.getStandSkills = async ids => ids.map(id => ({ id, name: searchToken })) + + try { + for (const challenge of searchableChallenges) { + await prisma.challenge.create({ + data: { + id: challenge.id, + name: challenge.name, + description: 'unrelated', + privateDescription: 'unified-search-e2e', + challengeSource: 'Topcoder', + descriptionFormat: 'html', + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: data.challenge.trackId } }, + tags: challenge.tags, + groups: [], + status: ChallengeStatusEnum.ACTIVE, + createdBy: 'unified-search-e2e', + updatedBy: 'unified-search-e2e', + ...(challenge.skillId + ? { + skills: { + create: { + skillId: challenge.skillId, + createdBy: 'unified-search-e2e', + updatedBy: 'unified-search-e2e' + } + } + } + : {}) + } + }) + } + + const response = await request.execute(app) + .get(basePath) + .set('Authorization', `Bearer ${config.M2M_READ_ACCESS_TOKEN}`) + .query({ + search: searchToken, + sortBy: 'name', + sortOrder: 'asc', + page: 2, + perPage: 1 + }) + + should.equal(response.status, 200) + should.equal(response.headers['x-page'], '2') + should.equal(response.headers['x-per-page'], '1') + should.equal(response.headers['x-total'], '2') + should.equal(response.headers['x-total-pages'], '2') + should.equal(response.body.length, 1) + should.equal(response.body[0].name, 'B Skill Only') + response.body[0].skills.should.deep.equal([{ id: skillId, name: searchToken }]) + } finally { + helper.searchStandSkills = async () => [] + helper.getStandSkills = originalGetStandSkills + await prisma.challenge.deleteMany({ + where: { id: { in: searchableChallenges.map(challenge => challenge.id) } } + }) + } + }).timeout(20000) +}) diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 002bdfa..f8466aa 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -52,6 +52,7 @@ describe("challenge service unit tests", () => { let billingLockRequests; let originalLockChallengeBillingAccountAmount; let originalRerateChallengeSubmitterRatings; + let originalSearchStandSkills; const notFoundId = uuid(); const authUser = { userId: "testuser", @@ -200,11 +201,14 @@ describe("challenge service unit tests", () => { }; originalRerateChallengeSubmitterRatings = helper.rerateChallengeSubmitterRatings; helper.rerateChallengeSubmitterRatings = async () => true; + originalSearchStandSkills = helper.searchStandSkills; + helper.searchStandSkills = async () => []; }); afterEach(() => { projectHelper.lockChallengeBillingAccountAmount = originalLockChallengeBillingAccountAmount; helper.rerateChallengeSubmitterRatings = originalRerateChallengeSubmitterRatings; + helper.searchStandSkills = originalSearchStandSkills; }); after(async () => { @@ -1679,6 +1683,217 @@ describe("challenge service unit tests", () => { should.equal(result.result[0].name, data.challenge.name); }); + it("searches names, descriptions, tags and skills before count and pagination", async () => { + const searchToken = `UnifiedSearch${Date.now()}`; + const skillId = uuid(); + const searchChallenges = [ + { + id: uuid(), + name: `A Name ${searchToken}`, + description: "unrelated", + tags: [], + }, + { + id: uuid(), + name: "B Description Only", + description: `contains ${searchToken.toUpperCase()} here`, + tags: [], + }, + { + id: uuid(), + name: "C Tag Only", + description: "unrelated", + tags: [`prefix-${searchToken.toUpperCase()}-suffix`], + }, + { + id: uuid(), + name: "D Skill Only", + description: "unrelated", + tags: [], + skillId, + }, + { + id: uuid(), + name: "E No Match", + description: "unrelated", + tags: [], + }, + ]; + const searchChallengeIds = searchChallenges.map((challenge) => challenge.id); + const originalGetStandSkills = helper.getStandSkills; + + helper.searchStandSkills = async (term) => { + should.equal(term, searchToken.toLowerCase()); + return [{ id: skillId, name: searchToken }]; + }; + helper.getStandSkills = async (ids) => + ids.map((id) => ({ id, name: id === skillId ? searchToken : `Skill ${id}` })); + + try { + for (const challenge of searchChallenges) { + await prisma.challenge.create({ + data: { + id: challenge.id, + name: challenge.name, + description: challenge.description, + privateDescription: "unified-search", + challengeSource: "Topcoder", + descriptionFormat: "html", + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: data.challenge.trackId } }, + tags: challenge.tags, + groups: [], + status: ChallengeStatusEnum.ACTIVE, + createdBy: "unified-search", + updatedBy: "unified-search", + ...(challenge.skillId + ? { + skills: { + create: { + skillId: challenge.skillId, + createdBy: "unified-search", + updatedBy: "unified-search", + }, + }, + } + : {}), + }, + }); + } + + const result = await service.searchChallenges( + { isMachine: true }, + { + ids: searchChallengeIds, + search: searchToken.toLowerCase(), + sortBy: "name", + sortOrder: "asc", + page: 2, + perPage: 2, + }, + ); + + should.equal(result.total, 4); + should.equal(result.page, 2); + should.equal(result.perPage, 2); + _.map(result.result, "name").should.deep.equal(["C Tag Only", "D Skill Only"]); + result.result[1].skills.should.deep.equal([{ id: skillId, name: searchToken }]); + } finally { + helper.getStandSkills = originalGetStandSkills; + await prisma.challenge.deleteMany({ + where: { id: { in: searchChallengeIds } }, + }); + } + }).timeout(10000); + + it("treats AI as an exact tag track facet and ORs it with persisted tracks", async () => { + const developmentTrackId = uuid(); + const designTrackId = uuid(); + const facetChallengeIds = [uuid(), uuid(), uuid(), uuid()]; + + await prisma.challengeTrack.createMany({ + data: [ + { + id: developmentTrackId, + name: `Development AI facet ${Date.now()}`, + description: "Development track for AI facet search", + isActive: true, + track: "DEVELOPMENT", + abbreviation: "Dev", + createdBy: "ai-track-facet", + updatedBy: "ai-track-facet", + }, + { + id: designTrackId, + name: `Design AI facet ${Date.now()}`, + description: "Design track for AI facet search", + isActive: true, + track: "DESIGN", + abbreviation: `Design-${designTrackId}`, + createdBy: "ai-track-facet", + updatedBy: "ai-track-facet", + }, + ], + }); + + const facetChallenges = [ + { id: facetChallengeIds[0], name: "A AI design", trackId: designTrackId, tags: ["AI"] }, + { id: facetChallengeIds[1], name: "B Development", trackId: developmentTrackId, tags: [] }, + { id: facetChallengeIds[2], name: "C AI design", trackId: designTrackId, tags: ["AI"] }, + { id: facetChallengeIds[3], name: "D lowercase ai", trackId: designTrackId, tags: ["ai"] }, + ]; + + try { + for (const challenge of facetChallenges) { + await prisma.challenge.create({ + data: { + id: challenge.id, + name: challenge.name, + description: "AI synthetic track facet test", + privateDescription: "AI synthetic track facet test", + challengeSource: "Topcoder", + descriptionFormat: "html", + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: challenge.trackId } }, + tags: challenge.tags, + groups: [], + status: ChallengeStatusEnum.ACTIVE, + createdBy: "ai-track-facet", + updatedBy: "ai-track-facet", + }, + }); + } + + const aiOnly = await service.searchChallenges( + { isMachine: true }, + { + ids: facetChallengeIds, + tracks: ["AI"], + sortBy: "name", + sortOrder: "asc", + page: 2, + perPage: 1, + }, + ); + should.equal(aiOnly.total, 2); + should.equal(aiOnly.page, 2); + should.equal(aiOnly.perPage, 1); + _.map(aiOnly.result, "name").should.deep.equal(["C AI design"]); + + const aiAndDevelopment = await service.searchChallenges( + { isMachine: true }, + { + ids: facetChallengeIds, + tracks: ["AI", "Dev"], + sortBy: "name", + sortOrder: "asc", + page: 2, + perPage: 2, + }, + ); + should.equal(aiAndDevelopment.total, 3); + should.equal(aiAndDevelopment.page, 2); + should.equal(aiAndDevelopment.perPage, 2); + _.map(aiAndDevelopment.result, "name").should.deep.equal(["C AI design"]); + + const unknown = await service.searchChallenges( + { isMachine: true }, + { ids: facetChallengeIds, tracks: ["NotARealTrack"] }, + ); + should.equal(unknown.total, 0); + should.equal(unknown.result.length, 0); + } finally { + await prisma.challenge.deleteMany({ + where: { id: { in: facetChallengeIds } }, + }); + await prisma.challengeTrack.deleteMany({ + where: { id: { in: [developmentTrackId, designTrackId] } }, + }); + } + }).timeout(10000); + it("search challenges by approvalStatus case-insensitively", async () => { const result = await service.searchChallenges( { isMachine: true },