From 99b39f548cee5f47f34cf5ca2bfc8b55bfc7409d Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 25 Mar 2026 20:18:46 +0530 Subject: [PATCH 01/15] PM-4305 Remove photo url check for completed profiles --- .circleci/config.yml | 2 +- sql/reports/topcoder/completed-profiles-count.sql | 2 -- sql/reports/topcoder/completed-profiles.sql | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f8b519c..2f5e993 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - pm-4203_1 + - PM-4305 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 97c432b..453128b 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -13,8 +13,6 @@ FROM members.member m INNER JOIN member_skills ms ON ms.user_id = m."userId" WHERE m.description IS NOT NULL AND m.description <> '' - AND m."photoURL" IS NOT NULL - AND m."photoURL" <> '' AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($3::uuid[] IS NULL OR ms.skill_ids @> $3::uuid[]) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index dfc7191..cfbe8dd 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -56,8 +56,6 @@ LEFT JOIN LATERAL ( ) ma ON TRUE WHERE m.description IS NOT NULL AND m.description <> '' - AND m."photoURL" IS NOT NULL - AND m."photoURL" <> '' AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($5::uuid[] IS NULL OR ms.skill_ids @> $5::uuid[]) From a1544f22804ab5f1d279191fa771628182e2f7cd Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 17:08:25 +0530 Subject: [PATCH 02/15] PM-4491 Add names in registered users --- sql/reports/challenges/registered-users.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sql/reports/challenges/registered-users.sql b/sql/reports/challenges/registered-users.sql index 2a82024..12e7f66 100644 --- a/sql/reports/challenges/registered-users.sql +++ b/sql/reports/challenges/registered-users.sql @@ -28,6 +28,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), rm."memberHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, From a91e218e71c58dd2219cb3faf8967e96561b6d13 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 17:08:58 +0530 Subject: [PATCH 03/15] Deploy branch --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f5e993..d065519 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,7 @@ workflows: - develop - pm-1127_1 - PM-4305 + - PM-4491-fix # Production builds are exectuted only on tagged commits to the # master branch. From 135dafab84a671714ad804440df7cb66fd7c8561 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 17:22:30 +0530 Subject: [PATCH 04/15] add names to registrants history report --- sql/reports/challenges/registrants-history.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sql/reports/challenges/registrants-history.sql b/sql/reports/challenges/registrants-history.sql index f38e0ef..eb3c5ef 100644 --- a/sql/reports/challenges/registrants-history.sql +++ b/sql/reports/challenges/registrants-history.sql @@ -91,9 +91,25 @@ SELECT ELSE null END AS "challengeCompletedDate", r."registrantHandle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(sub."registrantFinalScore", sum."registrantFinalScore") AS "registrantFinalScore" FROM registrants r +LEFT JOIN identity."user" u + ON r."memberId" ~ '^[0-9]+$' + AND u.user_id = r."memberId"::numeric + +LEFT JOIN members."member" mem + ON r."memberId" ~ '^[0-9]+$' + AND mem."userId" = r."memberId"::bigint LEFT JOIN LATERAL ( SELECT MAX(cw.handle) AS "winnerHandle", From d41987de415521363c1d70dd654789f6bafb899f Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 17:39:55 +0530 Subject: [PATCH 05/15] Add names to submitters query --- sql/reports/challenges/submitters.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 9231bc8..4c8b55d 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -110,6 +110,14 @@ SELECT sm."memberHandle" ) AS "handle", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE( comp_code.name, comp_id.name, From 13c367b7a042ea181481d7c97f937f3fd44d661e Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 17:51:36 +0530 Subject: [PATCH 06/15] fix dto --- src/reports/challenges/challenges-reports.service.ts | 2 ++ src/reports/challenges/dtos/challenge-users.dto.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index aeefc48..957b612 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -203,6 +203,8 @@ export class ChallengesReportsService { userId: record.userId, handle: record.handle, email: record.email ?? null, + firstName: record.firstName ?? null, + lastName: record.lastName ?? null, country: record.country ?? null, }; diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 33bf7b3..bb39570 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -25,6 +25,8 @@ export interface ChallengeUserRecordDto { userId: number; handle: string; email: string | null; + firstName: string | null; + lastName: string | null; country: string | null; submissionScore?: number | null; provisionalScore?: number | null; From de46c8ec8d4e08e85cdfd8a697eff77404e72546 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 18:02:07 +0530 Subject: [PATCH 07/15] Add names to valid submitters query --- sql/reports/challenges/valid-submitters.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index c223e44..db6c1ad 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -126,6 +126,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), vsm."memberHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, From bb86f51573cb2d73b54b3fb53bcc61532fa76530 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 30 Mar 2026 18:15:11 +0530 Subject: [PATCH 08/15] Add names to winners query --- sql/reports/challenges/winners.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index a9ae4df..3dad239 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -88,6 +88,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), wm."winnerHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, From 9d04237964ca3cb0fcb98bdff106aaeac3fc95b2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 03:43:56 +1100 Subject: [PATCH 09/15] PM-4151: add MM final scores to challenge exports What was broken Marathon Match challenge exports in the new challenge reports only exposed provisionalScore and finalRank. When a challenge only had final review scores for some members, those rows exported with blank score cells and no finalScore column even though the ranking logic already had access to final scores. Root cause (if identifiable) The earlier PM-4151 follow-up on develop reshaped Marathon Match rows to publish only provisionalScore and finalRank. The SQL still loaded final review scores for effective ranking, but the report queries and formatter never projected finalScore into the exported record shape. What was changed Added finalScore to the Marathon Match challenge report DTO and formatter output. Updated the submitters, valid-submitters, and winners SQL to emit rounded finalScore values while preserving the existing effective-rank ordering and tie-break behavior. Updated the challenge report catalog descriptions to document provisionalScore and finalScore availability. Included the repo lint auto-format on one existing conditional in topcoder-reports.service.ts. Any added/updated tests Added ChallengesReportsService regression tests covering Marathon Match submitter and winner exports plus the unchanged standard challenge submissionScore shape. The new challenge report spec passes. The repo-wide pnpm test command still fails in unrelated pre-existing SFDC specs on this branch. --- sql/reports/challenges/submitters.sql | 8 + sql/reports/challenges/valid-submitters.sql | 8 + sql/reports/challenges/winners.sql | 10 +- .../challenges-reports.service.spec.ts | 144 ++++++++++++++++++ .../challenges/challenges-reports.service.ts | 3 +- .../challenges/dtos/challenge-users.dto.ts | 7 +- src/reports/report-directory.data.ts | 6 +- .../topcoder/topcoder-reports.service.ts | 4 +- 8 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 src/reports/challenges/challenges-reports.service.spec.ts diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 9231bc8..959cd9a 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -85,6 +85,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -127,6 +131,10 @@ SELECT WHEN sm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index c223e44..85bb671 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -102,6 +102,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -144,6 +148,10 @@ SELECT WHEN vsm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index a9ae4df..68a1f3c 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -72,7 +72,11 @@ mm_winner_scores AS ( CASE WHEN mms.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mms.provisional_score_raw::numeric, 2) - END AS "provisionalScore" + END AS "provisionalScore", + CASE + WHEN mms.final_score_raw IS NULL THEN NULL + ELSE ROUND(mms.final_score_raw::numeric, 2) + END AS "finalScore" FROM mm_member_scores AS mms ) SELECT @@ -106,6 +110,10 @@ SELECT WHEN wm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN wm.is_marathon_match THEN wm.placement ELSE NULL diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts new file mode 100644 index 0000000..ae69299 --- /dev/null +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -0,0 +1,144 @@ +import { ChallengesReportsService } from "./challenges-reports.service"; + +describe("ChallengesReportsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn(), + }; + + let service: ChallengesReportsService; + + beforeEach(() => { + db.query.mockReset(); + sql.load.mockReset(); + sql.load.mockReturnValue("SELECT 1"); + service = new ChallengesReportsService(db as any, sql as any); + }); + + it("returns Marathon Match submitters with provisional and final score columns", async () => { + db.query.mockResolvedValue([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + country: "Australia", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + country: "China", + isMarathonMatch: true, + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/submitters.sql"); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "country", + "provisionalScore", + "finalScore", + "finalRank", + ]); + expect(result).toEqual([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + country: "Australia", + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + country: "China", + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + }); + + it("returns Marathon Match winners with final scores when available", async () => { + db.query.mockResolvedValue([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + country: "Afghanistan", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + + const result = await service.getWinners({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/winners.sql"); + expect(result).toEqual([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + country: "Afghanistan", + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + }); + + it("keeps standard challenge exports on the submissionScore shape", async () => { + db.query.mockResolvedValue([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + country: "Japan", + isMarathonMatch: false, + submissionScore: 34.34, + }, + ]); + + const result = await service.getValidSubmitters({ + challengeId: "3bb4d076-d2e7-4bd5-82ac-7eb4dd2d14a8", + }); + + expect(sql.load).toHaveBeenCalledWith( + "reports/challenges/valid-submitters.sql", + ); + expect(result).toEqual([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + country: "Japan", + submissionScore: 34.34, + }, + ]); + expect(result[0]).not.toHaveProperty("provisionalScore"); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); +}); diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index aeefc48..13d69bf 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -184,7 +184,7 @@ export class ChallengesReportsService { /** * Normalizes raw challenge user report rows into the exported column shape. * @param records SQL rows for one challenge report, including the internal Marathon Match flag. - * @returns Export-ready records with either submissionScore or Marathon Match-specific columns. + * @returns Export-ready records with either submissionScore or the Marathon Match-specific score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -208,6 +208,7 @@ export class ChallengesReportsService { if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalScore = record.finalScore ?? null; normalized.finalRank = record.finalRank ?? null; return normalized; } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 33bf7b3..8845e5f 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,9 +17,9 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. * Standard challenge submission-based reports expose submissionScore. - * Marathon Match submission-based reports expose provisionalScore from the - * latest submission and finalRank by current effective score, breaking ties by - * earlier submission time. + * Marathon Match submission-based reports expose provisionalScore and + * finalScore from the latest submission, plus finalRank by current effective + * score, breaking ties by earlier submission time. */ export interface ChallengeUserRecordDto { userId: number; @@ -28,5 +28,6 @@ export interface ChallengeUserRecordDto { country: string | null; submissionScore?: number | null; provisionalScore?: number | null; + finalScore?: number | null; finalRank?: number | null; } diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 5e91355..0c1eaee 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -390,21 +390,21 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.Submitters, [challengeIdParam], ), challengeReport( "Challenge Valid Submitters", "/challenges/:challengeId/valid-submitters", - "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore, finalScore, and the challenge-result finalRank.", AppScopes.Challenge.Winners, [challengeIdParam], ), diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 6342939..2f07a16 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -703,9 +703,7 @@ export class TopcoderReportsService { principalSkills: row.principalSkills || undefined, openToWork: row.openToWork ?? null, isOpenToWork: - typeof row.isOpenToWork === "boolean" - ? row.isOpenToWork - : false, + typeof row.isOpenToWork === "boolean" ? row.isOpenToWork : false, })); return { From 2e6aa4a2afed5d33aea379e9c6267f8307342d56 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 4 Apr 2026 03:36:05 +1100 Subject: [PATCH 10/15] PM-4151: add MM final scores to challenge exports What was broken Marathon Match challenge exports in the new challenge reports only exposed provisionalScore and finalRank. When a challenge only had final review scores for some members, those rows exported with blank score cells and no finalScore column even though the ranking logic already had access to final scores. Root cause (if identifiable) The earlier PM-4151 follow-up on develop reshaped Marathon Match rows to publish only provisionalScore and finalRank. The SQL still loaded final review scores for effective ranking, but the report queries and formatter never projected finalScore into the exported record shape. What was changed Added finalScore to the Marathon Match challenge report DTO and formatter output. Updated the submitters, valid-submitters, and winners SQL to emit rounded finalScore values while preserving the existing effective-rank ordering and tie-break behavior. Updated the challenge report catalog descriptions to document provisionalScore and finalScore availability. Aligned the new challenge report regression spec with the current develop branch export shape, including firstName and lastName fields. Included the repo lint auto-format on one existing conditional in topcoder-reports.service.ts. Any added/updated tests Added ChallengesReportsService regression tests covering Marathon Match submitter and winner exports plus the unchanged standard challenge submissionScore shape on the current export record format. Verified pnpm lint, the new challenge report spec, and pnpm build on this branch. The repo-wide pnpm test command still fails in unrelated pre-existing SFDC specs on the current develop base. --- sql/reports/challenges/submitters.sql | 8 + sql/reports/challenges/valid-submitters.sql | 8 + sql/reports/challenges/winners.sql | 10 +- .../challenges-reports.service.spec.ts | 162 ++++++++++++++++++ .../challenges/challenges-reports.service.ts | 3 +- .../challenges/dtos/challenge-users.dto.ts | 7 +- src/reports/report-directory.data.ts | 6 +- .../topcoder/topcoder-reports.service.ts | 4 +- 8 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 src/reports/challenges/challenges-reports.service.spec.ts diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 4c8b55d..34d08ab 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -85,6 +85,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -135,6 +139,10 @@ SELECT WHEN sm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index db6c1ad..701e0f8 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -102,6 +102,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -152,6 +156,10 @@ SELECT WHEN vsm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 3dad239..3f71104 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -72,7 +72,11 @@ mm_winner_scores AS ( CASE WHEN mms.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mms.provisional_score_raw::numeric, 2) - END AS "provisionalScore" + END AS "provisionalScore", + CASE + WHEN mms.final_score_raw IS NULL THEN NULL + ELSE ROUND(mms.final_score_raw::numeric, 2) + END AS "finalScore" FROM mm_member_scores AS mms ) SELECT @@ -114,6 +118,10 @@ SELECT WHEN wm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN wm.is_marathon_match THEN wm.placement ELSE NULL diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts new file mode 100644 index 0000000..999ac5d --- /dev/null +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -0,0 +1,162 @@ +import { ChallengesReportsService } from "./challenges-reports.service"; + +describe("ChallengesReportsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn(), + }; + + let service: ChallengesReportsService; + + beforeEach(() => { + db.query.mockReset(); + sql.load.mockReset(); + sql.load.mockReturnValue("SELECT 1"); + service = new ChallengesReportsService(db as any, sql as any); + }); + + it("returns Marathon Match submitters with provisional and final score columns", async () => { + db.query.mockResolvedValue([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", + country: "Australia", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", + country: "China", + isMarathonMatch: true, + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/submitters.sql"); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "firstName", + "lastName", + "country", + "provisionalScore", + "finalScore", + "finalRank", + ]); + expect(result).toEqual([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", + country: "Australia", + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", + country: "China", + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + }); + + it("returns Marathon Match winners with final scores when available", async () => { + db.query.mockResolvedValue([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", + country: "Afghanistan", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + + const result = await service.getWinners({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/winners.sql"); + expect(result).toEqual([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", + country: "Afghanistan", + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + }); + + it("keeps standard challenge exports on the submissionScore shape", async () => { + db.query.mockResolvedValue([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", + country: "Japan", + isMarathonMatch: false, + submissionScore: 34.34, + }, + ]); + + const result = await service.getValidSubmitters({ + challengeId: "3bb4d076-d2e7-4bd5-82ac-7eb4dd2d14a8", + }); + + expect(sql.load).toHaveBeenCalledWith( + "reports/challenges/valid-submitters.sql", + ); + expect(result).toEqual([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", + country: "Japan", + submissionScore: 34.34, + }, + ]); + expect(result[0]).not.toHaveProperty("provisionalScore"); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); +}); diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index 957b612..a94d39a 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -184,7 +184,7 @@ export class ChallengesReportsService { /** * Normalizes raw challenge user report rows into the exported column shape. * @param records SQL rows for one challenge report, including the internal Marathon Match flag. - * @returns Export-ready records with either submissionScore or Marathon Match-specific columns. + * @returns Export-ready records with either submissionScore or the Marathon Match-specific score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -210,6 +210,7 @@ export class ChallengesReportsService { if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalScore = record.finalScore ?? null; normalized.finalRank = record.finalRank ?? null; return normalized; } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index bb39570..8e5dcfb 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,9 +17,9 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. * Standard challenge submission-based reports expose submissionScore. - * Marathon Match submission-based reports expose provisionalScore from the - * latest submission and finalRank by current effective score, breaking ties by - * earlier submission time. + * Marathon Match submission-based reports expose provisionalScore and + * finalScore from the latest submission, plus finalRank by current effective + * score, breaking ties by earlier submission time. */ export interface ChallengeUserRecordDto { userId: number; @@ -30,5 +30,6 @@ export interface ChallengeUserRecordDto { country: string | null; submissionScore?: number | null; provisionalScore?: number | null; + finalScore?: number | null; finalRank?: number | null; } diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 5e91355..0c1eaee 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -390,21 +390,21 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.Submitters, [challengeIdParam], ), challengeReport( "Challenge Valid Submitters", "/challenges/:challengeId/valid-submitters", - "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore, finalScore, and the challenge-result finalRank.", AppScopes.Challenge.Winners, [challengeIdParam], ), diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index dd7cf31..64f9ff0 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -709,9 +709,7 @@ export class TopcoderReportsService { principalSkills: row.principalSkills || undefined, openToWork: row.openToWork ?? null, isOpenToWork: - typeof row.isOpenToWork === "boolean" - ? row.isOpenToWork - : false, + typeof row.isOpenToWork === "boolean" ? row.isOpenToWork : false, })); return { From c75969fbdc395aefa09410fba6fe5fed6c6a00b5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Apr 2026 00:47:02 +1000 Subject: [PATCH 11/15] PM-4151: align challenge export regression spec with names What was broken The PM-4151 challenge export regression spec on current develop still asserted the older export shape without firstName and lastName, so the focused challenge report test failed even though the Marathon Match finalScore export fix had already been merged. Root cause (if identifiable) The earlier follow-up branch updated the regression spec for the current export shape, but that spec change never landed on top of the develop branch after firstName and lastName were added to challenge user exports. What was changed Updated the challenge report regression fixtures and expectations to include firstName and lastName for Marathon Match and standard challenge exports. Kept the follow-up scoped to the regression coverage only because the functional MM finalScore export fix is already present on develop. Any added/updated tests Updated src/reports/challenges/challenges-reports.service.spec.ts to assert the current export columns. Verified pnpm lint, pnpm test --runInBand src/reports/challenges/challenges-reports.service.spec.ts, and pnpm build on this branch. The full pnpm test --runInBand suite still fails in unrelated pre-existing SFDC specs on the current develop base. --- .../challenges-reports.service.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts index ae69299..999ac5d 100644 --- a/src/reports/challenges/challenges-reports.service.spec.ts +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -23,6 +23,8 @@ describe("ChallengesReportsService", () => { userId: 88770025, handle: "devtest1400", email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", country: "Australia", isMarathonMatch: true, provisionalScore: null, @@ -33,6 +35,8 @@ describe("ChallengesReportsService", () => { userId: 22655076, handle: "liuliquan", email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", country: "China", isMarathonMatch: true, provisionalScore: 89.18, @@ -50,6 +54,8 @@ describe("ChallengesReportsService", () => { "userId", "handle", "email", + "firstName", + "lastName", "country", "provisionalScore", "finalScore", @@ -60,6 +66,8 @@ describe("ChallengesReportsService", () => { userId: 88770025, handle: "devtest1400", email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", country: "Australia", provisionalScore: null, finalScore: 96.42, @@ -69,6 +77,8 @@ describe("ChallengesReportsService", () => { userId: 22655076, handle: "liuliquan", email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", country: "China", provisionalScore: 89.18, finalScore: null, @@ -83,6 +93,8 @@ describe("ChallengesReportsService", () => { userId: 40158994, handle: "TCConnCopilot", email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", country: "Afghanistan", isMarathonMatch: true, provisionalScore: null, @@ -101,6 +113,8 @@ describe("ChallengesReportsService", () => { userId: 40158994, handle: "TCConnCopilot", email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", country: "Afghanistan", provisionalScore: null, finalScore: 100, @@ -115,6 +129,8 @@ describe("ChallengesReportsService", () => { userId: 88778748, handle: "disnadiji", email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", country: "Japan", isMarathonMatch: false, submissionScore: 34.34, @@ -133,6 +149,8 @@ describe("ChallengesReportsService", () => { userId: 88778748, handle: "disnadiji", email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", country: "Japan", submissionScore: 34.34, }, From f108c3a3df308587547959f4a589883398ec53d0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 8 Apr 2026 13:19:30 +1000 Subject: [PATCH 12/15] PM-4151: fallback MM export final scores to submission data What was broken Marathon Match submitter, valid submitter, and winner exports could leave finalScore blank when a final score existed only on the reviews.submission record. QA still reported blank score columns in production for those cases. Root cause The challenge export SQL only projected final_score_raw from the latest non-provisional reviewSummation row even though the surrounding scoring logic already treats submission.finalScore as a valid fallback source. What was changed Updated the three Marathon Match challenge export queries to use COALESCE(final_review.aggregateScore, submission.finalScore) for final_score_raw so the export keeps finalScore populated when review summary rows are absent. Any added/updated tests Added a regression spec that loads the submitters, valid-submitters, and winners SQL files and asserts they all preserve the submission.finalScore fallback. --- sql/reports/challenges/submitters.sql | 5 ++++- sql/reports/challenges/valid-submitters.sql | 5 ++++- sql/reports/challenges/winners.sql | 5 ++++- .../challenges/challenge-export-sql.spec.ts | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 src/reports/challenges/challenge-export-sql.spec.ts diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 34d08ab..3a19f08 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -18,7 +18,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 701e0f8..1f4ee22 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -18,7 +18,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw, + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw, ( passing_review.is_passing IS TRUE OR COALESCE(s."finalScore"::double precision, 0) > 98 diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 3f71104..8dc628a 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -16,7 +16,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts new file mode 100644 index 0000000..15b92b1 --- /dev/null +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -0,0 +1,20 @@ +import { SqlLoaderService } from "src/common/sql-loader.service"; + +describe("Challenge export SQL", () => { + const sqlLoader = new SqlLoaderService(); + + it.each([ + "reports/challenges/submitters.sql", + "reports/challenges/valid-submitters.sql", + "reports/challenges/winners.sql", + ])( + "falls back to submission.finalScore in %s when no final review summary exists", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toMatch( + /COALESCE\(\s*final_review\."aggregateScore",\s*s\."finalScore"::double precision\s*\)\s+AS final_score_raw/, + ); + }, + ); +}); From b51a69aa4ef4e41197f2d2da102e2fcf88dac1cc Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 8 Apr 2026 17:09:18 +1000 Subject: [PATCH 13/15] Engagements data report --- README.md | 9 + sql/reports/topcoder/engagement-data.sql | 74 +++ src/app-constants.ts | 2 + .../guards/topcoder-reports.guard.spec.ts | 23 + src/auth/permissions.util.spec.ts | 11 + src/reports/report-directory.data.spec.ts | 2 + src/reports/report-directory.data.ts | 10 + .../topcoder/topcoder-reports.controller.ts | 14 + .../topcoder/topcoder-reports.service.spec.ts | 182 ++++++ .../topcoder/topcoder-reports.service.ts | 535 +++++++++++++++++- 10 files changed, 859 insertions(+), 3 deletions(-) create mode 100644 sql/reports/topcoder/engagement-data.sql create mode 100644 src/reports/topcoder/topcoder-reports.service.spec.ts diff --git a/README.md b/README.md index add20e6..1d5b70b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ Create a `.env` file in the root of the project. You can copy the example struct # This is used by Prisma to connect to your local PostgreSQL instance. DATABASE_URL="postgresql://user:password@localhost:5432/lookups?schema=public" +# Engagements database used by the Engagement Data member report. +ENGAGEMENTS_DB_URL="postgresql://user:password@localhost:5432/engagements" + # Old tc-payments database URL (used by member-tax CSV export script) OLD_PAYMENTS_DATABASE_URL="postgresql://user:password@localhost:5432/tc_payments?schema=public" @@ -70,6 +73,12 @@ AUTH_SECRET="mysecret" # A JSON array string of valid token issuers. VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]' +# Platform API base URL plus M2M credentials used when reports need member or +# project enrichment outside the main reports database. +TOPCODER_API_URL_BASE="https://api.topcoder-dev.com" +M2M_CLIENT_ID="your-m2m-client-id" +M2M_CLIENT_SECRET="your-m2m-client-secret" + ## Running the Application ### Development Mode diff --git a/sql/reports/topcoder/engagement-data.sql b/sql/reports/topcoder/engagement-data.sql new file mode 100644 index 0000000..c9fead1 --- /dev/null +++ b/sql/reports/topcoder/engagement-data.sql @@ -0,0 +1,74 @@ +WITH assignment_members AS ( + SELECT + NULLIF(BTRIM(a."memberId"), '') AS member_id, + NULLIF(BTRIM(a."memberHandle"), '') AS fallback_handle, + NULLIF(BTRIM(e."projectId"), '') AS project_id + FROM "EngagementAssignment" a + JOIN "Engagement" e + ON e.id = a."engagementId" + WHERE NULLIF(BTRIM(a."memberId"), '') IS NOT NULL +), +assigned_summary AS ( + SELECT + member_id, + MAX(fallback_handle) AS fallback_handle, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT project_id), NULL) AS assigned_project_ids + FROM assignment_members + GROUP BY member_id +), +ranked_public_applications AS ( + SELECT + NULLIF(BTRIM(app."userId"), '') AS member_id, + NULLIF(BTRIM(app.handle), '') AS application_handle, + NULLIF(BTRIM(app.email), '') AS application_email, + NULLIF(BTRIM(app.address), '') AS application_address, + NULLIF(BTRIM(app."mobileNumber"), '') AS application_phone, + NULLIF(BTRIM(app.name), '') AS application_name, + ROW_NUMBER() OVER ( + PARTITION BY app."userId" + ORDER BY app."updatedAt" DESC, app."createdAt" DESC, app.id DESC + ) AS rn + FROM "EngagementApplication" app + JOIN "Engagement" e + ON e.id = app."engagementId" + WHERE COALESCE(e."isPrivate", false) = false + AND NULLIF(BTRIM(app."userId"), '') IS NOT NULL +), +latest_public_application AS ( + SELECT + member_id, + application_handle, + application_email, + application_address, + application_phone, + application_name + FROM ranked_public_applications + WHERE rn = 1 +), +eligible_members AS ( + SELECT member_id + FROM assigned_summary + UNION + SELECT member_id + FROM latest_public_application +) +SELECT + em.member_id, + COALESCE(assigned_summary.fallback_handle, latest_public_application.application_handle) + AS fallback_handle, + latest_public_application.application_email, + latest_public_application.application_address, + latest_public_application.application_phone, + latest_public_application.application_name, + (assigned_summary.member_id IS NOT NULL) AS has_assignment, + COALESCE(assigned_summary.assigned_project_ids, ARRAY[]::text[]) AS assigned_project_ids +FROM eligible_members em +LEFT JOIN assigned_summary + ON assigned_summary.member_id = em.member_id +LEFT JOIN latest_public_application + ON latest_public_application.member_id = em.member_id +ORDER BY COALESCE( + assigned_summary.fallback_handle, + latest_public_application.application_handle, + em.member_id +); diff --git a/src/app-constants.ts b/src/app-constants.ts index c7e1365..8ce6c89 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -7,6 +7,7 @@ export const Scopes = { AllReports: "reports:all", TopcoderReports: "reports:topcoder", Member: { + EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", @@ -61,6 +62,7 @@ export const ScopeRoleAccess: Record = { [Scopes.Challenge.Submitters]: challengeReportAccessRoles, [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.Member.EngagementData]: [UserRoles.TalentManager], [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Identity.UsersByHandles]: [ UserRoles.TalentManager, diff --git a/src/auth/guards/topcoder-reports.guard.spec.ts b/src/auth/guards/topcoder-reports.guard.spec.ts index 5d417bd..aa89333 100644 --- a/src/auth/guards/topcoder-reports.guard.spec.ts +++ b/src/auth/guards/topcoder-reports.guard.spec.ts @@ -10,6 +10,19 @@ import { TopcoderReportsGuard } from "./topcoder-reports.guard"; */ @RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) class TestTopcoderReportsController { + /** + * Represents the Engagement Data route for guard metadata tests. + * Returns no value because the handler body is not exercised in these unit tests. + */ + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ) + getEngagementData(): undefined { + return undefined; + } + /** * Represents the Recent Member Data route for guard metadata tests. * Returns no value because the handler body is not exercised in these unit tests. @@ -66,6 +79,16 @@ function createExecutionContext( describe("TopcoderReportsGuard", () => { const guard = new TopcoderReportsGuard(new Reflector()); + it("allows talent managers to access engagement data via route scopes", () => { + expect( + guard.canActivate( + createExecutionContext("getEngagementData", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + it("allows talent managers to access recent member data via route scopes", () => { expect( guard.canActivate( diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index e4017a8..7e32e40 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -35,6 +35,17 @@ describe("permissions.util", () => { ).toBe(true); }); + it("allows topcoder-prefixed talent manager roles for engagement data", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Member.EngagementData], + ), + ).toBe(true); + }); + it("normalizes comma-separated role claims before checking scoped access", () => { expect( hasRequiredRoleAccess("Topcoder Talent Manager, Topcoder User", [ diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 1ad5bc4..cd82828 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -42,6 +42,7 @@ describe("getAccessibleReportsDirectory", () => { "/identity/users-by-handles", ]); expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/engagement-data", "/member/recent-member-data", ]); }); @@ -84,6 +85,7 @@ describe("getAccessibleReportsDirectory", () => { directory.topcoder?.reports.map((report) => report.path), ).not.toContain("/topcoder/member-payment-accrual"); expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/engagement-data", "/member/recent-member-data", ]); expect(directory.admin?.reports.map((report) => report.path)).toEqual([ diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 0c1eaee..7a3fb9e 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -762,6 +762,16 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { label: "Member Reports", basePath: "/member", reports: [ + report( + "Engagement Data", + "/member/engagement-data", + "Members who have applied to public engagements or have any engagement assignments, including project names for assigned members", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ], + ), report( "Recent Member Data", "/member/recent-member-data", diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 4e9797a..dff2e30 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -113,6 +113,20 @@ export class TopcoderReportsController { return this.reports.getRecentMemberData(startDate); } + @Get("/member/engagement-data") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ) + @ApiOperation({ + summary: + "Members who applied to public engagements or have engagement assignments, including project names for assigned members", + }) + getEngagementData() { + return this.reports.getEngagementData(); + } + @Get("/topcoder/90-day-member-spend") @ApiOperation({ summary: "Total gross amount paid to members in the last 90 days", diff --git a/src/reports/topcoder/topcoder-reports.service.spec.ts b/src/reports/topcoder/topcoder-reports.service.spec.ts new file mode 100644 index 0000000..0d9d865 --- /dev/null +++ b/src/reports/topcoder/topcoder-reports.service.spec.ts @@ -0,0 +1,182 @@ +import { ConfigService } from "@nestjs/config"; +import { DbService } from "../../db/db.service"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { TopcoderReportsService } from "./topcoder-reports.service"; + +jest.mock("tc-core-library-js", () => ({ + auth: { + m2m: jest.fn(() => ({ + getMachineToken: jest.fn().mockResolvedValue("machine-token"), + })), + }, +})); + +describe("TopcoderReportsService", () => { + const originalFetch = global.fetch; + + let service: TopcoderReportsService; + let sql: { load: jest.Mock }; + let config: { get: jest.Mock }; + let engagementsPoolQuery: jest.Mock; + let fetchMock: jest.Mock; + + beforeEach(() => { + sql = { + load: jest.fn().mockReturnValue("SELECT * FROM engagement_data"), + }; + + config = { + get: jest.fn((key: string, defaultValue?: string) => { + switch (key) { + case "TOPCODER_API_URL_BASE": + return "https://api.topcoder.test"; + case "M2M_CLIENT_ID": + return "client-id"; + case "M2M_CLIENT_SECRET": + return "client-secret"; + case "AUTH0_URL": + return defaultValue ?? "https://auth.example.com/oauth/token"; + case "AUTH0_AUDIENCE": + return defaultValue ?? "https://api.topcoder.test"; + case "ENGAGEMENTS_DB_URL": + return "postgresql://localhost:5432/engagements"; + default: + return defaultValue; + } + }), + }; + + service = new TopcoderReportsService( + {} as DbService, + sql as unknown as SqlLoaderService, + config as unknown as ConfigService, + ); + + engagementsPoolQuery = jest.fn().mockResolvedValue({ + rows: [ + { + member_id: "101", + fallback_handle: "assigned_user", + application_email: "assigned-application@example.com", + application_address: "Assigned Application Address", + application_phone: "111-111-1111", + application_name: "Assigned User", + has_assignment: true, + assigned_project_ids: ["p2", "p1", "p1"], + }, + { + member_id: "202", + fallback_handle: "applicant_user", + application_email: "applicant@example.com", + application_address: "Applicant Address", + application_phone: "222-222-2222", + application_name: "Grace Hopper", + has_assignment: false, + assigned_project_ids: [], + }, + ], + }); + + Object.assign(service as object, { + engagementsPool: { + end: jest.fn(), + query: engagementsPoolQuery, + }, + }); + + fetchMock = jest.fn((input: string) => { + if (input.includes("/v6/members?")) { + return { + json: () => [ + { + userId: "101", + handle: "assigned_user", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + country: "United States", + addresses: [ + { + streetAddr1: "1 Main St", + city: "New York", + stateCode: "NY", + zip: "10001", + }, + ], + phones: [{ type: "mobile", number: "+1 555 0101" }], + }, + { + userId: "202", + handle: null, + firstName: null, + lastName: null, + email: null, + country: "Canada", + addresses: [], + phones: [], + }, + ], + ok: true, + }; + } + + if (input.includes("/v6/projects/p1")) { + return { + json: () => ({ id: "p1", name: "Project Alpha" }), + ok: true, + }; + } + + if (input.includes("/v6/projects/p2")) { + return { + json: () => ({ id: "p2", name: "Project Beta" }), + ok: true, + }; + } + + throw new Error(`Unexpected fetch call for ${input}`); + }); + + global.fetch = fetchMock as typeof global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it("builds engagement data rows with member enrichment, fallbacks, and project names", async () => { + await expect(service.getEngagementData()).resolves.toEqual([ + { + handle: "assigned_user", + firstName: "Ada", + lastName: "Lovelace", + country: "United States", + emailId: "ada@example.com", + phoneNumber: "+1 555 0101", + address: "1 Main St, New York, NY, 10001", + engagementExperience: "Assigned", + projectNames: "Project Alpha, Project Beta", + }, + { + handle: "applicant_user", + firstName: "Grace", + lastName: "Hopper", + country: "Canada", + emailId: "applicant@example.com", + phoneNumber: "222-222-2222", + address: "Applicant Address", + engagementExperience: "Applicant", + projectNames: "", + }, + ]); + + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data.sql", + ); + expect(engagementsPoolQuery).toHaveBeenCalledWith( + "SELECT * FROM engagement_data", + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 64f9ff0..62e79f5 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,7 +1,15 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { alpha3ToCountryName } from "../../common/country.util"; +import { Pool } from "pg"; +import * as core from "tc-core-library-js"; type RegistrantCountriesRow = { handle: string | null; @@ -115,12 +123,93 @@ type ChallengeSubmitterDataRow = { finalScore: string | number | null; }; +type EngagementDataBaseRow = { + member_id: string | null; + fallback_handle: string | null; + application_email: string | null; + application_address: string | null; + application_phone: string | null; + application_name: string | null; + has_assignment: boolean | null; + assigned_project_ids: string[] | null; +}; + +type MemberProfileAddress = { + streetAddr1?: string | null; + streetAddr2?: string | null; + city?: string | null; + stateCode?: string | null; + zip?: string | null; +}; + +type MemberProfilePhone = { + number?: string | null; + type?: string | null; +}; + +type MemberProfile = { + userId?: string | number | null; + handle?: string | null; + firstName?: string | null; + lastName?: string | null; + email?: string | null; + homeCountryCode?: string | null; + country?: string | null; + addresses?: MemberProfileAddress[] | null; + phones?: MemberProfilePhone[] | null; +}; + +type ProjectSummary = { + id?: string | number | null; + name?: string | null; +}; + +type ParsedName = { + firstName: string | null; + lastName: string | null; +}; + @Injectable() -export class TopcoderReportsService { +export class TopcoderReportsService implements OnModuleDestroy { + private readonly logger = new Logger(TopcoderReportsService.name); + private readonly m2m: { + getMachineToken: ( + clientId: string, + clientSecret: string, + ) => Promise; + }; + private engagementsPool?: Pool; + private readonly memberLookupBatchSize = 50; + private readonly projectLookupBatchSize = 10; + constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, - ) {} + private readonly config: ConfigService, + ) { + const authUrl = this.config.get( + "AUTH0_URL", + "https://topcoder-dev.auth0.com/oauth/token", + ); + const audience = this.config.get( + "AUTH0_AUDIENCE", + "https://api.topcoder-dev.com", + ); + + this.m2m = core.auth.m2m({ + AUTH0_AUDIENCE: audience, + AUTH0_URL: authUrl, + }) as { + getMachineToken: ( + clientId: string, + clientSecret: string, + ) => Promise; + }; + } + + async onModuleDestroy() { + await this.engagementsPool?.end(); + } async getMemberCount() { const query = this.sql.load("reports/topcoder/member-count.sql"); @@ -587,6 +676,86 @@ export class TopcoderReportsService { }); } + /** + * Returns the engagement data member report requested by PM-4800. + * + * The base member list comes from the engagements database, then member + * profiles and project names are resolved through existing platform APIs so + * the export includes handle, contact details, country, experience state, and + * comma-separated project names for assigned members. + * + * @returns One row per member with the engagement experience summary fields. + * @throws Error when required engagement or M2M configuration is missing. + */ + async getEngagementData() { + const rows = await this.queryEngagementDataRows(); + + if (!rows.length) { + return []; + } + + const memberIds = Array.from( + new Set( + rows + .map((row) => row.member_id?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ); + const projectIds = Array.from( + new Set( + rows.flatMap((row) => + (row.assigned_project_ids ?? []) + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ), + ); + + const token = await this.getM2MToken(); + const [profilesById, projectNamesById] = await Promise.all([ + this.fetchMemberProfilesByUserIds(memberIds, token), + this.fetchProjectNamesByIds(projectIds, token), + ]); + + return rows.map((row) => { + const memberId = row.member_id?.trim() ?? ""; + const member = profilesById.get(memberId); + const parsedName = this.parseName(row.application_name); + const handle = + this.toOptionalString(member?.handle) ?? row.fallback_handle; + const projectNames = Array.from( + new Set( + (row.assigned_project_ids ?? []) + .map((projectId) => + projectNamesById.get(projectId?.trim() ?? "")?.trim(), + ) + .filter((value): value is string => Boolean(value)), + ), + ).sort((left, right) => left.localeCompare(right)); + + return { + handle: handle ?? null, + firstName: + this.toOptionalString(member?.firstName) ?? parsedName.firstName, + lastName: + this.toOptionalString(member?.lastName) ?? parsedName.lastName, + country: this.resolveMemberCountry(member), + emailId: + this.toOptionalString(member?.email) ?? row.application_email ?? null, + phoneNumber: + this.getPreferredPhoneNumber(member?.phones) ?? + row.application_phone ?? + null, + address: + this.formatAddress(member?.addresses) ?? + row.application_address ?? + null, + engagementExperience: row.has_assignment ? "Assigned" : "Applicant", + projectNames: projectNames.join(", "), + }; + }); + } + async getRegistrantCountries(challengeId: string) { const query = this.sql.load("reports/topcoder/registrant-countries.sql"); const rows = await this.db.query(query, [ @@ -721,6 +890,366 @@ export class TopcoderReportsService { }; } + /** + * Loads the raw engagement report rows from the engagements database. + * + * @returns Aggregated rows keyed by member id with applicant fallbacks and + * assigned project ids. + * @throws Error when the engagements database URL is not configured. + */ + private async queryEngagementDataRows(): Promise { + const query = this.sql.load("reports/topcoder/engagement-data.sql"); + const result = + await this.getEngagementsPool().query(query); + + return result.rows; + } + + /** + * Returns a cached pg pool for the engagements database. + * + * @returns Shared pool targeting the engagements database. + * @throws Error when ENGAGEMENTS_DB_URL is missing. + */ + private getEngagementsPool(): Pool { + if (this.engagementsPool) { + return this.engagementsPool; + } + + const connectionString = this.config.get("ENGAGEMENTS_DB_URL", ""); + if (!connectionString) { + throw new Error( + "ENGAGEMENTS_DB_URL must be configured to generate the engagement data report.", + ); + } + + this.engagementsPool = new Pool({ connectionString }); + return this.engagementsPool; + } + + /** + * Fetches member profiles in batches so report rows can be enriched with + * profile fields like names, email, country, addresses, and phones. + * + * @param userIds Report member ids from the engagements database. + * @param token Auth token for the member API. + * @returns Map of user id to member profile payload. + */ + private async fetchMemberProfilesByUserIds( + userIds: string[], + token: string, + ): Promise> { + const normalizedUserIds = Array.from( + new Set( + userIds + .map((userId) => userId?.trim()) + .filter((userId): userId is string => Boolean(userId)), + ), + ); + + const profilesById = new Map(); + + for ( + let startIndex = 0; + startIndex < normalizedUserIds.length; + startIndex += this.memberLookupBatchSize + ) { + const batch = normalizedUserIds.slice( + startIndex, + startIndex + this.memberLookupBatchSize, + ); + const params = new URLSearchParams(); + const baseUrl = this.getMemberApiBaseUrl(); + + if (batch.length === 1) { + params.set("userId", batch[0]); + } else { + batch.forEach((userId) => params.append("userIds", userId)); + params.set("perPage", String(batch.length)); + } + + params.set( + "fields", + [ + "userId", + "handle", + "firstName", + "lastName", + "email", + "country", + "homeCountryCode", + "addresses", + "phones", + ].join(","), + ); + + const response = await this.fetchJson( + `${baseUrl}?${params.toString()}`, + token, + ); + const members = Array.isArray(response) ? response : []; + + members.forEach((member) => { + const userId = member?.userId; + if (userId === undefined || userId === null) { + return; + } + + profilesById.set(String(userId), member); + }); + } + + return profilesById; + } + + /** + * Resolves project names for the assigned project ids included in the report. + * + * Missing or inaccessible projects are skipped so the report can still render + * the rest of the member data. + * + * @param projectIds Project ids collected from engagement assignments. + * @param token Auth token for the projects API. + * @returns Map of project id to project name. + */ + private async fetchProjectNamesByIds( + projectIds: string[], + token: string, + ): Promise> { + const normalizedProjectIds = Array.from( + new Set( + projectIds + .map((projectId) => projectId?.trim()) + .filter((projectId): projectId is string => Boolean(projectId)), + ), + ); + + const projectNamesById = new Map(); + + for ( + let startIndex = 0; + startIndex < normalizedProjectIds.length; + startIndex += this.projectLookupBatchSize + ) { + const batch = normalizedProjectIds.slice( + startIndex, + startIndex + this.projectLookupBatchSize, + ); + + const responses = await Promise.all( + batch.map((projectId) => + this.fetchJson( + `${this.getProjectApiBaseUrl()}/${encodeURIComponent(projectId)}?fields=id,name`, + token, + ).catch((error: unknown) => { + const message = + error instanceof Error ? error.message : "Unknown error"; + this.logger.warn( + `Unable to resolve project name for projectId=${projectId}: ${message}`, + ); + return null; + }), + ), + ); + + responses.forEach((project) => { + const projectId = + project?.id === undefined || project?.id === null + ? null + : String(project.id).trim(); + const projectName = this.toOptionalString(project?.name); + + if (!projectId || !projectName) { + return; + } + + projectNamesById.set(projectId, projectName); + }); + } + + return projectNamesById; + } + + /** + * Retrieves an M2M token used for member and project API lookups. + * + * @returns Bearer token for platform API requests. + * @throws Error when the M2M credentials are not configured. + */ + private async getM2MToken(): Promise { + const clientId = this.config.get("M2M_CLIENT_ID"); + const clientSecret = this.config.get("M2M_CLIENT_SECRET"); + + if (!clientId || !clientSecret) { + throw new Error( + "M2M_CLIENT_ID and M2M_CLIENT_SECRET must be configured for the engagement data report.", + ); + } + + return this.m2m.getMachineToken(clientId, clientSecret); + } + + /** + * Executes an authenticated GET request and returns the parsed JSON body. + * + * @param url Absolute platform API URL. + * @param token Bearer token for the request. + * @returns Parsed JSON response body. + * @throws Error when the response is not successful. + */ + private async fetchJson(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error( + `Request failed (${response.status}) while fetching ${url}.`, + ); + } + + return (await response.json()) as T; + } + + /** + * Returns the base v6 members URL used for report enrichment. + * + * @returns Base URL for member API requests. + */ + private getMemberApiBaseUrl(): string { + const apiBaseUrl = this.config.get( + "TOPCODER_API_URL_BASE", + "https://api.topcoder-dev.com", + ); + + return `${apiBaseUrl.replace(/\/$/, "")}/v6/members`; + } + + /** + * Returns the base v6 projects URL used for project-name lookups. + * + * @returns Base URL for projects API requests. + */ + private getProjectApiBaseUrl(): string { + const apiBaseUrl = this.config.get( + "TOPCODER_API_URL_BASE", + "https://api.topcoder-dev.com", + ); + + return `${apiBaseUrl.replace(/\/$/, "")}/v6/projects`; + } + + /** + * Formats the preferred address from a member profile. + * + * @param addresses Member profile addresses array. + * @returns Comma-separated address string or null when unavailable. + */ + private formatAddress( + addresses?: MemberProfileAddress[] | null, + ): string | null { + const address = Array.isArray(addresses) ? addresses[0] : null; + if (!address) { + return null; + } + + const parts = [ + address.streetAddr1, + address.streetAddr2, + address.city, + address.stateCode, + address.zip, + ] + .map((value) => this.toOptionalString(value)) + .filter((value): value is string => Boolean(value)); + + return parts.length ? parts.join(", ") : null; + } + + /** + * Selects the preferred phone number from the member profile. + * + * Mobile numbers are preferred when present because that is how the + * engagements UI chooses a single display phone for application forms. + * + * @param phones Member profile phone list. + * @returns Preferred phone number or null when unavailable. + */ + private getPreferredPhoneNumber( + phones?: MemberProfilePhone[] | null, + ): string | null { + const normalizedPhones = Array.isArray(phones) + ? phones + .map((phone) => ({ + number: this.toOptionalString(phone?.number), + type: this.toOptionalString(phone?.type), + })) + .filter((phone): phone is { number: string; type: string | null } => + Boolean(phone.number), + ) + : []; + + if (!normalizedPhones.length) { + return null; + } + + const mobilePhone = normalizedPhones.find((phone) => + phone.type?.toLowerCase().includes("mobile"), + ); + + return mobilePhone?.number ?? normalizedPhones[0].number; + } + + /** + * Resolves the report country value from the member profile. + * + * @param member Member profile returned by member-api. + * @returns Country name or null when unavailable. + */ + private resolveMemberCountry(member?: MemberProfile): string | null { + const countryFromCode = alpha3ToCountryName( + this.toOptionalString(member?.homeCountryCode), + ); + + return countryFromCode ?? this.toOptionalString(member?.country); + } + + /** + * Splits a full name into first and last name fallbacks. + * + * @param value Full-name string captured on the engagement application. + * @returns Parsed first and last name values. + */ + private parseName(value?: string | null): ParsedName { + const normalizedValue = this.toOptionalString(value); + if (!normalizedValue) { + return { firstName: null, lastName: null }; + } + + const parts = normalizedValue.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + return { firstName: parts[0], lastName: null }; + } + + return { + firstName: parts[0] ?? null, + lastName: parts.slice(1).join(" ") || null, + }; + } + + /** + * Normalizes optional string values by trimming whitespace and dropping blanks. + * + * @param value Candidate string value. + * @returns Trimmed string or null when empty. + */ + private toOptionalString(value?: string | null): string | null { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : null; + } + private toNullableNumberArray(value: unknown): number[] | null { if (value === null || value === undefined) { return null; From 8ca58fafad15fafe15ce138ddee8a1306657da9c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 9 Apr 2026 12:32:58 +1000 Subject: [PATCH 14/15] Updates for engagements data report --- README.md | 10 +- .../topcoder/engagement-data-members.sql | 56 +++ .../topcoder/engagement-data-projects.sql | 7 + .../topcoder/topcoder-reports.service.spec.ts | 167 ++++---- .../topcoder/topcoder-reports.service.ts | 381 +++++------------- 5 files changed, 237 insertions(+), 384 deletions(-) create mode 100644 sql/reports/topcoder/engagement-data-members.sql create mode 100644 sql/reports/topcoder/engagement-data-projects.sql diff --git a/README.md b/README.md index 1d5b70b..9f2673e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ DATABASE_URL="postgresql://user:password@localhost:5432/lookups?schema=public" # Engagements database used by the Engagement Data member report. ENGAGEMENTS_DB_URL="postgresql://user:password@localhost:5432/engagements" +# The same report also reads member/profile/project data from the main +# DATABASE_URL connection, including members.member, members.memberAddress, +# members.memberPhone, identity.country, and projects.projects. + # Old tc-payments database URL (used by member-tax CSV export script) OLD_PAYMENTS_DATABASE_URL="postgresql://user:password@localhost:5432/tc_payments?schema=public" @@ -73,12 +77,6 @@ AUTH_SECRET="mysecret" # A JSON array string of valid token issuers. VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]' -# Platform API base URL plus M2M credentials used when reports need member or -# project enrichment outside the main reports database. -TOPCODER_API_URL_BASE="https://api.topcoder-dev.com" -M2M_CLIENT_ID="your-m2m-client-id" -M2M_CLIENT_SECRET="your-m2m-client-secret" - ## Running the Application ### Development Mode diff --git a/sql/reports/topcoder/engagement-data-members.sql b/sql/reports/topcoder/engagement-data-members.sql new file mode 100644 index 0000000..84c5eaa --- /dev/null +++ b/sql/reports/topcoder/engagement-data-members.sql @@ -0,0 +1,56 @@ +SELECT + m."userId"::text AS user_id, + NULLIF(BTRIM(m.handle), '') AS handle, + NULLIF(BTRIM(m."firstName"), '') AS first_name, + NULLIF(BTRIM(m."lastName"), '') AS last_name, + NULLIF(BTRIM(m.email), '') AS email, + COALESCE(NULLIF(BTRIM(c.country_name), ''), NULLIF(BTRIM(m.country), '')) + AS country, + preferred_address.street_addr_1, + preferred_address.street_addr_2, + preferred_address.city, + preferred_address.state_code, + preferred_address.zip, + preferred_phone.phone_number +FROM members.member m +LEFT JOIN identity.country c + ON c.country_code = m."homeCountryCode" +LEFT JOIN LATERAL ( + SELECT + NULLIF(BTRIM(a."streetAddr1"), '') AS street_addr_1, + NULLIF(BTRIM(a."streetAddr2"), '') AS street_addr_2, + NULLIF(BTRIM(a.city), '') AS city, + NULLIF(BTRIM(a."stateCode"), '') AS state_code, + NULLIF(BTRIM(a.zip), '') AS zip + FROM members."memberAddress" a + WHERE a."userId" = m."userId" + ORDER BY + CASE + WHEN UPPER(a.type) = 'HOME' THEN 0 + WHEN UPPER(a.type) = 'BILLING' THEN 1 + ELSE 2 + END, + a."updatedAt" DESC NULLS LAST, + a."createdAt" DESC NULLS LAST, + a.id DESC + LIMIT 1 +) preferred_address + ON TRUE +LEFT JOIN LATERAL ( + SELECT NULLIF(BTRIM(p.number), '') AS phone_number + FROM members."memberPhone" p + WHERE p."userId" = m."userId" + AND NULLIF(BTRIM(p.number), '') IS NOT NULL + ORDER BY + CASE + WHEN POSITION('mobile' IN LOWER(p.type)) > 0 THEN 0 + ELSE 1 + END, + p."updatedAt" DESC NULLS LAST, + p."createdAt" DESC NULLS LAST, + p.id ASC + LIMIT 1 +) preferred_phone + ON TRUE +WHERE m."userId"::text = ANY($1::text[]) +ORDER BY LOWER(m.handle), m."userId"; diff --git a/sql/reports/topcoder/engagement-data-projects.sql b/sql/reports/topcoder/engagement-data-projects.sql new file mode 100644 index 0000000..76ee553 --- /dev/null +++ b/sql/reports/topcoder/engagement-data-projects.sql @@ -0,0 +1,7 @@ +SELECT + p.id::text AS project_id, + NULLIF(BTRIM(p.name), '') AS project_name +FROM projects.projects p +WHERE p.id::text = ANY($1::text[]) + AND p."deletedAt" IS NULL +ORDER BY LOWER(p.name), p.id; diff --git a/src/reports/topcoder/topcoder-reports.service.spec.ts b/src/reports/topcoder/topcoder-reports.service.spec.ts index 0d9d865..b5a871c 100644 --- a/src/reports/topcoder/topcoder-reports.service.spec.ts +++ b/src/reports/topcoder/topcoder-reports.service.spec.ts @@ -3,51 +3,76 @@ import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { TopcoderReportsService } from "./topcoder-reports.service"; -jest.mock("tc-core-library-js", () => ({ - auth: { - m2m: jest.fn(() => ({ - getMachineToken: jest.fn().mockResolvedValue("machine-token"), - })), - }, -})); - describe("TopcoderReportsService", () => { - const originalFetch = global.fetch; - let service: TopcoderReportsService; + let db: { query: jest.Mock }; let sql: { load: jest.Mock }; let config: { get: jest.Mock }; let engagementsPoolQuery: jest.Mock; - let fetchMock: jest.Mock; beforeEach(() => { + db = { + query: jest.fn((query: string) => { + if (query === "reports/topcoder/engagement-data-members.sql") { + return [ + { + user_id: "101", + handle: "assigned_user", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + country: "United States", + street_addr_1: "1 Main St", + street_addr_2: null, + city: "New York", + state_code: "NY", + zip: "10001", + phone_number: "+1 555 0101", + }, + { + user_id: "202", + handle: null, + first_name: null, + last_name: null, + email: null, + country: "Canada", + street_addr_1: null, + street_addr_2: null, + city: null, + state_code: null, + zip: null, + phone_number: null, + }, + ]; + } + + if (query === "reports/topcoder/engagement-data-projects.sql") { + return [ + { project_id: "p1", project_name: "Project Alpha" }, + { project_id: "p2", project_name: "Project Beta" }, + ]; + } + + throw new Error(`Unexpected query: ${query}`); + }), + }; + sql = { - load: jest.fn().mockReturnValue("SELECT * FROM engagement_data"), + load: jest.fn((query: string) => query), }; config = { get: jest.fn((key: string, defaultValue?: string) => { - switch (key) { - case "TOPCODER_API_URL_BASE": - return "https://api.topcoder.test"; - case "M2M_CLIENT_ID": - return "client-id"; - case "M2M_CLIENT_SECRET": - return "client-secret"; - case "AUTH0_URL": - return defaultValue ?? "https://auth.example.com/oauth/token"; - case "AUTH0_AUDIENCE": - return defaultValue ?? "https://api.topcoder.test"; - case "ENGAGEMENTS_DB_URL": - return "postgresql://localhost:5432/engagements"; - default: - return defaultValue; + if (key === "ENGAGEMENTS_DB_URL") { + return "postgresql://localhost:5432/engagements"; } + + return defaultValue; }), }; service = new TopcoderReportsService( - {} as DbService, + db as unknown as DbService, sql as unknown as SqlLoaderService, config as unknown as ConfigService, ); @@ -83,69 +108,13 @@ describe("TopcoderReportsService", () => { query: engagementsPoolQuery, }, }); - - fetchMock = jest.fn((input: string) => { - if (input.includes("/v6/members?")) { - return { - json: () => [ - { - userId: "101", - handle: "assigned_user", - firstName: "Ada", - lastName: "Lovelace", - email: "ada@example.com", - country: "United States", - addresses: [ - { - streetAddr1: "1 Main St", - city: "New York", - stateCode: "NY", - zip: "10001", - }, - ], - phones: [{ type: "mobile", number: "+1 555 0101" }], - }, - { - userId: "202", - handle: null, - firstName: null, - lastName: null, - email: null, - country: "Canada", - addresses: [], - phones: [], - }, - ], - ok: true, - }; - } - - if (input.includes("/v6/projects/p1")) { - return { - json: () => ({ id: "p1", name: "Project Alpha" }), - ok: true, - }; - } - - if (input.includes("/v6/projects/p2")) { - return { - json: () => ({ id: "p2", name: "Project Beta" }), - ok: true, - }; - } - - throw new Error(`Unexpected fetch call for ${input}`); - }); - - global.fetch = fetchMock as typeof global.fetch; }); afterEach(() => { - global.fetch = originalFetch; jest.clearAllMocks(); }); - it("builds engagement data rows with member enrichment, fallbacks, and project names", async () => { + it("builds engagement data rows with DB-backed enrichment, fallbacks, and project names", async () => { await expect(service.getEngagementData()).resolves.toEqual([ { handle: "assigned_user", @@ -174,9 +143,33 @@ describe("TopcoderReportsService", () => { expect(sql.load).toHaveBeenCalledWith( "reports/topcoder/engagement-data.sql", ); + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data-members.sql", + ); + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data-projects.sql", + ); expect(engagementsPoolQuery).toHaveBeenCalledWith( - "SELECT * FROM engagement_data", + "reports/topcoder/engagement-data.sql", + ); + expect(db.query).toHaveBeenCalledTimes(2); + expect(db.query).toHaveBeenNthCalledWith( + 1, + "reports/topcoder/engagement-data-members.sql", + [["101", "202"]], ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(db.query).toHaveBeenNthCalledWith( + 2, + "reports/topcoder/engagement-data-projects.sql", + [["p2", "p1"]], + ); + }); + + it("returns an empty list without running enrichment queries when no members qualify", async () => { + engagementsPoolQuery.mockResolvedValueOnce({ rows: [] }); + + await expect(service.getEngagementData()).resolves.toEqual([]); + + expect(db.query).not.toHaveBeenCalled(); }); }); diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 62e79f5..81beb45 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,15 +1,9 @@ -import { - Injectable, - Logger, - NotFoundException, - OnModuleDestroy, -} from "@nestjs/common"; +import { Injectable, NotFoundException, OnModuleDestroy } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { alpha3ToCountryName } from "../../common/country.util"; import { Pool } from "pg"; -import * as core from "tc-core-library-js"; type RegistrantCountriesRow = { handle: string | null; @@ -134,7 +128,22 @@ type EngagementDataBaseRow = { assigned_project_ids: string[] | null; }; -type MemberProfileAddress = { +type EngagementMemberRow = { + user_id: string | number | null; + handle: string | null; + first_name: string | null; + last_name: string | null; + email: string | null; + country: string | null; + street_addr_1: string | null; + street_addr_2: string | null; + city: string | null; + state_code: string | null; + zip: string | null; + phone_number: string | null; +}; + +type FormattableAddress = { streetAddr1?: string | null; streetAddr2?: string | null; city?: string | null; @@ -142,26 +151,9 @@ type MemberProfileAddress = { zip?: string | null; }; -type MemberProfilePhone = { - number?: string | null; - type?: string | null; -}; - -type MemberProfile = { - userId?: string | number | null; - handle?: string | null; - firstName?: string | null; - lastName?: string | null; - email?: string | null; - homeCountryCode?: string | null; - country?: string | null; - addresses?: MemberProfileAddress[] | null; - phones?: MemberProfilePhone[] | null; -}; - -type ProjectSummary = { - id?: string | number | null; - name?: string | null; +type EngagementProjectRow = { + project_id: string | number | null; + project_name: string | null; }; type ParsedName = { @@ -171,41 +163,13 @@ type ParsedName = { @Injectable() export class TopcoderReportsService implements OnModuleDestroy { - private readonly logger = new Logger(TopcoderReportsService.name); - private readonly m2m: { - getMachineToken: ( - clientId: string, - clientSecret: string, - ) => Promise; - }; private engagementsPool?: Pool; - private readonly memberLookupBatchSize = 50; - private readonly projectLookupBatchSize = 10; constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, private readonly config: ConfigService, - ) { - const authUrl = this.config.get( - "AUTH0_URL", - "https://topcoder-dev.auth0.com/oauth/token", - ); - const audience = this.config.get( - "AUTH0_AUDIENCE", - "https://api.topcoder-dev.com", - ); - - this.m2m = core.auth.m2m({ - AUTH0_AUDIENCE: audience, - AUTH0_URL: authUrl, - }) as { - getMachineToken: ( - clientId: string, - clientSecret: string, - ) => Promise; - }; - } + ) {} async onModuleDestroy() { await this.engagementsPool?.end(); @@ -679,13 +643,12 @@ export class TopcoderReportsService implements OnModuleDestroy { /** * Returns the engagement data member report requested by PM-4800. * - * The base member list comes from the engagements database, then member - * profiles and project names are resolved through existing platform APIs so - * the export includes handle, contact details, country, experience state, and - * comma-separated project names for assigned members. + * The base member list comes from the engagements database, while member + * profile/contact fields and project names are resolved directly from the + * main reports database so the export stays DB-only. * * @returns One row per member with the engagement experience summary fields. - * @throws Error when required engagement or M2M configuration is missing. + * @throws Error when the engagements database URL is not configured. */ async getEngagementData() { const rows = await this.queryEngagementDataRows(); @@ -711,15 +674,14 @@ export class TopcoderReportsService implements OnModuleDestroy { ), ); - const token = await this.getM2MToken(); - const [profilesById, projectNamesById] = await Promise.all([ - this.fetchMemberProfilesByUserIds(memberIds, token), - this.fetchProjectNamesByIds(projectIds, token), + const [membersById, projectNamesById] = await Promise.all([ + this.fetchEngagementMembersByUserIds(memberIds), + this.fetchProjectNamesByIds(projectIds), ]); return rows.map((row) => { const memberId = row.member_id?.trim() ?? ""; - const member = profilesById.get(memberId); + const member = membersById.get(memberId); const parsedName = this.parseName(row.application_name); const handle = this.toOptionalString(member?.handle) ?? row.fallback_handle; @@ -736,18 +698,28 @@ export class TopcoderReportsService implements OnModuleDestroy { return { handle: handle ?? null, firstName: - this.toOptionalString(member?.firstName) ?? parsedName.firstName, + this.toOptionalString(member?.first_name) ?? parsedName.firstName, lastName: - this.toOptionalString(member?.lastName) ?? parsedName.lastName, - country: this.resolveMemberCountry(member), + this.toOptionalString(member?.last_name) ?? parsedName.lastName, + country: this.toOptionalString(member?.country), emailId: this.toOptionalString(member?.email) ?? row.application_email ?? null, phoneNumber: - this.getPreferredPhoneNumber(member?.phones) ?? + this.toOptionalString(member?.phone_number) ?? row.application_phone ?? null, address: - this.formatAddress(member?.addresses) ?? + this.formatAddress( + member + ? { + streetAddr1: member.street_addr_1, + streetAddr2: member.street_addr_2, + city: member.city, + stateCode: member.state_code, + zip: member.zip, + } + : null, + ) ?? row.application_address ?? null, engagementExperience: row.has_assignment ? "Assigned" : "Applicant", @@ -928,17 +900,15 @@ export class TopcoderReportsService implements OnModuleDestroy { } /** - * Fetches member profiles in batches so report rows can be enriched with - * profile fields like names, email, country, addresses, and phones. + * Fetches member profile rows from the main reports database so the report + * can be enriched without member-api calls. * * @param userIds Report member ids from the engagements database. - * @param token Auth token for the member API. - * @returns Map of user id to member profile payload. + * @returns Map of user id to member profile/contact row. */ - private async fetchMemberProfilesByUserIds( + private async fetchEngagementMembersByUserIds( userIds: string[], - token: string, - ): Promise> { + ): Promise> { const normalizedUserIds = Array.from( new Set( userIds @@ -947,74 +917,41 @@ export class TopcoderReportsService implements OnModuleDestroy { ), ); - const profilesById = new Map(); + const membersById = new Map(); - for ( - let startIndex = 0; - startIndex < normalizedUserIds.length; - startIndex += this.memberLookupBatchSize - ) { - const batch = normalizedUserIds.slice( - startIndex, - startIndex + this.memberLookupBatchSize, - ); - const params = new URLSearchParams(); - const baseUrl = this.getMemberApiBaseUrl(); - - if (batch.length === 1) { - params.set("userId", batch[0]); - } else { - batch.forEach((userId) => params.append("userIds", userId)); - params.set("perPage", String(batch.length)); - } + if (!normalizedUserIds.length) { + return membersById; + } - params.set( - "fields", - [ - "userId", - "handle", - "firstName", - "lastName", - "email", - "country", - "homeCountryCode", - "addresses", - "phones", - ].join(","), - ); + const query = this.sql.load("reports/topcoder/engagement-data-members.sql"); + const rows = await this.db.query(query, [ + normalizedUserIds, + ]); - const response = await this.fetchJson( - `${baseUrl}?${params.toString()}`, - token, - ); - const members = Array.isArray(response) ? response : []; + rows.forEach((member) => { + const userId = + member?.user_id === undefined || member.user_id === null + ? null + : String(member.user_id).trim(); - members.forEach((member) => { - const userId = member?.userId; - if (userId === undefined || userId === null) { - return; - } + if (!userId) { + return; + } - profilesById.set(String(userId), member); - }); - } + membersById.set(userId, member); + }); - return profilesById; + return membersById; } /** * Resolves project names for the assigned project ids included in the report. * - * Missing or inaccessible projects are skipped so the report can still render - * the rest of the member data. - * * @param projectIds Project ids collected from engagement assignments. - * @param token Auth token for the projects API. * @returns Map of project id to project name. */ private async fetchProjectNamesByIds( projectIds: string[], - token: string, ): Promise> { const normalizedProjectIds = Array.from( new Set( @@ -1026,131 +963,41 @@ export class TopcoderReportsService implements OnModuleDestroy { const projectNamesById = new Map(); - for ( - let startIndex = 0; - startIndex < normalizedProjectIds.length; - startIndex += this.projectLookupBatchSize - ) { - const batch = normalizedProjectIds.slice( - startIndex, - startIndex + this.projectLookupBatchSize, - ); - - const responses = await Promise.all( - batch.map((projectId) => - this.fetchJson( - `${this.getProjectApiBaseUrl()}/${encodeURIComponent(projectId)}?fields=id,name`, - token, - ).catch((error: unknown) => { - const message = - error instanceof Error ? error.message : "Unknown error"; - this.logger.warn( - `Unable to resolve project name for projectId=${projectId}: ${message}`, - ); - return null; - }), - ), - ); - - responses.forEach((project) => { - const projectId = - project?.id === undefined || project?.id === null - ? null - : String(project.id).trim(); - const projectName = this.toOptionalString(project?.name); - - if (!projectId || !projectName) { - return; - } - - projectNamesById.set(projectId, projectName); - }); + if (!normalizedProjectIds.length) { + return projectNamesById; } - return projectNamesById; - } - - /** - * Retrieves an M2M token used for member and project API lookups. - * - * @returns Bearer token for platform API requests. - * @throws Error when the M2M credentials are not configured. - */ - private async getM2MToken(): Promise { - const clientId = this.config.get("M2M_CLIENT_ID"); - const clientSecret = this.config.get("M2M_CLIENT_SECRET"); + const query = this.sql.load( + "reports/topcoder/engagement-data-projects.sql", + ); + const rows = await this.db.query(query, [ + normalizedProjectIds, + ]); - if (!clientId || !clientSecret) { - throw new Error( - "M2M_CLIENT_ID and M2M_CLIENT_SECRET must be configured for the engagement data report.", - ); - } + rows.forEach((project) => { + const projectId = + project?.project_id === undefined || project.project_id === null + ? null + : String(project.project_id).trim(); + const projectName = this.toOptionalString(project?.project_name); - return this.m2m.getMachineToken(clientId, clientSecret); - } + if (!projectId || !projectName) { + return; + } - /** - * Executes an authenticated GET request and returns the parsed JSON body. - * - * @param url Absolute platform API URL. - * @param token Bearer token for the request. - * @returns Parsed JSON response body. - * @throws Error when the response is not successful. - */ - private async fetchJson(url: string, token: string): Promise { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - }, + projectNamesById.set(projectId, projectName); }); - if (!response.ok) { - throw new Error( - `Request failed (${response.status}) while fetching ${url}.`, - ); - } - - return (await response.json()) as T; - } - - /** - * Returns the base v6 members URL used for report enrichment. - * - * @returns Base URL for member API requests. - */ - private getMemberApiBaseUrl(): string { - const apiBaseUrl = this.config.get( - "TOPCODER_API_URL_BASE", - "https://api.topcoder-dev.com", - ); - - return `${apiBaseUrl.replace(/\/$/, "")}/v6/members`; - } - - /** - * Returns the base v6 projects URL used for project-name lookups. - * - * @returns Base URL for projects API requests. - */ - private getProjectApiBaseUrl(): string { - const apiBaseUrl = this.config.get( - "TOPCODER_API_URL_BASE", - "https://api.topcoder-dev.com", - ); - - return `${apiBaseUrl.replace(/\/$/, "")}/v6/projects`; + return projectNamesById; } /** - * Formats the preferred address from a member profile. + * Formats a preferred address row into the report output string. * - * @param addresses Member profile addresses array. + * @param address Address row selected for a member. * @returns Comma-separated address string or null when unavailable. */ - private formatAddress( - addresses?: MemberProfileAddress[] | null, - ): string | null { - const address = Array.isArray(addresses) ? addresses[0] : null; + private formatAddress(address?: FormattableAddress | null): string | null { if (!address) { return null; } @@ -1168,54 +1015,6 @@ export class TopcoderReportsService implements OnModuleDestroy { return parts.length ? parts.join(", ") : null; } - /** - * Selects the preferred phone number from the member profile. - * - * Mobile numbers are preferred when present because that is how the - * engagements UI chooses a single display phone for application forms. - * - * @param phones Member profile phone list. - * @returns Preferred phone number or null when unavailable. - */ - private getPreferredPhoneNumber( - phones?: MemberProfilePhone[] | null, - ): string | null { - const normalizedPhones = Array.isArray(phones) - ? phones - .map((phone) => ({ - number: this.toOptionalString(phone?.number), - type: this.toOptionalString(phone?.type), - })) - .filter((phone): phone is { number: string; type: string | null } => - Boolean(phone.number), - ) - : []; - - if (!normalizedPhones.length) { - return null; - } - - const mobilePhone = normalizedPhones.find((phone) => - phone.type?.toLowerCase().includes("mobile"), - ); - - return mobilePhone?.number ?? normalizedPhones[0].number; - } - - /** - * Resolves the report country value from the member profile. - * - * @param member Member profile returned by member-api. - * @returns Country name or null when unavailable. - */ - private resolveMemberCountry(member?: MemberProfile): string | null { - const countryFromCode = alpha3ToCountryName( - this.toOptionalString(member?.homeCountryCode), - ); - - return countryFromCode ?? this.toOptionalString(member?.country); - } - /** * Splits a full name into first and last name fallbacks. * From 1576ce0b9c704ad3241986f98ca8bb8682787bcf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 9 Apr 2026 16:04:40 +1000 Subject: [PATCH 15/15] Reports schema fix --- sql/reports/topcoder/engagement-data.sql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sql/reports/topcoder/engagement-data.sql b/sql/reports/topcoder/engagement-data.sql index c9fead1..85f4df0 100644 --- a/sql/reports/topcoder/engagement-data.sql +++ b/sql/reports/topcoder/engagement-data.sql @@ -3,14 +3,14 @@ WITH assignment_members AS ( NULLIF(BTRIM(a."memberId"), '') AS member_id, NULLIF(BTRIM(a."memberHandle"), '') AS fallback_handle, NULLIF(BTRIM(e."projectId"), '') AS project_id - FROM "EngagementAssignment" a - JOIN "Engagement" e + FROM engagements."EngagementAssignment" a + JOIN engagements."Engagement" e ON e.id = a."engagementId" WHERE NULLIF(BTRIM(a."memberId"), '') IS NOT NULL ), assigned_summary AS ( SELECT - member_id, + member_id, MAX(fallback_handle) AS fallback_handle, ARRAY_REMOVE(ARRAY_AGG(DISTINCT project_id), NULL) AS assigned_project_ids FROM assignment_members @@ -28,8 +28,8 @@ ranked_public_applications AS ( PARTITION BY app."userId" ORDER BY app."updatedAt" DESC, app."createdAt" DESC, app.id DESC ) AS rn - FROM "EngagementApplication" app - JOIN "Engagement" e + FROM engagements."EngagementApplication" app + JOIN engagements."Engagement" e ON e.id = app."engagementId" WHERE COALESCE(e."isPrivate", false) = false AND NULLIF(BTRIM(app."userId"), '') IS NOT NULL