Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/common/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<Object>>} 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
Expand Down Expand Up @@ -2105,6 +2129,7 @@ module.exports = {
getMemberByHandle,
getMembersByHandles,
getStandSkills,
searchStandSkills,
submitZendeskRequest,
updateSelfServiceProjectInfo,
getFromInternalCache,
Expand Down
164 changes: 128 additions & 36 deletions src/services/ChallengeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -1668,10 +1669,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<Array<String>>} 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<Array<String>>} 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<Object>} paginated challenge rows and total metadata.
* @throws Propagates validation, visibility dependency, and database errors.
*/
async function searchChallenges(currentUser, criteria) {
const page = criteria.page || 1;
Expand Down Expand Up @@ -1763,9 +1817,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 },
Expand All @@ -1774,14 +1845,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 } },
Expand All @@ -1794,10 +1857,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(
Expand All @@ -1812,6 +1875,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)) {
Expand Down Expand Up @@ -1865,33 +1929,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) {
Expand Down
Loading
Loading