From 12361bc2311b7a7f8ef5dc5fe6aed4fd7e3fb271 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 10 Nov 2025 09:30:24 +1100 Subject: [PATCH 1/3] PS-441 fix --- src/common/phase-helper.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/common/phase-helper.js b/src/common/phase-helper.js index 374314a..5236a26 100644 --- a/src/common/phase-helper.js +++ b/src/common/phase-helper.js @@ -91,6 +91,7 @@ class ChallengePhaseHelper { timelineTemplateId ); const { phaseDefinitionMap } = await this.getPhaseDefinitionsAndMap(); + const challengePhaseIds = new Set(_.map(challengePhases, "phaseId")); // Ensure deterministic processing order based on the timeline template sequence // DB returns phases ordered by dates, which can cause "fixedStartDate" logic below @@ -107,9 +108,18 @@ class ChallengePhaseHelper { const phaseFromTemplate = timelineTemplateMap.get(phase.phaseId); const phaseDefinition = phaseDefinitionMap.get(phase.phaseId); const newPhase = _.find(newPhases, (p) => p.phaseId === phase.phaseId); + const templatePredecessor = _.get(phaseFromTemplate, "predecessor"); + // Prefer template predecessor only when that phase exists on the challenge, otherwise keep the stored link. + const resolvedPredecessor = _.isNil(phaseFromTemplate) + ? phase.predecessor + : _.isNil(templatePredecessor) + ? null + : challengePhaseIds.has(templatePredecessor) + ? templatePredecessor + : phase.predecessor; const updatedPhase = { ...phase, - predecessor: phaseFromTemplate && phaseFromTemplate.predecessor, + predecessor: resolvedPredecessor, description: phaseDefinition.description, }; if (updatedPhase.name === "Post-Mortem") { @@ -157,6 +167,9 @@ class ChallengePhaseHelper { const predecessorPhase = _.find(updatedPhases, { phaseId: phase.predecessor, }); + if (_.isNil(predecessorPhase)) { + continue; + } if (phase.name === "Iterative Review") { if (!iterativeReviewSet) { if (_.isNil(phase.actualStartDate)) { From 2d7491078fa15e596595f06c48e639a3764e27f0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 11 Nov 2025 12:56:51 +1100 Subject: [PATCH 2/3] Performance for past challenges call --- src/services/ChallengeService.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index c4bde00..f673d3b 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -949,6 +949,15 @@ async function searchChallenges(currentUser, criteria) { if (criteria.memberId) { memberChallengeIds = await helper.listChallengesByMember(criteria.memberId); + if ( + currentUser && + !_hasAdminRole && + !_.get(currentUser, "isMachine", false) && + _.toString(criteria.memberId) === _.toString(currentUser.userId) + ) { + currentUserChallengeIds = memberChallengeIds; + } + prismaFilter.where.AND.push({ id: { in: memberChallengeIds }, }); From 292aa4d8697ac4f0410f0c18482c9c33b9e8dd2f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 11 Nov 2025 16:07:36 +1100 Subject: [PATCH 3/3] Performance updates for PM-2206 --- .../migration.sql | 61 +++++++++++++++-- .../migration.sql | 30 +++++++++ prisma/schema.prisma | 15 +++++ src/common/helper.js | 27 -------- src/services/ChallengeService.js | 65 ++++++++++--------- 5 files changed, 135 insertions(+), 63 deletions(-) create mode 100644 prisma/migrations/20251121140000_member_access_view/migration.sql diff --git a/prisma/migrations/20251107090000_add_my_reviews_indexes/migration.sql b/prisma/migrations/20251107090000_add_my_reviews_indexes/migration.sql index c71d518..5a09bb0 100644 --- a/prisma/migrations/20251107090000_add_my_reviews_indexes/migration.sql +++ b/prisma/migrations/20251107090000_add_my_reviews_indexes/migration.sql @@ -1,13 +1,66 @@ -- Add indexes to support faster `/v6/my-reviews` queries. -CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE SCHEMA IF NOT EXISTS skills; +CREATE SCHEMA IF NOT EXISTS reviews; + +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA skills; +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA pg_catalog; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA reviews; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA skills; CREATE INDEX IF NOT EXISTS "challenge_status_type_track_created_at_idx" ON "Challenge" ("status", "typeId", "trackId", "createdAt" DESC); +DROP INDEX IF EXISTS "challenge_name_idx"; + +CREATE INDEX IF NOT EXISTS "challenge_name_trgm_idx" + ON "Challenge" USING gin ("name" pg_catalog.gin_trgm_ops); + +DO +$$ +DECLARE + challenge_phase_schema TEXT; +BEGIN + SELECT n.nspname + INTO challenge_phase_schema + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'ChallengePhase' + AND c.relkind = 'r' + LIMIT 1; + + IF challenge_phase_schema IS NULL THEN + RETURN; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_class idx + JOIN pg_namespace ns ON ns.oid = idx.relnamespace + WHERE idx.relname = 'challenge_phase_order_idx' + AND ns.nspname = challenge_phase_schema + ) + AND EXISTS ( + SELECT 1 + FROM pg_class idx + JOIN pg_namespace ns ON ns.oid = idx.relnamespace + WHERE idx.relname = 'challenge_phase_challenge_open_end_idx' + AND ns.nspname = challenge_phase_schema + AND pg_get_indexdef(idx.oid) LIKE '%("challengeId", "isOpen", "scheduledEndDate", "actualEndDate", name)%' + ) + THEN + EXECUTE format( + 'ALTER INDEX %I.%I RENAME TO %I', + challenge_phase_schema, + 'challenge_phase_challenge_open_end_idx', + 'challenge_phase_order_idx' + ); + END IF; +END +$$ LANGUAGE plpgsql; + CREATE INDEX IF NOT EXISTS "challenge_phase_challenge_open_end_idx" ON "ChallengePhase" ("challengeId", "isOpen", "scheduledEndDate", "actualEndDate"); -CREATE INDEX IF NOT EXISTS "challenge_name_trgm_idx" - ON "Challenge" - USING gin ("name" pg_catalog.gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "challenge_phase_order_idx" + ON "ChallengePhase" ("challengeId", "isOpen", "scheduledEndDate", "actualEndDate", "name"); diff --git a/prisma/migrations/20251121140000_member_access_view/migration.sql b/prisma/migrations/20251121140000_member_access_view/migration.sql new file mode 100644 index 0000000..d1865cc --- /dev/null +++ b/prisma/migrations/20251121140000_member_access_view/migration.sql @@ -0,0 +1,30 @@ +-- View to use in performance updates (PM-2206) +DROP VIEW IF EXISTS "challenges"."MemberChallengeAccess"; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'resources' + AND table_name = 'Resource' + ) THEN + EXECUTE format( + 'CREATE VIEW %I.%I AS + SELECT DISTINCT r."challengeId", r."memberId" + FROM resources."Resource" r + WHERE r."challengeId" IS NOT NULL + AND r."memberId" IS NOT NULL', + current_schema(), 'MemberChallengeAccess' + ); + ELSE + EXECUTE format( + 'CREATE VIEW %I.%I AS + SELECT CAST(NULL AS TEXT) AS "challengeId", + CAST(NULL AS TEXT) AS "memberId" + WHERE FALSE', + current_schema(), 'MemberChallengeAccess' + ); + END IF; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 19235cf..76fa346 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,7 @@ model Challenge { terms ChallengeTerm[] skills ChallengeSkill[] auditLogs AuditLog[] + memberAccesses MemberChallengeAccess[] // Relation to ChallengeType (FK: typeId) type ChallengeType @relation(fields: [typeId], references: [id]) @@ -161,6 +162,20 @@ model Challenge { @@index([projectId, status]) } +////////////////////////////////////////// +// MemberChallengeAccess view – member/challenge pairs from resources schema +////////////////////////////////////////// + +model MemberChallengeAccess { + challengeId String + memberId String + + challenge Challenge @relation(fields: [challengeId], references: [id]) + + @@id([challengeId, memberId]) + @@map("MemberChallengeAccess") +} + ////////////////////////////////////////// // ChallengeType model ////////////////////////////////////////// diff --git a/src/common/helper.js b/src/common/helper.js index d22c711..a22e55f 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -976,32 +976,6 @@ function calculateChallengeEndDate(challenge, data) { // return result.toDate() } -/** - * Lists challenge ids that given member has access to. - * @param {Number} memberId the member id - * @returns {Promise} an array of challenge ids represents challenges that given member has access to. - */ -async function listChallengesByMember(memberId) { - const token = await m2mHelper.getM2MToken(); - let allIds = []; - - try { - const result = await axios.get(`${config.RESOURCES_API_URL}/${memberId}/challenges`, { - headers: { Authorization: `Bearer ${token}` }, - params: { - useScroll: true, - }, - }); - - allIds = result.data || []; - } catch (e) { - // only log the error but don't throw it, so the following logic can still be executed. - logger.debug(`Failed to get challenges that accessible to the memberId ${memberId}`, e); - } - - return allIds; -} - /** * Lists resources that given member has in the given challenge. * @param {Number} memberId the member id @@ -1544,7 +1518,6 @@ module.exports = { dedupeChallengeTerms, postBusEvent, calculateChallengeEndDate, - listChallengesByMember, listResourcesByMemberAndChallenge, getProjectDefaultTerms, validateChallengeTerms, diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index f673d3b..beb13e6 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -938,32 +938,28 @@ async function searchChallenges(currentUser, criteria) { }); } - let memberChallengeIds; - let currentUserChallengeIds; - let currentUserChallengeIdSet; + const requestedMemberId = !_.isNil(criteria.memberId) + ? _.toString(criteria.memberId) + : null; + const currentUserMemberId = + currentUser && !_hasAdminRole && !_.get(currentUser, "isMachine", false) + ? _.toString(currentUser.userId) + : null; + const memberIdForTaskFilter = requestedMemberId || currentUserMemberId; // FIXME: This is wrong! // if (!_.isUndefined(currentUser) && currentUser.handle) { // accessQuery.push({ match_phrase: { createdBy: currentUser.handle } }) // } - if (criteria.memberId) { - memberChallengeIds = await helper.listChallengesByMember(criteria.memberId); - if ( - currentUser && - !_hasAdminRole && - !_.get(currentUser, "isMachine", false) && - _.toString(criteria.memberId) === _.toString(currentUser.userId) - ) { - currentUserChallengeIds = memberChallengeIds; - } - + if (requestedMemberId) { prismaFilter.where.AND.push({ - id: { in: memberChallengeIds }, + memberAccesses: { + some: { + memberId: requestedMemberId, + }, + }, }); - } else if (currentUser && !_hasAdminRole && !_.get(currentUser, "isMachine", false)) { - currentUserChallengeIds = await helper.listChallengesByMember(currentUser.userId); - memberChallengeIds = currentUserChallengeIds; } // FIXME: Tech Debt @@ -1000,9 +996,11 @@ async function searchChallenges(currentUser, criteria) { } } else if (excludeTasks) { const taskFilter = []; - if (_.get(memberChallengeIds, "length", 0) > 0) { + if (memberIdForTaskFilter) { taskFilter.push({ - id: { in: memberChallengeIds }, + memberAccesses: { + some: { memberId: memberIdForTaskFilter }, + }, }); } taskFilter.push({ @@ -1025,12 +1023,22 @@ async function searchChallenges(currentUser, criteria) { const sortFilter = {}; sortFilter[sortByProp] = sortOrderProp; + const challengeInclude = currentUserMemberId + ? { + ...includeReturnFields, + memberAccesses: { + where: { memberId: currentUserMemberId }, + select: { memberId: true }, + }, + } + : includeReturnFields; + const prismaQuery = { ...prismaFilter, take: perPage, skip: (page - 1) * perPage, orderBy: [sortFilter], - include: includeReturnFields, + include: challengeInclude, }; try { @@ -1088,20 +1096,13 @@ async function searchChallenges(currentUser, criteria) { if (!currentUser.isMachine && !_hasAdminRole) { result.forEach((challenge) => { _.unset(challenge, "billing"); - }); - - if (!currentUserChallengeIds) { - currentUserChallengeIds = await helper.listChallengesByMember(currentUser.userId); - } - - const accessibleIds = currentUserChallengeIds || []; - currentUserChallengeIdSet = currentUserChallengeIdSet || new Set(accessibleIds); - - result.forEach((challenge) => { - if (!currentUserChallengeIdSet.has(challenge.id)) { + if (!_.get(challenge, "memberAccesses.length")) { _.unset(challenge, "privateDescription"); } + _.unset(challenge, "memberAccesses"); }); + } else { + result.forEach((challenge) => _.unset(challenge, "memberAccesses")); } } else { result.forEach((challenge) => {