From 7b4b692878a16ff2af5816bab54cb0c063f7e1c2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 27 Feb 2026 08:00:41 +1100 Subject: [PATCH 01/42] Fix up challenge type selection and add that as a column --- .../challenges/registrants-history.sql | 32 +++++++++++++++---- .../challenges/dtos/registrants.dto.ts | 1 + 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/sql/reports/challenges/registrants-history.sql b/sql/reports/challenges/registrants-history.sql index d1ec062..3ea663c 100644 --- a/sql/reports/challenges/registrants-history.sql +++ b/sql/reports/challenges/registrants-history.sql @@ -8,8 +8,11 @@ filtered_challenges AS MATERIALIZED ( SELECT c.id, c.status, + ct.name AS "challengeType", lp."actualEndDate" AS "challengeCompletedDate" FROM challenges."Challenge" c + JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" LEFT JOIN LATERAL ( SELECT cp."actualEndDate" FROM challenges."ChallengePhase" cp @@ -40,8 +43,8 @@ filtered_challenges AS MATERIALIZED ( ) -- filter by challenge status AND ($3::text[] IS NULL OR c.status::text = ANY($3::text[])) - -- exclude task challenge types from this report - AND COALESCE(c."taskIsTask", false) = false + -- include only challenge types supported by this report + AND ct.name IN ('Challenge', 'Marathon Match', 'First2Finish') -- filter by completion date bounds on the latest challenge phase end date AND ( ($4::timestamptz IS NULL AND $5::timestamptz IS NULL) @@ -57,6 +60,7 @@ registrants AS MATERIALIZED ( SELECT fc.id AS "challengeId", fc.status AS "challengeStatus", + fc."challengeType", fc."challengeCompletedDate", registrant."memberId", registrant."registrantHandle" @@ -76,18 +80,26 @@ registrants AS MATERIALIZED ( SELECT r."challengeId", r."challengeStatus", + r."challengeType", win."winnerHandle", - COALESCE(sub."isWinner", false) AS "isWinner", + ( + COALESCE(win."isWinner", false) + OR COALESCE(sub."isWinner", false) + OR COALESCE(cr."isWinner", false) + ) AS "isWinner", CASE WHEN r."challengeStatus" = 'COMPLETED' THEN r."challengeCompletedDate" ELSE null END AS "challengeCompletedDate", r."registrantHandle", - sub."registrantFinalScore" + COALESCE(sub."registrantFinalScore", cr."registrantFinalScore") + AS "registrantFinalScore" FROM registrants r LEFT JOIN LATERAL ( - SELECT MAX(cw.handle) AS "winnerHandle" + SELECT + MAX(cw.handle) AS "winnerHandle", + COUNT(*) > 0 AS "isWinner" FROM challenges."ChallengeWinner" cw WHERE cw."challengeId" = r."challengeId" AND cw."userId"::text = r."memberId" @@ -100,4 +112,12 @@ LEFT JOIN LATERAL ( FROM reviews.submission s WHERE s."challengeId" = r."challengeId" AND s."memberId" = r."memberId" -) sub ON true; +) sub ON true +LEFT JOIN LATERAL ( + SELECT + BOOL_OR(cr.placement = 1) AS "isWinner", + ROUND(MAX(cr."finalScore")::numeric, 2) AS "registrantFinalScore" + FROM reviews."challengeResult" cr + WHERE cr."challengeId" = r."challengeId" + AND cr."userId" = r."memberId" +) cr ON true; diff --git a/src/reports/challenges/dtos/registrants.dto.ts b/src/reports/challenges/dtos/registrants.dto.ts index 5abfb9c..09097d6 100644 --- a/src/reports/challenges/dtos/registrants.dto.ts +++ b/src/reports/challenges/dtos/registrants.dto.ts @@ -59,4 +59,5 @@ export interface ChallengeRegistrantsResponseDto { challengeCompletedDate: string | null; registrantFinalScore?: number; challengeStatus: string; + challengeType: string; } From 83609e3cdef6f7fbee1f468b876881edf6ba64f6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 3 Mar 2026 21:33:35 +0200 Subject: [PATCH 02/42] PM-4158 completed profiles report --- sql/reports/topcoder/completed-profiles.sql | 66 +++++++++++++++++++ .../topcoder/dto/completed-profiles.dto.ts | 12 ++++ .../topcoder/topcoder-reports.controller.ts | 10 +++ .../topcoder/topcoder-reports.service.ts | 21 ++++++ 4 files changed, 109 insertions(+) create mode 100644 sql/reports/topcoder/completed-profiles.sql create mode 100644 src/reports/topcoder/dto/completed-profiles.dto.ts diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql new file mode 100644 index 0000000..a3b3c26 --- /dev/null +++ b/sql/reports/topcoder/completed-profiles.sql @@ -0,0 +1,66 @@ +-- Query to return members with completed profiles +-- A profile is considered complete if it has: +-- - description (bio) +-- - profile photo +-- - at least 3 skills +-- - gig availability set (true or false) +-- - at least one work history entry +-- - at least one education entry +-- - at least one location (address) + +WITH member_skills AS ( + SELECT + us.user_id, + COUNT(*) AS skill_count + FROM skills.user_skill us + GROUP BY us.user_id +), +member_work_history AS ( + SELECT DISTINCT + mt."userId" + FROM members."memberTraits" mt + JOIN members."memberTraitWork" mw + ON mw."memberTraitId" = mt.id +), +member_education AS ( + SELECT DISTINCT + mt."userId" + FROM members."memberTraits" mt + JOIN members."memberTraitEducation" me + ON me."memberTraitId" = mt.id +), +member_location AS ( + SELECT DISTINCT + ma."userId" + FROM members."memberAddress" ma +), +completed_profiles AS ( + SELECT + m."userId", + m.handle, + COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", + m.country AS "countryName", + m.email + FROM members.member m + LEFT JOIN member_skills ms ON ms.user_id = m."userId" + LEFT JOIN member_work_history mwh ON mwh."userId" = m."userId" + LEFT JOIN member_education me ON me."userId" = m."userId" + LEFT JOIN member_location ml ON ml."userId" = m."userId" + WHERE m.description IS NOT NULL + AND m.description <> '' + AND m."photoURL" IS NOT NULL + AND m."photoURL" <> '' + AND m."availableForGigs" IS NOT NULL + AND COALESCE(ms.skill_count, 0) >= 3 + AND mwh."userId" IS NOT NULL + AND me."userId" IS NOT NULL + AND ml."userId" IS NOT NULL + AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) +) +SELECT + cp."userId" AS "userId", + cp.handle, + cp."countryCode", + cp."countryName" +FROM completed_profiles cp +ORDER BY cp.handle; diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts new file mode 100644 index 0000000..e040935 --- /dev/null +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; + +export class CompletedProfilesQueryDto { + @ApiPropertyOptional({ + description: "Filter by country code (ISO 3166-1 alpha-2)", + example: "US", + }) + @IsOptional() + @IsString() + countryCode?: string; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 63aec82..02e24a8 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -12,6 +12,7 @@ import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { MemberPaymentAccrualQueryDto } from "./dto/member-payment-accrual.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; +import { CompletedProfilesQueryDto } from "./dto/completed-profiles.dto"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; @@ -240,4 +241,13 @@ export class TopcoderReportsController { getMembershipParticipationFunnelData() { return this.reports.getMembershipParticipationFunnelData(); } + + @Get("/completed-profiles") + @ApiOperation({ + summary: "List of members with 100% completed profiles", + }) + getCompletedProfiles(@Query() query: CompletedProfilesQueryDto) { + const { countryCode } = query; + return this.reports.getCompletedProfiles(countryCode); + } } diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index a298a3a..8c9dbcc 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -86,6 +86,13 @@ type RecentMemberDataRow = { submissions_over_75: string | number | null; }; +type CompletedProfileRow = { + userId: string | number | null; + handle: string | null; + countryCode: string | null; + countryName: string | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -607,6 +614,20 @@ export class TopcoderReportsService { }; } + async getCompletedProfiles(countryCode?: string) { + const query = this.sql.load("reports/topcoder/completed-profiles.sql"); + const rows = await this.db.query(query, [ + countryCode || null, + ]); + + return rows.map((row) => ({ + userId: row.userId ? Number(row.userId) : null, + handle: row.handle || "", + countryCode: row.countryCode || undefined, + countryName: row.countryName || undefined, + })); + } + private toNullableNumber(value: string | number | null | undefined) { if (value === null || value === undefined) { return null; From 67286d68e3e3291e1a14c9dd27b18e68da210f14 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 09:20:01 +1100 Subject: [PATCH 03/42] New challenge submitter report (PM-4151) --- .../topcoder/challenge-submitter-data.sql | 155 ++++++++++++++++++ src/reports/report-directory.data.ts | 14 ++ .../dto/challenge-submitter-data.dto.ts | 9 + .../topcoder/topcoder-reports.controller.ts | 11 ++ .../topcoder/topcoder-reports.service.ts | 57 +++++++ 5 files changed, 246 insertions(+) create mode 100644 sql/reports/topcoder/challenge-submitter-data.sql create mode 100644 src/reports/topcoder/dto/challenge-submitter-data.dto.ts diff --git a/sql/reports/topcoder/challenge-submitter-data.sql b/sql/reports/topcoder/challenge-submitter-data.sql new file mode 100644 index 0000000..2930202 --- /dev/null +++ b/sql/reports/topcoder/challenge-submitter-data.sql @@ -0,0 +1,155 @@ +WITH challenge_context AS ( + SELECT + c.id, + (LOWER(ct.name) = LOWER('Marathon Match')) AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +member_submissions AS ( + SELECT + cc.id AS challenge_id, + cc.is_marathon_match, + s.id AS submission_id, + s."memberId"::bigint AS user_id, + s.placement, + s."submittedDate" + FROM challenge_context AS cc + JOIN reviews.submission AS s + ON s."challengeId" = cc.id + AND s."memberId" IS NOT NULL +), +submitters AS ( + SELECT DISTINCT + ms.user_id, + ms.is_marathon_match + FROM member_submissions AS ms +), +primary_submission AS ( + SELECT DISTINCT ON (ms.user_id) + ms.user_id, + ms.submission_id, + ms.placement + FROM member_submissions AS ms + ORDER BY + ms.user_id, + ms.placement NULLS LAST, + ms."submittedDate" DESC NULLS LAST, + ms.submission_id DESC +), +winner_placements AS ( + SELECT + cw."userId"::bigint AS user_id, + MIN(cw.placement) AS placement + FROM challenge_context AS cc + JOIN challenges."ChallengeWinner" AS cw + ON cw."challengeId" = cc.id + WHERE cw.type = 'PLACEMENT' + GROUP BY cw."userId" +), +marathon_scores AS ( + SELECT + st.user_id, + COALESCE( + jsonb_agg( + rs."aggregateScore" + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt"), rs.id + ) FILTER ( + WHERE rs."isProvisional" IS TRUE + ), + '[]'::jsonb + ) AS provisional_scores + FROM submitters AS st + LEFT JOIN member_submissions AS ms + ON ms.user_id = st.user_id + LEFT JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = ms.submission_id + GROUP BY st.user_id +), +marathon_final_score AS ( + SELECT + st.user_id, + COALESCE(wp.placement, ps.placement) AS placement, + final_score."aggregateScore" AS final_score + FROM submitters AS st + LEFT JOIN winner_placements AS wp + ON wp.user_id = st.user_id + LEFT JOIN primary_submission AS ps + ON ps.user_id = st.user_id + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = ps.submission_id + AND rs."isFinal" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC, rs.id DESC + LIMIT 1 + ) AS final_score ON TRUE +), +submitter_profiles AS ( + SELECT + st.user_id, + COALESCE( + NULLIF(TRIM(mem.handle), ''), + NULLIF(TRIM(handle_fallback.member_handle), '') + ) AS handle, + mem.email, + COALESCE( + home_code.name, + home_id.name, + NULLIF(TRIM(mem."homeCountryCode"), ''), + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), '') + ) AS country, + st.is_marathon_match, + mfs.placement, + ms.provisional_scores, + mfs.final_score + FROM submitters AS st + LEFT JOIN members.member AS mem + ON mem."userId" = st.user_id + LEFT JOIN LATERAL ( + SELECT MAX(res."memberHandle") AS member_handle + FROM resources."Resource" AS res + WHERE res."challengeId" = $1::text + AND res."memberId" = st.user_id::text + ) AS handle_fallback ON TRUE + LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") + LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") + LEFT JOIN marathon_scores AS ms + ON ms.user_id = st.user_id + LEFT JOIN marathon_final_score AS mfs + ON mfs.user_id = st.user_id +) +SELECT + sp.user_id AS "userId", + sp.handle AS "handle", + sp.email AS "email", + sp.country AS "country", + CASE + WHEN sp.is_marathon_match THEN sp.placement + ELSE NULL + END AS "place", + CASE + WHEN sp.is_marathon_match THEN sp.provisional_scores + ELSE NULL + END AS "provisionalScores", + CASE + WHEN sp.is_marathon_match THEN sp.final_score + ELSE NULL + END AS "finalScore" +FROM submitter_profiles AS sp +ORDER BY + CASE + WHEN sp.is_marathon_match THEN sp.placement + ELSE NULL + END ASC NULLS LAST, + sp.handle ASC NULLS LAST, + sp.user_id ASC; diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 03c8681..55dd567 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -195,6 +195,14 @@ const registrantCountriesParam: ReportParameter = { required: true, }; +const challengeSubmitterDataParam: ReportParameter = { + name: "challengeId", + type: "string", + description: "Challenge ID to retrieve submitter profile data for", + location: "query", + required: true, +}; + const marathonMatchHandleParam: ReportParameter = { name: "handle", type: "string", @@ -427,6 +435,12 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { "Countries of all registrants for the specified challenge", [registrantCountriesParam], ), + report( + "challenge_submitter_data", + "/topcoder/challenge_submitter_data", + "Submitter profile data for a challenge, with Marathon Match placements and scores", + [challengeSubmitterDataParam], + ), report( "Marathon Match Stats", "/topcoder/mm-stats/:handle", diff --git a/src/reports/topcoder/dto/challenge-submitter-data.dto.ts b/src/reports/topcoder/dto/challenge-submitter-data.dto.ts new file mode 100644 index 0000000..a1540d8 --- /dev/null +++ b/src/reports/topcoder/dto/challenge-submitter-data.dto.ts @@ -0,0 +1,9 @@ +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class ChallengeSubmitterDataQueryDto { + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + challengeId!: string; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 63aec82..095644e 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -8,6 +8,7 @@ import { } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; +import { ChallengeSubmitterDataQueryDto } from "./dto/challenge-submitter-data.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { MemberPaymentAccrualQueryDto } from "./dto/member-payment-accrual.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; @@ -38,6 +39,16 @@ export class TopcoderReportsController { return this.reports.getRegistrantCountries(challengeId); } + @Get("/challenge_submitter_data") + @ApiOperation({ + summary: + "Submitter profile data for a challenge, with Marathon Match placements and scores", + }) + getChallengeSubmitterData(@Query() query: ChallengeSubmitterDataQueryDto) { + const { challengeId } = query; + return this.reports.getChallengeSubmitterData(challengeId); + } + @Get("/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index a298a3a..e680ed9 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -86,6 +86,16 @@ type RecentMemberDataRow = { submissions_over_75: string | number | null; }; +type ChallengeSubmitterDataRow = { + userId: string | number | null; + handle: string | null; + email: string | null; + country: string | null; + place: string | number | null; + provisionalScores: unknown; + finalScore: string | number | null; +}; + @Injectable() export class TopcoderReportsService { constructor( @@ -571,6 +581,25 @@ export class TopcoderReportsService { })); } + async getChallengeSubmitterData(challengeId: string) { + const query = this.sql.load( + "reports/topcoder/challenge-submitter-data.sql", + ); + const rows = await this.db.query(query, [ + challengeId, + ]); + + return rows.map((row) => ({ + userId: this.toNullableNumber(row.userId), + handle: row.handle ?? null, + email: row.email ?? null, + country: row.country ?? null, + place: this.toNullableNumber(row.place), + provisionalScores: this.toNullableNumberArray(row.provisionalScores), + finalScore: this.toNullableNumber(row.finalScore), + })); + } + async getMarathonMatchStats(handle: string) { const query = this.sql.load("reports/topcoder/mm-stats.sql"); const rows = await this.db.query(query, [handle]); @@ -607,6 +636,34 @@ export class TopcoderReportsService { }; } + private toNullableNumberArray(value: unknown): number[] | null { + if (value === null || value === undefined) { + return null; + } + + let normalizedValue = value; + + if (typeof normalizedValue === "string") { + try { + normalizedValue = JSON.parse(normalizedValue); + } catch { + return null; + } + } + + if (!Array.isArray(normalizedValue)) { + return null; + } + + return normalizedValue.reduce((scores, item) => { + const numericValue = Number(item); + if (Number.isFinite(numericValue)) { + scores.push(numericValue); + } + return scores; + }, []); + } + private toNullableNumber(value: string | number | null | undefined) { if (value === null || value === undefined) { return null; From 867bf8a3deee150fd91087309bf967072c6d56bb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 12:05:48 +1100 Subject: [PATCH 04/42] Fix for PM-4046 --- .../challenges/registrants-history.sql | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/sql/reports/challenges/registrants-history.sql b/sql/reports/challenges/registrants-history.sql index 3ea663c..f38e0ef 100644 --- a/sql/reports/challenges/registrants-history.sql +++ b/sql/reports/challenges/registrants-history.sql @@ -75,7 +75,6 @@ registrants AS MATERIALIZED ( AND res."roleId" = sr.id GROUP BY res."memberId" ) registrant ON true - LIMIT 1000 ) SELECT r."challengeId", @@ -85,7 +84,6 @@ SELECT ( COALESCE(win."isWinner", false) OR COALESCE(sub."isWinner", false) - OR COALESCE(cr."isWinner", false) ) AS "isWinner", CASE WHEN r."challengeStatus" = 'COMPLETED' @@ -93,7 +91,7 @@ SELECT ELSE null END AS "challengeCompletedDate", r."registrantHandle", - COALESCE(sub."registrantFinalScore", cr."registrantFinalScore") + COALESCE(sub."registrantFinalScore", sum."registrantFinalScore") AS "registrantFinalScore" FROM registrants r LEFT JOIN LATERAL ( @@ -108,16 +106,19 @@ LEFT JOIN LATERAL ( LEFT JOIN LATERAL ( SELECT BOOL_OR(s.placement = 1) AS "isWinner", - ROUND(MAX(s."finalScore"), 2) AS "registrantFinalScore" + COALESCE(ROUND(MAX(s."finalScore")::numeric, 2), ROUND(MAX(s."initialScore")::numeric, 2)) AS "registrantFinalScore" FROM reviews.submission s WHERE s."challengeId" = r."challengeId" AND s."memberId" = r."memberId" ) sub ON true LEFT JOIN LATERAL ( SELECT - BOOL_OR(cr.placement = 1) AS "isWinner", - ROUND(MAX(cr."finalScore")::numeric, 2) AS "registrantFinalScore" - FROM reviews."challengeResult" cr - WHERE cr."challengeId" = r."challengeId" - AND cr."userId" = r."memberId" -) cr ON true; + ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "registrantFinalScore" + FROM reviews."reviewSummation" rs + WHERE rs."submissionId" IN ( + SELECT id from reviews.submission + WHERE "challengeId" = r."challengeId" + AND "memberId" = r."memberId" + ) +) sum ON true; + From 820da9e2e598679fda2acce4739f624ae502b72b Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 4 Mar 2026 09:04:01 +0200 Subject: [PATCH 05/42] Update Trivy action to use latest version --- .github/workflows/trivy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa48..38d108d 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@latest with: scan-type: "fs" ignore-unfixed: true From 3c03155300f715ec0302a4e45589aa191a7cddbc Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 13:54:41 +0200 Subject: [PATCH 06/42] PM-4158 - Update profiles report --- sql/reports/topcoder/completed-profiles.sql | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index a3b3c26..9e64597 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -3,10 +3,10 @@ -- - description (bio) -- - profile photo -- - at least 3 skills --- - gig availability set (true or false) +-- - engagement availability (personalization trait with openToWork, availability boolean, and preferredRoles array) -- - at least one work history entry -- - at least one education entry --- - at least one location (address) +-- - at least one location (city in address AND homeCountryCode) WITH member_skills AS ( SELECT @@ -29,10 +29,26 @@ member_education AS ( JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id ), +member_engagement_availability AS ( + SELECT DISTINCT + mt."userId" + FROM members."memberTraits" mt + JOIN members."memberTraitPersonalization" mtp + ON mtp."memberTraitId" = mt.id + WHERE mt."traitId" = 'personalization' + AND mtp.value IS NOT NULL + AND mtp.value::jsonb ? 'openToWork' + AND mtp.value::jsonb ->> 'availability' IS NOT NULL + AND jsonb_typeof(mtp.value::jsonb -> 'availability') = 'boolean' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 +), member_location AS ( SELECT DISTINCT ma."userId" FROM members."memberAddress" ma + WHERE ma.city IS NOT NULL + AND TRIM(ma.city) <> '' ), completed_profiles AS ( SELECT @@ -45,15 +61,17 @@ completed_profiles AS ( LEFT JOIN member_skills ms ON ms.user_id = m."userId" LEFT JOIN member_work_history mwh ON mwh."userId" = m."userId" LEFT JOIN member_education me ON me."userId" = m."userId" + LEFT JOIN member_engagement_availability mea ON mea."userId" = m."userId" LEFT JOIN member_location ml ON ml."userId" = m."userId" WHERE m.description IS NOT NULL AND m.description <> '' AND m."photoURL" IS NOT NULL AND m."photoURL" <> '' - AND m."availableForGigs" IS NOT NULL + AND m."homeCountryCode" IS NOT NULL AND COALESCE(ms.skill_count, 0) >= 3 AND mwh."userId" IS NOT NULL AND me."userId" IS NOT NULL + AND mea."userId" IS NOT NULL AND ml."userId" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) ) From 5a704df774e17351d97446dc20d34691e231450b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 14:29:51 +0200 Subject: [PATCH 07/42] depploy to dev --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8eb7042..5f26f74 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,8 @@ workflows: only: - develop - pm-1127_1 - + - PM-4158_completed-profiles-report + # Production builds are exectuted only on tagged commits to the # master branch. - "build-prod": From 0072f8a78af7be330de452c089fcca052bb4335a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 14:53:51 +0200 Subject: [PATCH 08/42] PM-4158 #time 30m Fix profiles query --- sql/reports/topcoder/completed-profiles.sql | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 9e64597..dd01fbd 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -35,11 +35,10 @@ member_engagement_availability AS ( FROM members."memberTraits" mt JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - WHERE mt."traitId" = 'personalization' + WHERE mtp.key = 'openToWork' AND mtp.value IS NOT NULL - AND mtp.value::jsonb ? 'openToWork' + AND mtp.value::jsonb ? 'availability' AND mtp.value::jsonb ->> 'availability' IS NOT NULL - AND jsonb_typeof(mtp.value::jsonb -> 'availability') = 'boolean' AND mtp.value::jsonb ? 'preferredRoles' AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 ), From 857eb1e5ec9c1764f2c9b71211b17c9ca69343c4 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 15:18:37 +0200 Subject: [PATCH 09/42] Fix engagement availability --- sql/reports/topcoder/completed-profiles.sql | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index dd01fbd..eaac94a 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -37,10 +37,14 @@ member_engagement_availability AS ( ON mtp."memberTraitId" = mt.id WHERE mtp.key = 'openToWork' AND mtp.value IS NOT NULL - AND mtp.value::jsonb ? 'availability' - AND mtp.value::jsonb ->> 'availability' IS NOT NULL - AND mtp.value::jsonb ? 'preferredRoles' - AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + AND ( + NOT (mtp.value::jsonb ? 'availability') + OR ( + mtp.value::jsonb ? 'availability' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ) + ) ), member_location AS ( SELECT DISTINCT From 690f73e5b25af11ef220ca9b6ce867901b97458a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 15:31:08 +0200 Subject: [PATCH 10/42] PM-4158 #time 15m refactor query for completed profiles --- sql/reports/topcoder/completed-profiles.sql | 123 +++++++++----------- 1 file changed, 53 insertions(+), 70 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index eaac94a..abb3c43 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -1,4 +1,3 @@ --- Query to return members with completed profiles -- A profile is considered complete if it has: -- - description (bio) -- - profile photo @@ -14,74 +13,58 @@ WITH member_skills AS ( COUNT(*) AS skill_count FROM skills.user_skill us GROUP BY us.user_id -), -member_work_history AS ( - SELECT DISTINCT - mt."userId" - FROM members."memberTraits" mt - JOIN members."memberTraitWork" mw - ON mw."memberTraitId" = mt.id -), -member_education AS ( - SELECT DISTINCT - mt."userId" - FROM members."memberTraits" mt - JOIN members."memberTraitEducation" me - ON me."memberTraitId" = mt.id -), -member_engagement_availability AS ( - SELECT DISTINCT - mt."userId" - FROM members."memberTraits" mt - JOIN members."memberTraitPersonalization" mtp - ON mtp."memberTraitId" = mt.id - WHERE mtp.key = 'openToWork' - AND mtp.value IS NOT NULL - AND ( - NOT (mtp.value::jsonb ? 'availability') - OR ( - mtp.value::jsonb ? 'availability' - AND mtp.value::jsonb ? 'preferredRoles' - AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 - ) - ) -), -member_location AS ( - SELECT DISTINCT - ma."userId" - FROM members."memberAddress" ma - WHERE ma.city IS NOT NULL - AND TRIM(ma.city) <> '' -), -completed_profiles AS ( - SELECT - m."userId", - m.handle, - COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", - m.country AS "countryName", - m.email - FROM members.member m - LEFT JOIN member_skills ms ON ms.user_id = m."userId" - LEFT JOIN member_work_history mwh ON mwh."userId" = m."userId" - LEFT JOIN member_education me ON me."userId" = m."userId" - LEFT JOIN member_engagement_availability mea ON mea."userId" = m."userId" - LEFT JOIN member_location ml ON ml."userId" = 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 COALESCE(ms.skill_count, 0) >= 3 - AND mwh."userId" IS NOT NULL - AND me."userId" IS NOT NULL - AND mea."userId" IS NOT NULL - AND ml."userId" IS NOT NULL - AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) + HAVING COUNT(*) >= 3 -- Filter early to reduce dataset ) SELECT - cp."userId" AS "userId", - cp.handle, - cp."countryCode", - cp."countryName" -FROM completed_profiles cp -ORDER BY cp.handle; + m."userId" AS "userId", + m.handle, + COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", + m.country AS "countryName" +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) + -- Check work history exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitWork" mw ON mw."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + -- Check education exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + -- Check engagement availability exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + AND mtp.value IS NOT NULL + AND ( + NOT (mtp.value::jsonb ? 'availability') + OR ( + mtp.value::jsonb ? 'availability' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ) + ) + ) + -- Check location exists + AND EXISTS ( + SELECT 1 + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ) +ORDER BY m.handle; From 5dc70a5aedf2670b2d091033564928997ab08361 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 09:46:09 +1100 Subject: [PATCH 11/42] Updates for new reports requirements --- sql/reports/challenges/registered-users.sql | 82 ++++++++ sql/reports/challenges/submitters.sql | 85 ++++++++ sql/reports/challenges/valid-submitters.sql | 86 ++++++++ sql/reports/challenges/winners.sql | 81 ++++++++ sql/reports/identity/users-by-group.sql | 18 ++ sql/reports/identity/users-by-handles.sql | 30 +++ sql/reports/identity/users-by-role.sql | 16 ++ src/app-constants.ts | 9 + src/app.module.ts | 2 + .../challenges-reports.controller.ts | 66 ++++++ .../challenges/challenges-reports.service.ts | 100 +++++++++ .../challenges/dtos/challenge-users.dto.ts | 27 +++ .../identity/dtos/identity-users.dto.ts | 63 ++++++ .../identity/identity-reports.controller.ts | 138 +++++++++++++ .../identity/identity-reports.module.ts | 20 ++ .../identity/identity-reports.service.ts | 191 ++++++++++++++++++ src/reports/report-directory.data.ts | 116 ++++++++++- 17 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 sql/reports/challenges/registered-users.sql create mode 100644 sql/reports/challenges/submitters.sql create mode 100644 sql/reports/challenges/valid-submitters.sql create mode 100644 sql/reports/challenges/winners.sql create mode 100644 sql/reports/identity/users-by-group.sql create mode 100644 sql/reports/identity/users-by-handles.sql create mode 100644 sql/reports/identity/users-by-role.sql create mode 100644 src/reports/challenges/dtos/challenge-users.dto.ts create mode 100644 src/reports/identity/dtos/identity-users.dto.ts create mode 100644 src/reports/identity/identity-reports.controller.ts create mode 100644 src/reports/identity/identity-reports.module.ts create mode 100644 src/reports/identity/identity-reports.service.ts diff --git a/sql/reports/challenges/registered-users.sql b/sql/reports/challenges/registered-users.sql new file mode 100644 index 0000000..599a0bc --- /dev/null +++ b/sql/reports/challenges/registered-users.sql @@ -0,0 +1,82 @@ +WITH challenge_context AS ( + SELECT + c.id, + ct.name AS "challengeType", + (ct.name = 'Marathon Match') AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +registered_members AS MATERIALIZED ( + SELECT + cc.id AS "challengeId", + cc.is_marathon_match, + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + GROUP BY + cc.id, + cc.is_marathon_match, + res."memberId" +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN rm."memberId" ~ '^[0-9]+$' THEN rm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE(u.handle, rm."memberHandle") AS "handle", + e.address AS "email", + -- Resolve competition country first, then fall back to home country. + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + mem."competitionCountryCode", + mem."homeCountryCode" + ) AS "country", + -- Marathon Match score support is kept in schema; non-MM rows return NULL. + CASE + WHEN rm.is_marathon_match THEN mm_score."submissionScore" + ELSE NULL + END AS "submissionScore" +FROM registered_members AS rm +LEFT JOIN identity."user" AS u + ON rm."memberId" ~ '^[0-9]+$' + AND u.user_id = rm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON rm."memberId" ~ '^[0-9]+$' + AND mem."userId" = rm."memberId"::bigint +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") +LEFT JOIN LATERAL ( + -- For MM, use the best aggregateScore across this member's submissions. + SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" + FROM reviews."submission" AS s + JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = s.id + WHERE s."challengeId" = rm."challengeId" + AND s."memberId" = rm."memberId" +) AS mm_score ON true +ORDER BY + "submissionScore" DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql new file mode 100644 index 0000000..97b1f26 --- /dev/null +++ b/sql/reports/challenges/submitters.sql @@ -0,0 +1,85 @@ +WITH challenge_context AS ( + SELECT + c.id, + ct.name AS "challengeType", + (ct.name = 'Marathon Match') AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +submitter_members AS MATERIALIZED ( + SELECT + cc.id AS "challengeId", + cc.is_marathon_match, + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + JOIN reviews."submission" AS s + ON s."challengeId" = cc.id + AND s."memberId" = res."memberId" + GROUP BY + cc.id, + cc.is_marathon_match, + res."memberId" +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN sm."memberId" ~ '^[0-9]+$' THEN sm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE(u.handle, sm."memberHandle") AS "handle", + e.address AS "email", + -- Resolve competition country first, then fall back to home country. + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + mem."competitionCountryCode", + mem."homeCountryCode" + ) AS "country", + -- Marathon Match detection controls whether aggregate score is emitted. + CASE + WHEN sm.is_marathon_match THEN mm_score."submissionScore" + ELSE NULL + END AS "submissionScore" +FROM submitter_members AS sm +LEFT JOIN identity."user" AS u + ON sm."memberId" ~ '^[0-9]+$' + AND u.user_id = sm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON sm."memberId" ~ '^[0-9]+$' + AND mem."userId" = sm."memberId"::bigint +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") +LEFT JOIN LATERAL ( + -- For MM, use the best aggregateScore across this member's submissions. + SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" + FROM reviews."submission" AS s + JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = s.id + WHERE s."challengeId" = sm."challengeId" + AND s."memberId" = sm."memberId" +) AS mm_score ON true +ORDER BY + "submissionScore" DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql new file mode 100644 index 0000000..0cb2413 --- /dev/null +++ b/sql/reports/challenges/valid-submitters.sql @@ -0,0 +1,86 @@ +WITH challenge_context AS ( + SELECT + c.id, + ct.name AS "challengeType", + (ct.name = 'Marathon Match') AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +valid_submitter_members AS MATERIALIZED ( + SELECT + cc.id AS "challengeId", + cc.is_marathon_match, + res."memberId", + MAX(res."memberHandle") AS "memberHandle" + FROM challenge_context AS cc + JOIN resources."Resource" AS res + ON res."challengeId" = cc.id + JOIN resources."ResourceRole" AS rr + ON rr.id = res."roleId" + AND rr.name = 'Submitter' + JOIN reviews."submission" AS s + ON s."challengeId" = cc.id + AND s."memberId" = res."memberId" + AND s."finalScore" > 98 + GROUP BY + cc.id, + cc.is_marathon_match, + res."memberId" +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN vsm."memberId" ~ '^[0-9]+$' THEN vsm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE(u.handle, vsm."memberHandle") AS "handle", + e.address AS "email", + -- Resolve competition country first, then fall back to home country. + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + mem."competitionCountryCode", + mem."homeCountryCode" + ) AS "country", + -- Marathon Match detection controls whether aggregate score is emitted. + CASE + WHEN vsm.is_marathon_match THEN mm_score."submissionScore" + ELSE NULL + END AS "submissionScore" +FROM valid_submitter_members AS vsm +LEFT JOIN identity."user" AS u + ON vsm."memberId" ~ '^[0-9]+$' + AND u.user_id = vsm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON vsm."memberId" ~ '^[0-9]+$' + AND mem."userId" = vsm."memberId"::bigint +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") +LEFT JOIN LATERAL ( + -- For MM, use the best aggregateScore across this member's submissions. + SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" + FROM reviews."submission" AS s + JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = s.id + WHERE s."challengeId" = vsm."challengeId" + AND s."memberId" = vsm."memberId" +) AS mm_score ON true +ORDER BY + "submissionScore" DESC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql new file mode 100644 index 0000000..4040fbb --- /dev/null +++ b/sql/reports/challenges/winners.sql @@ -0,0 +1,81 @@ +WITH challenge_context AS ( + SELECT + c.id, + ct.name AS "challengeType", + (ct.name = 'Marathon Match') AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = $1::text +), +winner_members AS MATERIALIZED ( + SELECT + cc.id AS "challengeId", + cc.is_marathon_match, + cw."userId"::text AS "memberId", + MAX(cw.handle) AS "winnerHandle", + MIN(cw.placement) AS placement + FROM challenge_context AS cc + JOIN challenges."ChallengeWinner" AS cw + ON cw."challengeId" = cc.id + GROUP BY + cc.id, + cc.is_marathon_match, + cw."userId" +) +SELECT + COALESCE( + u.user_id::bigint, + CASE + WHEN wm."memberId" ~ '^[0-9]+$' THEN wm."memberId"::bigint + ELSE NULL + END + ) AS "userId", + COALESCE(u.handle, wm."winnerHandle") AS "handle", + e.address AS "email", + -- Resolve competition country first, then fall back to home country. + COALESCE( + comp_code.name, + comp_id.name, + home_code.name, + home_id.name, + mem."competitionCountryCode", + mem."homeCountryCode" + ) AS "country", + -- Marathon Match detection controls whether aggregate score is emitted. + CASE + WHEN wm.is_marathon_match THEN mm_score."submissionScore" + ELSE NULL + END AS "submissionScore" +FROM winner_members AS wm +LEFT JOIN identity."user" AS u + ON wm."memberId" ~ '^[0-9]+$' + AND u.user_id = wm."memberId"::numeric +LEFT JOIN identity.email AS e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +LEFT JOIN members."member" AS mem + ON wm."memberId" ~ '^[0-9]+$' + AND mem."userId" = wm."memberId"::bigint +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") +LEFT JOIN LATERAL ( + -- For MM, use the best aggregateScore across this member's submissions. + SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" + FROM reviews."submission" AS s + JOIN reviews."reviewSummation" AS rs + ON rs."submissionId" = s.id + WHERE s."challengeId" = wm."challengeId" + AND s."memberId" = wm."memberId" +) AS mm_score ON true +ORDER BY + "submissionScore" DESC NULLS LAST, + wm.placement ASC NULLS LAST, + "handle" ASC NULLS LAST, + "userId" ASC NULLS LAST; diff --git a/sql/reports/identity/users-by-group.sql b/sql/reports/identity/users-by-group.sql new file mode 100644 index 0000000..157ac61 --- /dev/null +++ b/sql/reports/identity/users-by-group.sql @@ -0,0 +1,18 @@ +SELECT + u.user_id AS "userId", + u.handle AS "handle", + e.address AS "email" +FROM identity.security_groups sg +JOIN identity.user_group_xref ugx + ON sg.group_id = ugx.group_id +JOIN identity.security_user su + ON ugx.login_id = su.login_id +JOIN identity."user" u + ON su.user_id ~ '^[0-9]+$' + AND su.user_id::numeric = u.user_id +LEFT JOIN identity.email e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +WHERE ($1::numeric IS NOT NULL OR $2::text IS NOT NULL) + AND ($1::numeric IS NULL OR sg.group_id = $1::numeric) + AND ($2::text IS NULL OR LOWER(sg.description) = LOWER($2::text)); diff --git a/sql/reports/identity/users-by-handles.sql b/sql/reports/identity/users-by-handles.sql new file mode 100644 index 0000000..9265b68 --- /dev/null +++ b/sql/reports/identity/users-by-handles.sql @@ -0,0 +1,30 @@ +WITH input_handles AS ( + SELECT + handle_input, + ordinality + FROM unnest($1::text[]) WITH ORDINALITY AS t(handle_input, ordinality) +) +SELECT + ih.handle_input AS "handle", + u.user_id AS "userId", + pe.address AS "email", + COALESCE( + NULLIF(BTRIM(mem."competitionCountryCode"), ''), + NULLIF(BTRIM(mem."homeCountryCode"), '') + ) AS "country" +FROM input_handles AS ih +LEFT JOIN identity."user" AS u + ON LOWER(u.handle) = LOWER(ih.handle_input) +LEFT JOIN LATERAL ( + SELECT e.address + FROM identity.user_email_xref AS uex + INNER JOIN identity.email AS e + ON e.email_id = uex.email_id + WHERE uex.user_id = u.user_id + ORDER BY uex.is_primary DESC, e.primary_ind DESC, e.email_id ASC + LIMIT 1 +) AS pe + ON TRUE +LEFT JOIN members."member" AS mem + ON mem."userId" = u.user_id +ORDER BY ih.ordinality; diff --git a/sql/reports/identity/users-by-role.sql b/sql/reports/identity/users-by-role.sql new file mode 100644 index 0000000..45e8133 --- /dev/null +++ b/sql/reports/identity/users-by-role.sql @@ -0,0 +1,16 @@ +SELECT + u.user_id AS "userId", + u.handle AS "handle", + e.address AS "email" +FROM identity.role r +JOIN identity.role_assignment ra + ON r.id = ra.role_id +JOIN identity."user" u + ON ra.subject_id = u.user_id +LEFT JOIN identity.email e + ON e.user_id = u.user_id + AND e.primary_ind = 1 +WHERE ($1::int IS NOT NULL OR $2::text IS NOT NULL) + AND ra.subject_type = 1 + AND ($1::int IS NULL OR r.id = $1::int) + AND ($2::text IS NULL OR LOWER(r.name) = LOWER($2::text)); diff --git a/src/app-constants.ts b/src/app-constants.ts index 42777a9..f9c1771 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -23,6 +23,15 @@ export const Scopes = { History: "reports:challenge-history", Registrants: "reports:challenge-registrants", SubmissionLinks: "reports:challenge-submission-links", + RegisteredUsers: "reports:challenge-registered-users", + Submitters: "reports:challenge-submitters", + ValidSubmitters: "reports:challenge-valid-submitters", + Winners: "reports:challenge-winners", + }, + Identity: { + UsersByRole: "reports:identity-users-by-role", + UsersByGroup: "reports:identity-users-by-group", + UsersByHandles: "reports:identity-users-by-handles", }, }; diff --git a/src/app.module.ts b/src/app.module.ts index 2d8eed5..3fccf17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { TopcoderReportsModule } from "./reports/topcoder/topcoder-reports.modul import { StatisticsModule } from "./statistics/statistics.module"; import { SfdcReportsModule } from "./reports/sfdc/sfdc-reports.module"; import { ChallengesReportsModule } from "./reports/challenges/challenges-reports.module"; +import { IdentityReportsModule } from "./reports/identity/identity-reports.module"; import { ReportsModule } from "./reports/reports.module"; @Module({ @@ -20,6 +21,7 @@ import { ReportsModule } from "./reports/reports.module"; StatisticsModule, SfdcReportsModule, ChallengesReportsModule, + IdentityReportsModule, ReportsModule, HealthModule, ], diff --git a/src/reports/challenges/challenges-reports.controller.ts b/src/reports/challenges/challenges-reports.controller.ts index 4f2cad7..c5c43bb 100644 --- a/src/reports/challenges/challenges-reports.controller.ts +++ b/src/reports/challenges/challenges-reports.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Param, Query, UseGuards, UseInterceptors, @@ -25,6 +26,7 @@ import { SubmissionLinksDto, SubmissionLinksQueryDto, } from "./dtos/submission-links.dto"; +import { ChallengeUsersPathParamDto } from "./dtos/challenge-users.dto"; @ApiTags("Challenges Reports") @ApiProduces("application/json", "text/csv") @@ -83,4 +85,68 @@ export class ChallengesReportsController { const report = await this.reportsService.getSubmissionLinks(query); return report; } + + @Get("/:challengeId/registered-users") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.RegisteredUsers) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge registered users report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getRegisteredUsers(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getRegisteredUsers(params); + return report; + } + + @Get("/:challengeId/submitters") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.Submitters) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge submitters report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getSubmitters(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getSubmitters(params); + return report; + } + + @Get("/:challengeId/valid-submitters") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.ValidSubmitters) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge valid submitters report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getValidSubmitters(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getValidSubmitters(params); + return report; + } + + @Get("/:challengeId/winners") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Challenge.Winners) + @ApiBearerAuth() + @ApiOperation({ + summary: "Return the challenge winners report", + }) + @ApiResponse({ + status: 200, + description: "Export successful.", + }) + async getWinners(@Param() params: ChallengeUsersPathParamDto) { + const report = await this.reportsService.getWinners(params); + return report; + } } diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index daf2437..ec41bb1 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -9,6 +9,10 @@ import { import { multiValueArrayFilter } from "src/common/filtering"; import { ChallengesReportResponseDto } from "./dtos/challenge.dto"; import { SubmissionLinksQueryDto } from "./dtos/submission-links.dto"; +import { + ChallengeUserRecordDto, + ChallengeUsersPathParamDto, +} from "./dtos/challenge-users.dto"; @Injectable() export class ChallengesReportsService { @@ -76,4 +80,100 @@ export class ChallengesReportsService { return payments; } + + /** + * Retrieves all users registered for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Registered user records with handle, email, country, and MM score when applicable. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getRegisteredUsers( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getRegisteredUsers with filters:", filters); + const query = this.sql.load("reports/challenges/registered-users.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves users who submitted at least one submission for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Submitter records with handle, email, country, and MM score when applicable. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getSubmitters( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getSubmitters with filters:", filters); + const query = this.sql.load("reports/challenges/submitters.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves users with at least one passing submission for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Valid submitter records with handle, email, country, and MM score when applicable. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getValidSubmitters( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getValidSubmitters with filters:", filters); + const query = this.sql.load("reports/challenges/valid-submitters.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Retrieves winner records for the specified challenge. + * @param filters Path params containing challengeId. + * @returns Winner records with handle, email, country, and MM score when applicable. + * @throws Does not throw. Logs query errors and returns an empty array. + */ + async getWinners( + filters: ChallengeUsersPathParamDto, + ): Promise { + this.logger.debug("Starting getWinners with filters:", filters); + const query = this.sql.load("reports/challenges/winners.sql"); + + try { + const results = await this.db.query(query, [ + filters.challengeId, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts new file mode 100644 index 0000000..a881dcd --- /dev/null +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +/** + * Path parameters used to retrieve challenge user reports by challenge ID. + */ +export class ChallengeUsersPathParamDto { + @ApiProperty({ + required: true, + description: "Challenge ID to retrieve user report data for", + }) + @IsString() + @IsNotEmpty() + challengeId: string; +} + +/** + * User record returned by challenge user reports including resolved country. + * For Marathon Match challenges, submissionScore contains the best aggregate score. + */ +export interface ChallengeUserRecordDto { + userId: number; + handle: string; + email: string | null; + country: string | null; + submissionScore?: number | null; +} diff --git a/src/reports/identity/dtos/identity-users.dto.ts b/src/reports/identity/dtos/identity-users.dto.ts new file mode 100644 index 0000000..a92bb92 --- /dev/null +++ b/src/reports/identity/dtos/identity-users.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; + +/** + * Query filters for exporting users by role. + */ +export class UsersByRoleQueryDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Type(() => Number) + roleId?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + roleName?: string; +} + +/** + * Query filters for exporting users by security group. + */ +export class UsersByGroupQueryDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Type(() => Number) + groupId?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + groupName?: string; +} + +/** + * Shared identity user payload returned by identity report endpoints. + */ +export interface IdentityUserDto { + userId: number | null; + handle: string; + email: string | null; + country: string | null; +} + +/** + * Request payload for exporting users by handle list. + */ +export class UsersByHandlesBodyDto { + @ApiProperty({ type: [String], required: true }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + handles!: string[]; +} diff --git a/src/reports/identity/identity-reports.controller.ts b/src/reports/identity/identity-reports.controller.ts new file mode 100644 index 0000000..b29a00b --- /dev/null +++ b/src/reports/identity/identity-reports.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + Get, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, + ValidationPipe, +} from "@nestjs/common"; +import { + ApiBody, + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Scopes as AppScopes } from "src/app-constants"; +import { Scopes } from "src/auth/decorators/scopes.decorator"; +import { PermissionsGuard } from "src/auth/guards/permissions.guard"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; +import { + UsersByHandlesBodyDto, + UsersByGroupQueryDto, + UsersByRoleQueryDto, +} from "./dtos/identity-users.dto"; +import { IdentityReportsService } from "./identity-reports.service"; + +type UploadedHandlesFile = { + originalname?: string; + buffer?: Buffer; +}; + +/** + * Handles identity report endpoints and delegates query execution to the service layer. + */ +@ApiTags("Identity Reports") +@ApiProduces("application/json", "text/csv") +@UseInterceptors(CsvResponseInterceptor) +@Controller("/identity") +export class IdentityReportsController { + constructor(private readonly service: IdentityReportsService) {} + + /** + * Exports users matched by role ID and/or role name. + * @param query Query-string filters for role lookup. + * @returns List of matching identity users. + */ + @Get("/users-by-role") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByRole) + @ApiBearerAuth() + @ApiOperation({ summary: "Export users for a given role (by ID or name)" }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByRole(@Query() query: UsersByRoleQueryDto) { + return this.service.getUsersByRole(query); + } + + /** + * Exports users matched by group ID and/or group description. + * @param query Query-string filters for group lookup. + * @returns List of matching identity users. + */ + @Get("/users-by-group") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByGroup) + @ApiBearerAuth() + @ApiOperation({ summary: "Export users for a given group (by ID or name)" }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByGroup(@Query() query: UsersByGroupQueryDto) { + return this.service.getUsersByGroup(query); + } + + /** + * Exports user details for the provided list of handles. + * Unknown handles are included with null values for unmatched fields. + * @param body JSON payload containing handles to look up. + * @returns List with one row per requested handle. + */ + @Post("/users-by-handles") + @UseGuards(PermissionsGuard) + @UseInterceptors(FileInterceptor("file")) + @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByHandles) + @ApiBearerAuth() + @ApiOperation({ + summary: + "Export user details (ID, handle, email, country) for a list of handles", + }) + @ApiConsumes("application/json", "multipart/form-data") + @ApiBody({ + required: true, + schema: { + oneOf: [ + { + type: "object", + required: ["handles"], + properties: { + handles: { + type: "array", + items: { type: "string" }, + description: "List of handles to look up.", + }, + }, + }, + { + type: "object", + required: ["file"], + properties: { + file: { + type: "string", + format: "binary", + description: + "Upload a .txt or .csv file with handles (one per line or comma-separated).", + }, + }, + }, + ], + }, + }) + @ApiResponse({ status: 200, description: "Export successful." }) + async getUsersByHandles( + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + skipMissingProperties: true, + }), + ) + body: UsersByHandlesBodyDto, + @UploadedFile() file?: UploadedHandlesFile, + ) { + return this.service.getUsersByHandles(body, file); + } +} diff --git a/src/reports/identity/identity-reports.module.ts b/src/reports/identity/identity-reports.module.ts new file mode 100644 index 0000000..fda9f53 --- /dev/null +++ b/src/reports/identity/identity-reports.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { CsvSerializer } from "src/common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; +import { SqlLoaderService } from "src/common/sql-loader.service"; +import { IdentityReportsController } from "./identity-reports.controller"; +import { IdentityReportsService } from "./identity-reports.service"; + +/** + * Nest module that wires identity report controller, service, and CSV helpers. + */ +@Module({ + controllers: [IdentityReportsController], + providers: [ + IdentityReportsService, + SqlLoaderService, + CsvSerializer, + CsvResponseInterceptor, + ], +}) +export class IdentityReportsModule {} diff --git a/src/reports/identity/identity-reports.service.ts b/src/reports/identity/identity-reports.service.ts new file mode 100644 index 0000000..730ad7c --- /dev/null +++ b/src/reports/identity/identity-reports.service.ts @@ -0,0 +1,191 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { extname } from "node:path"; +import { alpha3ToCountryName } from "src/common/country.util"; +import { Logger } from "src/common/logger"; +import { SqlLoaderService } from "src/common/sql-loader.service"; +import { DbService } from "src/db/db.service"; +import { + IdentityUserDto, + UsersByHandlesBodyDto, + UsersByGroupQueryDto, + UsersByRoleQueryDto, +} from "./dtos/identity-users.dto"; + +const SUPPORTED_HANDLES_UPLOAD_EXTENSIONS = new Set([".txt", ".csv"]); + +type UsersByHandlesRow = { + userId: number | null; + handle: string; + email: string | null; + country: string | null; +}; + +type UploadedHandlesFile = { + originalname?: string; + buffer?: Buffer; +}; + +/** + * Provides identity-focused report queries and maps HTTP filters to SQL parameters. + */ +@Injectable() +export class IdentityReportsService { + private readonly logger = new Logger(IdentityReportsService.name); + + /** + * Initializes the service with database access and SQL template loading. + * @param db PostgreSQL query service. + * @param sql SQL file loader for report templates. + */ + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + /** + * Queries users assigned to a role by role ID and/or role name. + * @param filters Role filters from the request query string. + * @returns Matching users with ID, handle, and primary email. + */ + async getUsersByRole( + filters: UsersByRoleQueryDto, + ): Promise { + this.logger.debug("Starting getUsersByRole with filters:", filters); + const query = this.sql.load("reports/identity/users-by-role.sql"); + + try { + const results = await this.db.query(query, [ + filters.roleId ?? null, + filters.roleName ?? null, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Queries users in a security group by group ID and/or group description. + * @param filters Group filters from the request query string. + * @returns Matching users with ID, handle, and primary email. + */ + async getUsersByGroup( + filters: UsersByGroupQueryDto, + ): Promise { + this.logger.debug("Starting getUsersByGroup with filters:", filters); + const query = this.sql.load("reports/identity/users-by-group.sql"); + + try { + const results = await this.db.query(query, [ + filters.groupId ?? null, + filters.groupName ?? null, + ]); + + return results; + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Queries user details for each submitted handle, preserving all input handles. + * Unknown handles are returned with null user details. + * @param body Request body containing a non-empty list of handles. + * @param file Optional `.txt` or `.csv` upload with handles. + * @returns One row per input handle with user ID, handle, email, and country. + * @throws Does not throw; query failures are logged and return an empty array. + */ + async getUsersByHandles( + body: UsersByHandlesBodyDto, + file?: UploadedHandlesFile, + ): Promise { + const handles = this.resolveHandles(body, file); + this.logger.debug("Starting getUsersByHandles with handle count:", { + count: handles.length, + }); + const query = this.sql.load("reports/identity/users-by-handles.sql"); + + try { + const results = await this.db.query(query, [handles]); + + return results.map((row) => ({ + userId: row.userId, + handle: row.handle, + email: row.email, + country: alpha3ToCountryName(row.country) ?? row.country, + })); + } catch (e) { + this.logger.error(e); + return []; + } + } + + /** + * Resolves handles from either JSON body input or uploaded text/CSV content. + * @param body Request JSON body. + * @param file Optional uploaded file. + * @returns Normalized handle list in caller-provided order. + * @throws BadRequestException when neither mode provides at least one handle. + */ + private resolveHandles( + body: UsersByHandlesBodyDto | undefined, + file?: UploadedHandlesFile, + ): string[] { + if (Array.isArray(body?.handles) && body.handles.length > 0) { + return body.handles; + } + + if (file) { + return this.parseHandlesFromFile(file); + } + + throw new BadRequestException( + "Provide either a non-empty 'handles' array or a .txt/.csv file upload.", + ); + } + + /** + * Parses handles from uploaded `.txt`/`.csv` content. + * @param file Uploaded file provided by multipart form-data. + * @returns Ordered handles extracted from file contents. + * @throws BadRequestException when type is unsupported or file has no handles. + */ + private parseHandlesFromFile(file: UploadedHandlesFile): string[] { + const extension = extname(file.originalname ?? "").toLowerCase(); + if (!SUPPORTED_HANDLES_UPLOAD_EXTENSIONS.has(extension)) { + throw new BadRequestException( + "Unsupported file type. Only .txt and .csv uploads are allowed.", + ); + } + + const content = (file.buffer ?? Buffer.alloc(0)) + .toString("utf8") + .replace(/^\uFEFF/, ""); + + const handles = content + .split(/\r?\n/) + .flatMap((line) => line.split(",")) + .map((value) => + value + .trim() + .replace(/^"(.*)"$/, "$1") + .trim(), + ) + .filter((value) => value.length > 0); + + if (handles.length > 1 && /^handles?$/i.test(handles[0])) { + handles.shift(); + } + + if (handles.length === 0) { + throw new BadRequestException( + "Uploaded file does not contain any handles.", + ); + } + + return handles; + } +} diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 55dd567..0811631 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -1,10 +1,15 @@ import { ChallengeStatus } from "./challenges/dtos/challenge-status.enum"; -export type ReportGroupKey = "challenges" | "sfdc" | "statistics" | "topcoder"; +export type ReportGroupKey = + | "challenges" + | "sfdc" + | "statistics" + | "topcoder" + | "identity"; -type HttpMethod = "GET"; +type HttpMethod = "GET" | "POST"; -type ParameterLocation = "query" | "path"; +type ParameterLocation = "query" | "path" | "body"; type ReportParameterType = | "string" @@ -54,6 +59,19 @@ const report = ( parameters, }); +const postReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): AvailableReport => ({ + name, + path, + description, + method: "POST", + parameters, +}); + const challengeStatusParam: ReportParameter = { name: "challengeStatus", type: "enum[]", @@ -137,6 +155,14 @@ const handlesParam: ReportParameter = { location: "query", }; +const handlesBodyParam: ReportParameter = { + name: "handles", + type: "string[]", + description: "List of user handles to look up", + required: true, + location: "body", +}; + const minPaymentParam: ReportParameter = { name: "minPaymentAmount", type: "number", @@ -195,6 +221,14 @@ const registrantCountriesParam: ReportParameter = { required: true, }; +const challengeIdParam: ReportParameter = { + name: "challengeId", + type: "string", + description: "Challenge ID to retrieve report data for", + location: "path", + required: true, +}; + const challengeSubmitterDataParam: ReportParameter = { name: "challengeId", type: "string", @@ -211,6 +245,34 @@ const marathonMatchHandleParam: ReportParameter = { required: true, }; +const roleIdParam: ReportParameter = { + name: "roleId", + type: "number", + description: "Role ID", + location: "query", +}; + +const roleNameParam: ReportParameter = { + name: "roleName", + type: "string", + description: "Role name", + location: "query", +}; + +const groupIdParam: ReportParameter = { + name: "groupId", + type: "number", + description: "Group ID", + location: "query", +}; + +const groupNameParam: ReportParameter = { + name: "groupName", + type: "string", + description: "Security group description", + location: "query", +}; + export const REPORTS_DIRECTORY: ReportsDirectory = { challenges: { label: "Challenges Reports", @@ -234,6 +296,54 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { "Return the submission links report", submissionLinksFilters, ), + report( + "Challenge Registered Users", + "/challenges/:challengeId/registered-users", + "Return the challenge registered users report", + [challengeIdParam], + ), + report( + "Challenge Submitters", + "/challenges/:challengeId/submitters", + "Return the challenge submitters report", + [challengeIdParam], + ), + report( + "Challenge Valid Submitters", + "/challenges/:challengeId/valid-submitters", + "Return the challenge valid submitters report", + [challengeIdParam], + ), + report( + "Challenge Winners", + "/challenges/:challengeId/winners", + "Return the challenge winners report", + [challengeIdParam], + ), + ], + }, + identity: { + label: "Identity Reports", + basePath: "/identity", + reports: [ + report( + "Users by Role", + "/identity/users-by-role", + "Export user ID, handle, and email for all users assigned to the specified role", + [roleIdParam, roleNameParam], + ), + report( + "Users by Group", + "/identity/users-by-group", + "Export user ID, handle, and email for all users belonging to the specified security group", + [groupIdParam, groupNameParam], + ), + postReport( + "Users by Handles", + "/identity/users-by-handles", + "Export user ID, handle, email, and country for each supplied handle; unknown handles return empty fields", + [handlesBodyParam], + ), ], }, sfdc: { From 7107090b1293b467e6cc780edb8391e31ce249d8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 10:14:37 +1100 Subject: [PATCH 12/42] Fix email retrieval --- sql/reports/identity/users-by-handles.sql | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sql/reports/identity/users-by-handles.sql b/sql/reports/identity/users-by-handles.sql index 9265b68..6e547e5 100644 --- a/sql/reports/identity/users-by-handles.sql +++ b/sql/reports/identity/users-by-handles.sql @@ -17,11 +17,10 @@ LEFT JOIN identity."user" AS u ON LOWER(u.handle) = LOWER(ih.handle_input) LEFT JOIN LATERAL ( SELECT e.address - FROM identity.user_email_xref AS uex - INNER JOIN identity.email AS e - ON e.email_id = uex.email_id - WHERE uex.user_id = u.user_id - ORDER BY uex.is_primary DESC, e.primary_ind DESC, e.email_id ASC + FROM identity.email AS e + WHERE e.user_id = u.user_id + AND NULLIF(BTRIM(e.address), '') IS NOT NULL + ORDER BY COALESCE(e.primary_ind, 0) DESC, e.email_id ASC LIMIT 1 ) AS pe ON TRUE From c659c763fdf42348b38aa0a6bbdc7da303b79aa9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 10:21:50 +1100 Subject: [PATCH 13/42] Fix for groups report and ID handling --- sql/reports/identity/users-by-group.sql | 37 +++++++++++++------ .../identity/dtos/identity-users.dto.ts | 8 ++-- .../identity/identity-reports.controller.ts | 6 ++- .../identity/identity-reports.service.ts | 2 +- src/reports/report-directory.data.ts | 8 ++-- 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/sql/reports/identity/users-by-group.sql b/sql/reports/identity/users-by-group.sql index 157ac61..ce8e9f6 100644 --- a/sql/reports/identity/users-by-group.sql +++ b/sql/reports/identity/users-by-group.sql @@ -1,18 +1,33 @@ SELECT - u.user_id AS "userId", + DISTINCT u.user_id AS "userId", u.handle AS "handle", e.address AS "email" -FROM identity.security_groups sg -JOIN identity.user_group_xref ugx - ON sg.group_id = ugx.group_id -JOIN identity.security_user su - ON ugx.login_id = su.login_id +FROM groups."Group" g +JOIN groups."GroupMember" gm + ON g.id = gm."groupId" +LEFT JOIN groups."User" gu + ON gu.id = gm."memberId" JOIN identity."user" u - ON su.user_id ~ '^[0-9]+$' - AND su.user_id::numeric = u.user_id + ON u.user_id::text = ( + CASE + WHEN gm."memberId" ~ '^[0-9]+$' THEN gm."memberId" + WHEN gu."universalUID" ~ '^[0-9]+$' THEN gu."universalUID" + ELSE NULL + END + ) LEFT JOIN identity.email e ON e.user_id = u.user_id AND e.primary_ind = 1 -WHERE ($1::numeric IS NOT NULL OR $2::text IS NOT NULL) - AND ($1::numeric IS NULL OR sg.group_id = $1::numeric) - AND ($2::text IS NULL OR LOWER(sg.description) = LOWER($2::text)); +WHERE ($1::text IS NOT NULL OR $2::text IS NOT NULL) + AND gm."membershipType" = 'user' + AND g.status = 'active' + AND ( + $1::text IS NULL + OR g.id = $1::text + OR g."oldId" = $1::text + ) + AND ( + $2::text IS NULL + OR LOWER(g.name) = LOWER($2::text) + OR LOWER(COALESCE(g.description, '')) = LOWER($2::text) + ); diff --git a/src/reports/identity/dtos/identity-users.dto.ts b/src/reports/identity/dtos/identity-users.dto.ts index a92bb92..cc7ddf6 100644 --- a/src/reports/identity/dtos/identity-users.dto.ts +++ b/src/reports/identity/dtos/identity-users.dto.ts @@ -4,7 +4,6 @@ import { ArrayNotEmpty, IsArray, IsInt, - IsNumber, IsOptional, IsString, } from "class-validator"; @@ -26,14 +25,13 @@ export class UsersByRoleQueryDto { } /** - * Query filters for exporting users by security group. + * Query filters for exporting users by group. */ export class UsersByGroupQueryDto { @ApiProperty({ required: false }) @IsOptional() - @IsNumber() - @Type(() => Number) - groupId?: number; + @IsString() + groupId?: string; @ApiProperty({ required: false }) @IsOptional() diff --git a/src/reports/identity/identity-reports.controller.ts b/src/reports/identity/identity-reports.controller.ts index b29a00b..a009e76 100644 --- a/src/reports/identity/identity-reports.controller.ts +++ b/src/reports/identity/identity-reports.controller.ts @@ -61,7 +61,7 @@ export class IdentityReportsController { } /** - * Exports users matched by group ID and/or group description. + * Exports users matched by group UUID/legacy ID and/or group name. * @param query Query-string filters for group lookup. * @returns List of matching identity users. */ @@ -69,7 +69,9 @@ export class IdentityReportsController { @UseGuards(PermissionsGuard) @Scopes(AppScopes.AllReports, AppScopes.Identity.UsersByGroup) @ApiBearerAuth() - @ApiOperation({ summary: "Export users for a given group (by ID or name)" }) + @ApiOperation({ + summary: "Export users for a given group (by UUID/legacy ID or name)", + }) @ApiResponse({ status: 200, description: "Export successful." }) async getUsersByGroup(@Query() query: UsersByGroupQueryDto) { return this.service.getUsersByGroup(query); diff --git a/src/reports/identity/identity-reports.service.ts b/src/reports/identity/identity-reports.service.ts index 730ad7c..e9976b4 100644 --- a/src/reports/identity/identity-reports.service.ts +++ b/src/reports/identity/identity-reports.service.ts @@ -67,7 +67,7 @@ export class IdentityReportsService { } /** - * Queries users in a security group by group ID and/or group description. + * Queries users in a group by group UUID/legacy ID and/or group name. * @param filters Group filters from the request query string. * @returns Matching users with ID, handle, and primary email. */ diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 0811631..bac5c35 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -261,15 +261,15 @@ const roleNameParam: ReportParameter = { const groupIdParam: ReportParameter = { name: "groupId", - type: "number", - description: "Group ID", + type: "string", + description: "Group UUID or legacy numeric group ID", location: "query", }; const groupNameParam: ReportParameter = { name: "groupName", type: "string", - description: "Security group description", + description: "Group name", location: "query", }; @@ -335,7 +335,7 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { report( "Users by Group", "/identity/users-by-group", - "Export user ID, handle, and email for all users belonging to the specified security group", + "Export user ID, handle, and email for all users belonging to the specified group", [groupIdParam, groupNameParam], ), postReport( From 493481788ba48f0cdf0ca1d47b51d1bca49c638b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 10:40:27 +1100 Subject: [PATCH 14/42] Fix valid submitters query --- sql/reports/challenges/valid-submitters.sql | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index 0cb2413..a11ccb7 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -23,7 +23,18 @@ valid_submitter_members AS MATERIALIZED ( JOIN reviews."submission" AS s ON s."challengeId" = cc.id AND s."memberId" = res."memberId" - AND s."finalScore" > 98 + AND ( + -- Prefer explicit passing review summation when available (review-api v6 flow). + EXISTS ( + SELECT 1 + FROM reviews."reviewSummation" AS rs_pass + WHERE rs_pass."submissionId" = s.id + AND rs_pass."isPassing" = TRUE + AND COALESCE(rs_pass."isFinal", TRUE) = TRUE + ) + -- Keep legacy finalScore threshold fallback for older data where summations may be missing. + OR s."finalScore" > 98 + ) GROUP BY cc.id, cc.is_marathon_match, From c8f15a07a30f2e09d2205565683f67b75cafe480 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 15:41:09 +1100 Subject: [PATCH 15/42] Allow Talent Manager / Project Manager access to users-by-handle --- src/app-constants.ts | 4 ++++ src/auth/guards/permissions.guard.ts | 31 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/app-constants.ts b/src/app-constants.ts index f9c1771..1ef208f 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -38,3 +38,7 @@ export const Scopes = { export const UserRoles = { Admin: "Administrator", }; + +export const ScopeRoleAccess: Record = { + [Scopes.Identity.UsersByHandles]: ["Talent Manager", "Project Manager"], +}; diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts index 8adf313..9019dcc 100644 --- a/src/auth/guards/permissions.guard.ts +++ b/src/auth/guards/permissions.guard.ts @@ -7,13 +7,19 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { SCOPES_KEY } from "../decorators/scopes.decorator"; -import { UserRoles } from "../../app-constants"; +import { ScopeRoleAccess, UserRoles } from "../../app-constants"; @Injectable() export class PermissionsGuard implements CanActivate { private static readonly adminRoles = new Set( Object.values(UserRoles).map((role) => role.toLowerCase()), ); + private static readonly scopedRoleAccess = new Map( + Object.entries(ScopeRoleAccess).map(([scope, roles]) => [ + scope.toLowerCase(), + new Set(roles.map((role) => role.toLowerCase())), + ]), + ); constructor(private reflector: Reflector) {} @@ -44,6 +50,10 @@ export class PermissionsGuard implements CanActivate { return true; } + if (this.hasRequiredRoleAccess(roles, requiredScopes)) { + return true; + } + const scopes: string[] = authUser.scopes ?? []; if (this.hasRequiredScope(scopes, requiredScopes)) { return true; @@ -74,4 +84,23 @@ export class PermissionsGuard implements CanActivate { PermissionsGuard.adminRoles.has(role?.toLowerCase()), ); } + + private hasRequiredRoleAccess( + roles: string[], + requiredScopes: string[], + ): boolean { + if (!roles?.length || !requiredScopes?.length) { + return false; + } + + const normalizedRoles = roles.map((role) => role?.toLowerCase()); + return requiredScopes.some((scope) => { + const allowedRoles = PermissionsGuard.scopedRoleAccess.get( + scope?.toLowerCase(), + ); + return ( + !!allowedRoles && normalizedRoles.some((role) => allowedRoles.has(role)) + ); + }); + } } From 1388a4a519ffcff7aea9020773fac223f7b49ba7 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 5 Mar 2026 10:57:47 +0200 Subject: [PATCH 16/42] PM-4158 #time 30m fix user role for completed profile report --- src/app-constants.ts | 6 ++++- src/auth/guards/permissions.guard.ts | 4 ++-- src/auth/guards/topcoder-reports.guard.ts | 27 ++++++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/app-constants.ts b/src/app-constants.ts index 42777a9..e97d35a 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -26,6 +26,10 @@ export const Scopes = { }, }; -export const UserRoles = { +export const AdminRoles = { Admin: "Administrator", }; + +export const UserRoles = { + TalentManager: "Talent Manager", +}; diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts index 8adf313..83dca08 100644 --- a/src/auth/guards/permissions.guard.ts +++ b/src/auth/guards/permissions.guard.ts @@ -7,12 +7,12 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { SCOPES_KEY } from "../decorators/scopes.decorator"; -import { UserRoles } from "../../app-constants"; +import { AdminRoles } from "../../app-constants"; @Injectable() export class PermissionsGuard implements CanActivate { private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), + Object.values(AdminRoles).map((role) => role.toLowerCase()), ); constructor(private reflector: Reflector) {} diff --git a/src/auth/guards/topcoder-reports.guard.ts b/src/auth/guards/topcoder-reports.guard.ts index ba6b435..afe4cd4 100644 --- a/src/auth/guards/topcoder-reports.guard.ts +++ b/src/auth/guards/topcoder-reports.guard.ts @@ -6,13 +6,16 @@ import { UnauthorizedException, } from "@nestjs/common"; -import { Scopes, UserRoles } from "../../app-constants"; +import { Scopes, AdminRoles, UserRoles } from "../../app-constants"; @Injectable() export class TopcoderReportsGuard implements CanActivate { private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), + Object.values(AdminRoles).map((role) => role.toLowerCase()), ); + private static readonly completedProfilesRoles = new Set([ + UserRoles.TalentManager.toLowerCase(), + ]); canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); @@ -34,7 +37,10 @@ export class TopcoderReportsGuard implements CanActivate { } const roles: string[] = authUser.roles ?? []; - if (this.isAdmin(roles)) { + if ( + this.isAdmin(roles) || + this.canAccessCompletedProfiles(context, roles) + ) { return true; } @@ -56,4 +62,19 @@ export class TopcoderReportsGuard implements CanActivate { TopcoderReportsGuard.adminRoles.has(role?.toLowerCase()), ); } + + private canAccessCompletedProfiles( + context: ExecutionContext, + roles: string[], + ): boolean { + const handlerName = context.getHandler().name; + + if (handlerName !== "getCompletedProfiles") { + return false; + } + + return roles.some((role) => + TopcoderReportsGuard.completedProfilesRoles.has(role?.toLowerCase()), + ); + } } From f1821d330daae7202ff88e5d6ab38737f3a76f20 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 5 Mar 2026 13:58:17 +0200 Subject: [PATCH 17/42] PM-4198 add support for pagination for completed profiles report --- .../topcoder/completed-profiles-count.sql | 53 ++++++++++++++++++ sql/reports/topcoder/completed-profiles.sql | 55 ++++++++++++------- .../topcoder/dto/completed-profiles.dto.ts | 18 +++++- .../topcoder/topcoder-reports.controller.ts | 11 +++- .../topcoder/topcoder-reports.service.ts | 47 +++++++++++++++- 5 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 sql/reports/topcoder/completed-profiles-count.sql diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql new file mode 100644 index 0000000..30a6c9f --- /dev/null +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -0,0 +1,53 @@ +-- Count members with 100% completed profiles (optionally filtered by country) +WITH member_skills AS ( + SELECT + us.user_id, + COUNT(*) AS skill_count + FROM skills.user_skill us + GROUP BY us.user_id + HAVING COUNT(*) >= 3 +) +SELECT COUNT(*) AS total +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 EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitWork" mw ON mw."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + ) + -- AND EXISTS ( + -- SELECT 1 + -- FROM members."memberTraits" mt + -- INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + -- WHERE mt."userId" = m."userId" + -- AND mtp.key = 'openToWork' + -- AND mtp.value IS NOT NULL + -- AND ( + -- NOT (mtp.value::jsonb ? 'availability') + -- OR ( + -- mtp.value::jsonb ? 'availability' + -- AND mtp.value::jsonb ? 'preferredRoles' + -- AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + -- ) + -- ) + -- ) + AND EXISTS ( + SELECT 1 + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ); diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index abb3c43..10d1400 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -18,10 +18,25 @@ WITH member_skills AS ( SELECT m."userId" AS "userId", m.handle, + m."firstName" AS "firstName", + m."lastName" AS "lastName", + m."photoURL" AS "photoURL", COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", - m.country AS "countryName" + m.country AS "countryName", + ma.city, + ms.skill_count AS "skillCount" FROM members.member m INNER JOIN member_skills ms ON ms.user_id = m."userId" +LEFT JOIN LATERAL ( + SELECT + ma.city + FROM members."memberAddress" ma + WHERE ma."userId" = m."userId" + AND ma.city IS NOT NULL + AND TRIM(ma.city) <> '' + ORDER BY ma.id ASC + LIMIT 1 +) ma ON true WHERE m.description IS NOT NULL AND m.description <> '' AND m."photoURL" IS NOT NULL @@ -42,23 +57,23 @@ WHERE m.description IS NOT NULL INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id WHERE mt."userId" = m."userId" ) - -- Check engagement availability exists - AND EXISTS ( - SELECT 1 - FROM members."memberTraits" mt - INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - WHERE mt."userId" = m."userId" - AND mtp.key = 'openToWork' - AND mtp.value IS NOT NULL - AND ( - NOT (mtp.value::jsonb ? 'availability') - OR ( - mtp.value::jsonb ? 'availability' - AND mtp.value::jsonb ? 'preferredRoles' - AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 - ) - ) - ) + -- -- Check engagement availability exists + -- AND EXISTS ( + -- SELECT 1 + -- FROM members."memberTraits" mt + -- INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + -- WHERE mt."userId" = m."userId" + -- AND mtp.key = 'openToWork' + -- AND mtp.value IS NOT NULL + -- AND ( + -- NOT (mtp.value::jsonb ? 'availability') + -- OR ( + -- mtp.value::jsonb ? 'availability' + -- AND mtp.value::jsonb ? 'preferredRoles' + -- AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + -- ) + -- ) + -- ) -- Check location exists AND EXISTS ( SELECT 1 @@ -67,4 +82,6 @@ WHERE m.description IS NOT NULL AND ma.city IS NOT NULL AND TRIM(ma.city) <> '' ) -ORDER BY m.handle; +ORDER BY m.handle +LIMIT $2::int +OFFSET $3::int; diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts index e040935..32b96ce 100644 --- a/src/reports/topcoder/dto/completed-profiles.dto.ts +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsString } from "class-validator"; +import { IsNumberString, IsOptional, IsString } from "class-validator"; export class CompletedProfilesQueryDto { @ApiPropertyOptional({ @@ -9,4 +9,20 @@ export class CompletedProfilesQueryDto { @IsOptional() @IsString() countryCode?: string; + + @ApiPropertyOptional({ + description: "Page number (1-based)", + example: "1", + }) + @IsOptional() + @IsNumberString() + page?: string; + + @ApiPropertyOptional({ + description: "Number of records per page", + example: "50", + }) + @IsOptional() + @IsNumberString() + perPage?: string; } diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index cd3edfd..310dd49 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -258,7 +258,14 @@ export class TopcoderReportsController { summary: "List of members with 100% completed profiles", }) getCompletedProfiles(@Query() query: CompletedProfilesQueryDto) { - const { countryCode } = query; - return this.reports.getCompletedProfiles(countryCode); + const { countryCode, page, perPage } = query; + const parsedPage = Math.max(Number(page || 1), 1); + const parsedPerPage = Math.min(Math.max(Number(perPage || 50), 1), 200); + + return this.reports.getCompletedProfiles( + countryCode, + parsedPage, + parsedPerPage, + ); } } diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index c1df7da..a116762 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -89,8 +89,18 @@ type RecentMemberDataRow = { type CompletedProfileRow = { userId: string | number | null; handle: string | null; + firstName: string | null; + lastName: string | null; + photoURL: string | null; countryCode: string | null; countryName: string | null; + city: string | null; + skillCount: string | number | null; + principalSkills: string[] | null; +}; + +type CompletedProfilesCountRow = { + total: string | number | null; }; type ChallengeSubmitterDataRow = { @@ -643,18 +653,51 @@ export class TopcoderReportsService { }; } - async getCompletedProfiles(countryCode?: string) { + async getCompletedProfiles(countryCode?: string, page = 1, perPage = 50) { + const safePage = Number.isFinite(page) ? Math.max(Math.floor(page), 1) : 1; + const safePerPage = Number.isFinite(perPage) + ? Math.min(Math.max(Math.floor(perPage), 1), 200) + : 50; + const offset = (safePage - 1) * safePerPage; + + const countQuery = this.sql.load( + "reports/topcoder/completed-profiles-count.sql", + ); + const countRows = await this.db.query( + countQuery, + [countryCode || null], + ); + const total = Number(countRows?.[0]?.total ?? 0); + const query = this.sql.load("reports/topcoder/completed-profiles.sql"); const rows = await this.db.query(query, [ countryCode || null, + safePerPage, + offset, ]); - return rows.map((row) => ({ + const data = rows.map((row) => ({ userId: row.userId ? Number(row.userId) : null, handle: row.handle || "", + firstName: row.firstName || undefined, + lastName: row.lastName || undefined, + photoURL: row.photoURL || undefined, countryCode: row.countryCode || undefined, countryName: row.countryName || undefined, + city: row.city || undefined, + skillCount: + row.skillCount !== null && row.skillCount !== undefined + ? Number(row.skillCount) + : undefined, })); + + return { + data, + page: safePage, + perPage: safePerPage, + total, + totalPages: total > 0 ? Math.ceil(total / safePerPage) : 1, + }; } private toNullableNumberArray(value: unknown): number[] | null { From 9a0c947e5b6255f3eb1080c31e54a791b225f9dc Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 5 Mar 2026 14:15:49 +0200 Subject: [PATCH 18/42] fix query --- .../topcoder/completed-profiles-count.sql | 32 ++++++++--------- sql/reports/topcoder/completed-profiles.sql | 34 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 30a6c9f..6a0ce9c 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -28,22 +28,22 @@ WHERE m.description IS NOT NULL INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id WHERE mt."userId" = m."userId" ) - -- AND EXISTS ( - -- SELECT 1 - -- FROM members."memberTraits" mt - -- INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - -- WHERE mt."userId" = m."userId" - -- AND mtp.key = 'openToWork' - -- AND mtp.value IS NOT NULL - -- AND ( - -- NOT (mtp.value::jsonb ? 'availability') - -- OR ( - -- mtp.value::jsonb ? 'availability' - -- AND mtp.value::jsonb ? 'preferredRoles' - -- AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 - -- ) - -- ) - -- ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + AND mtp.value IS NOT NULL + AND ( + NOT (mtp.value::jsonb ? 'availability') + OR ( + mtp.value::jsonb ? 'availability' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ) + ) + ) AND EXISTS ( SELECT 1 FROM members."memberAddress" ma diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 10d1400..d85d8b3 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -57,23 +57,23 @@ WHERE m.description IS NOT NULL INNER JOIN members."memberTraitEducation" me ON me."memberTraitId" = mt.id WHERE mt."userId" = m."userId" ) - -- -- Check engagement availability exists - -- AND EXISTS ( - -- SELECT 1 - -- FROM members."memberTraits" mt - -- INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - -- WHERE mt."userId" = m."userId" - -- AND mtp.key = 'openToWork' - -- AND mtp.value IS NOT NULL - -- AND ( - -- NOT (mtp.value::jsonb ? 'availability') - -- OR ( - -- mtp.value::jsonb ? 'availability' - -- AND mtp.value::jsonb ? 'preferredRoles' - -- AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 - -- ) - -- ) - -- ) + -- Check engagement availability exists + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + AND mtp.value IS NOT NULL + AND ( + NOT (mtp.value::jsonb ? 'availability') + OR ( + mtp.value::jsonb ? 'availability' + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ) + ) + ) -- Check location exists AND EXISTS ( SELECT 1 From 24f4dc706d1517fd376239f61ee6c0271b4e3a42 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 5 Mar 2026 14:16:20 +0200 Subject: [PATCH 19/42] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5f26f74..83de99d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - PM-4158_completed-profiles-report + - PM-4198_customer-portal-completed-profiles-report-updates # Production builds are exectuted only on tagged commits to the # master branch. From 505c7064011fbefd9b977cad9a0bb2fd069dbe5b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Mar 2026 11:28:13 +1100 Subject: [PATCH 20/42] Bug fixes and better RBAC for PM / TM roles --- sql/reports/challenges/registered-users.sql | 44 +-- sql/reports/challenges/submitters.sql | 117 +++++-- sql/reports/challenges/valid-submitters.sql | 146 ++++++-- sql/reports/challenges/winners.sql | 113 ++++-- src/app-constants.ts | 23 +- src/auth/guards/permissions.guard.ts | 72 +--- src/auth/guards/topcoder-reports.guard.ts | 37 +- src/auth/permissions.util.spec.ts | 45 +++ src/auth/permissions.util.ts | 140 ++++++++ .../challenges/challenges-reports.service.ts | 60 +++- .../challenges/dtos/challenge-users.dto.ts | 5 +- src/reports/report-directory.data.spec.ts | 73 ++++ src/reports/report-directory.data.ts | 328 ++++++++++++++---- src/reports/reports.controller.ts | 28 +- 14 files changed, 917 insertions(+), 314 deletions(-) create mode 100644 src/auth/permissions.util.spec.ts create mode 100644 src/auth/permissions.util.ts create mode 100644 src/reports/report-directory.data.spec.ts diff --git a/sql/reports/challenges/registered-users.sql b/sql/reports/challenges/registered-users.sql index 599a0bc..2a82024 100644 --- a/sql/reports/challenges/registered-users.sql +++ b/sql/reports/challenges/registered-users.sql @@ -1,17 +1,10 @@ WITH challenge_context AS ( - SELECT - c.id, - ct.name AS "challengeType", - (ct.name = 'Marathon Match') AS is_marathon_match + SELECT c.id FROM challenges."Challenge" AS c - JOIN challenges."ChallengeType" AS ct - ON ct.id = c."typeId" WHERE c.id = $1::text ), registered_members AS MATERIALIZED ( SELECT - cc.id AS "challengeId", - cc.is_marathon_match, res."memberId", MAX(res."memberHandle") AS "memberHandle" FROM challenge_context AS cc @@ -20,10 +13,7 @@ registered_members AS MATERIALIZED ( JOIN resources."ResourceRole" AS rr ON rr.id = res."roleId" AND rr.name = 'Submitter' - GROUP BY - cc.id, - cc.is_marathon_match, - res."memberId" + GROUP BY res."memberId" ) SELECT COALESCE( @@ -33,22 +23,20 @@ SELECT ELSE NULL END ) AS "userId", - COALESCE(u.handle, rm."memberHandle") AS "handle", - e.address AS "email", - -- Resolve competition country first, then fall back to home country. + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + rm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, comp_id.name, home_code.name, home_id.name, - mem."competitionCountryCode", - mem."homeCountryCode" - ) AS "country", - -- Marathon Match score support is kept in schema; non-MM rows return NULL. - CASE - WHEN rm.is_marathon_match THEN mm_score."submissionScore" - ELSE NULL - END AS "submissionScore" + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS "country" FROM registered_members AS rm LEFT JOIN identity."user" AS u ON rm."memberId" ~ '^[0-9]+$' @@ -67,16 +55,6 @@ LEFT JOIN lookups."Country" AS comp_code ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") -LEFT JOIN LATERAL ( - -- For MM, use the best aggregateScore across this member's submissions. - SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" - FROM reviews."submission" AS s - JOIN reviews."reviewSummation" AS rs - ON rs."submissionId" = s.id - WHERE s."challengeId" = rm."challengeId" - AND s."memberId" = rm."memberId" -) AS mm_score ON true ORDER BY - "submissionScore" DESC NULLS LAST, "handle" ASC NULLS LAST, "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 97b1f26..a75f0f8 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -1,16 +1,44 @@ WITH challenge_context AS ( SELECT c.id, - ct.name AS "challengeType", (ct.name = 'Marathon Match') AS is_marathon_match FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" WHERE c.id = $1::text ), +submission_metrics AS ( + SELECT + s."memberId", + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS standard_score, + provisional_review.provisional_score, + final_review."aggregateScore" AS final_score_raw + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(rs."aggregateScore") AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ) AS provisional_review ON TRUE +), submitter_members AS MATERIALIZED ( SELECT - cc.id AS "challengeId", cc.is_marathon_match, res."memberId", MAX(res."memberHandle") AS "memberHandle" @@ -20,13 +48,41 @@ submitter_members AS MATERIALIZED ( JOIN resources."ResourceRole" AS rr ON rr.id = res."roleId" AND rr.name = 'Submitter' - JOIN reviews."submission" AS s - ON s."challengeId" = cc.id - AND s."memberId" = res."memberId" + JOIN submission_metrics AS smx + ON smx."memberId" = res."memberId" GROUP BY - cc.id, cc.is_marathon_match, res."memberId" +), +standard_member_scores AS ( + SELECT + sm."memberId", + ROUND(MAX(sm.standard_score)::numeric, 2) AS "submissionScore" + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_member_scores AS ( + SELECT + sm."memberId", + MAX(sm.provisional_score) AS provisional_score_raw, + MAX(sm.final_score_raw) AS final_score_raw + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_ranked_scores AS ( + SELECT + mms."memberId", + CASE + WHEN mms.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mms.provisional_score_raw::numeric, 2) + END AS "provisionalScore", + CASE + WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL + ELSE RANK() OVER ( + ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST + ) + END AS "finalRank" + FROM mm_member_scores AS mms ) SELECT COALESCE( @@ -36,22 +92,33 @@ SELECT ELSE NULL END ) AS "userId", - COALESCE(u.handle, sm."memberHandle") AS "handle", - e.address AS "email", - -- Resolve competition country first, then fall back to home country. + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + sm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, comp_id.name, home_code.name, home_id.name, - mem."competitionCountryCode", - mem."homeCountryCode" + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') ) AS "country", - -- Marathon Match detection controls whether aggregate score is emitted. + sm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN sm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", CASE - WHEN sm.is_marathon_match THEN mm_score."submissionScore" + WHEN sm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL - END AS "submissionScore" + END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END AS "finalRank" FROM submitter_members AS sm LEFT JOIN identity."user" AS u ON sm."memberId" ~ '^[0-9]+$' @@ -70,16 +137,18 @@ LEFT JOIN lookups."Country" AS comp_code ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") -LEFT JOIN LATERAL ( - -- For MM, use the best aggregateScore across this member's submissions. - SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" - FROM reviews."submission" AS s - JOIN reviews."reviewSummation" AS rs - ON rs."submissionId" = s.id - WHERE s."challengeId" = sm."challengeId" - AND s."memberId" = sm."memberId" -) AS mm_score ON true +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = sm."memberId" +LEFT JOIN mm_ranked_scores AS mrs + ON mrs."memberId" = sm."memberId" ORDER BY - "submissionScore" DESC NULLS LAST, + CASE + WHEN sm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END ASC NULLS LAST, + CASE + WHEN sm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, "handle" ASC NULLS LAST, "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index a11ccb7..aca73af 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -1,16 +1,61 @@ WITH challenge_context AS ( SELECT c.id, - ct.name AS "challengeType", (ct.name = 'Marathon Match') AS is_marathon_match FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" WHERE c.id = $1::text ), +submission_metrics AS ( + SELECT + s."memberId", + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS standard_score, + provisional_review.provisional_score, + final_review."aggregateScore" AS final_score_raw, + ( + passing_review.is_passing IS TRUE + OR COALESCE(s."finalScore"::double precision, 0) > 98 + ) AS is_valid_submission + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(rs."aggregateScore") AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ) AS provisional_review ON TRUE + LEFT JOIN LATERAL ( + SELECT TRUE AS is_passing + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isPassing" = TRUE + AND COALESCE(rs."isFinal", TRUE) = TRUE + LIMIT 1 + ) AS passing_review ON TRUE +), +valid_submission_metrics AS ( + SELECT * + FROM submission_metrics + WHERE is_valid_submission = TRUE +), valid_submitter_members AS MATERIALIZED ( SELECT - cc.id AS "challengeId", cc.is_marathon_match, res."memberId", MAX(res."memberHandle") AS "memberHandle" @@ -20,25 +65,41 @@ valid_submitter_members AS MATERIALIZED ( JOIN resources."ResourceRole" AS rr ON rr.id = res."roleId" AND rr.name = 'Submitter' - JOIN reviews."submission" AS s - ON s."challengeId" = cc.id - AND s."memberId" = res."memberId" - AND ( - -- Prefer explicit passing review summation when available (review-api v6 flow). - EXISTS ( - SELECT 1 - FROM reviews."reviewSummation" AS rs_pass - WHERE rs_pass."submissionId" = s.id - AND rs_pass."isPassing" = TRUE - AND COALESCE(rs_pass."isFinal", TRUE) = TRUE - ) - -- Keep legacy finalScore threshold fallback for older data where summations may be missing. - OR s."finalScore" > 98 - ) + JOIN valid_submission_metrics AS vsmx + ON vsmx."memberId" = res."memberId" GROUP BY - cc.id, cc.is_marathon_match, res."memberId" +), +standard_member_scores AS ( + SELECT + vsm."memberId", + ROUND(MAX(vsm.standard_score)::numeric, 2) AS "submissionScore" + FROM valid_submission_metrics AS vsm + GROUP BY vsm."memberId" +), +mm_member_scores AS ( + SELECT + vsm."memberId", + MAX(vsm.provisional_score) AS provisional_score_raw, + MAX(vsm.final_score_raw) AS final_score_raw + FROM valid_submission_metrics AS vsm + GROUP BY vsm."memberId" +), +mm_ranked_scores AS ( + SELECT + mms."memberId", + CASE + WHEN mms.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mms.provisional_score_raw::numeric, 2) + END AS "provisionalScore", + CASE + WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL + ELSE RANK() OVER ( + ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST + ) + END AS "finalRank" + FROM mm_member_scores AS mms ) SELECT COALESCE( @@ -48,22 +109,33 @@ SELECT ELSE NULL END ) AS "userId", - COALESCE(u.handle, vsm."memberHandle") AS "handle", - e.address AS "email", - -- Resolve competition country first, then fall back to home country. + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + vsm."memberHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, comp_id.name, home_code.name, home_id.name, - mem."competitionCountryCode", - mem."homeCountryCode" + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') ) AS "country", - -- Marathon Match detection controls whether aggregate score is emitted. + vsm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN vsm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", CASE - WHEN vsm.is_marathon_match THEN mm_score."submissionScore" + WHEN vsm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL - END AS "submissionScore" + END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END AS "finalRank" FROM valid_submitter_members AS vsm LEFT JOIN identity."user" AS u ON vsm."memberId" ~ '^[0-9]+$' @@ -82,16 +154,18 @@ LEFT JOIN lookups."Country" AS comp_code ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") -LEFT JOIN LATERAL ( - -- For MM, use the best aggregateScore across this member's submissions. - SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" - FROM reviews."submission" AS s - JOIN reviews."reviewSummation" AS rs - ON rs."submissionId" = s.id - WHERE s."challengeId" = vsm."challengeId" - AND s."memberId" = vsm."memberId" -) AS mm_score ON true +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = vsm."memberId" +LEFT JOIN mm_ranked_scores AS mrs + ON mrs."memberId" = vsm."memberId" ORDER BY - "submissionScore" DESC NULLS LAST, + CASE + WHEN vsm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END ASC NULLS LAST, + CASE + WHEN vsm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, "handle" ASC NULLS LAST, "userId" ASC NULLS LAST; diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index 4040fbb..bf85b1c 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -1,16 +1,44 @@ WITH challenge_context AS ( SELECT c.id, - ct.name AS "challengeType", (ct.name = 'Marathon Match') AS is_marathon_match FROM challenges."Challenge" AS c JOIN challenges."ChallengeType" AS ct ON ct.id = c."typeId" WHERE c.id = $1::text ), +submission_metrics AS ( + SELECT + s."memberId", + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS standard_score, + provisional_review.provisional_score, + final_review."aggregateScore" AS final_score_raw + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(rs."aggregateScore") AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ) AS provisional_review ON TRUE +), winner_members AS MATERIALIZED ( SELECT - cc.id AS "challengeId", cc.is_marathon_match, cw."userId"::text AS "memberId", MAX(cw.handle) AS "winnerHandle", @@ -18,10 +46,40 @@ winner_members AS MATERIALIZED ( FROM challenge_context AS cc JOIN challenges."ChallengeWinner" AS cw ON cw."challengeId" = cc.id + AND cw.type = 'PLACEMENT' GROUP BY - cc.id, cc.is_marathon_match, cw."userId" +), +standard_member_scores AS ( + SELECT + sm."memberId", + ROUND(MAX(sm.standard_score)::numeric, 2) AS "submissionScore" + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_member_scores AS ( + SELECT + sm."memberId", + MAX(sm.provisional_score) AS provisional_score_raw, + MAX(sm.final_score_raw) AS final_score_raw + FROM submission_metrics AS sm + GROUP BY sm."memberId" +), +mm_ranked_scores AS ( + SELECT + mms."memberId", + CASE + WHEN mms.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mms.provisional_score_raw::numeric, 2) + END AS "provisionalScore", + CASE + WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL + ELSE RANK() OVER ( + ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST + ) + END AS "finalRank" + FROM mm_member_scores AS mms ) SELECT COALESCE( @@ -31,22 +89,33 @@ SELECT ELSE NULL END ) AS "userId", - COALESCE(u.handle, wm."winnerHandle") AS "handle", - e.address AS "email", - -- Resolve competition country first, then fall back to home country. + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + wm."winnerHandle" + ) AS "handle", + COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, comp_id.name, home_code.name, home_id.name, - mem."competitionCountryCode", - mem."homeCountryCode" + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') ) AS "country", - -- Marathon Match detection controls whether aggregate score is emitted. + wm.is_marathon_match AS "isMarathonMatch", + CASE + WHEN wm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END AS "submissionScore", CASE - WHEN wm.is_marathon_match THEN mm_score."submissionScore" + WHEN wm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL - END AS "submissionScore" + END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN mrs."finalRank" + ELSE NULL + END AS "finalRank" FROM winner_members AS wm LEFT JOIN identity."user" AS u ON wm."memberId" ~ '^[0-9]+$' @@ -65,17 +134,19 @@ LEFT JOIN lookups."Country" AS comp_code ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") -LEFT JOIN LATERAL ( - -- For MM, use the best aggregateScore across this member's submissions. - SELECT ROUND(MAX(rs."aggregateScore")::numeric, 2) AS "submissionScore" - FROM reviews."submission" AS s - JOIN reviews."reviewSummation" AS rs - ON rs."submissionId" = s.id - WHERE s."challengeId" = wm."challengeId" - AND s."memberId" = wm."memberId" -) AS mm_score ON true +LEFT JOIN standard_member_scores AS sms + ON sms."memberId" = wm."memberId" +LEFT JOIN mm_ranked_scores AS mrs + ON mrs."memberId" = wm."memberId" ORDER BY - "submissionScore" DESC NULLS LAST, + CASE + WHEN wm.is_marathon_match THEN mrs."finalRank" + ELSE wm.placement + END ASC NULLS LAST, + CASE + WHEN wm.is_marathon_match THEN NULL + ELSE sms."submissionScore" + END DESC NULLS LAST, wm.placement ASC NULLS LAST, "handle" ASC NULLS LAST, "userId" ASC NULLS LAST; diff --git a/src/app-constants.ts b/src/app-constants.ts index 1ef208f..10836d0 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -39,6 +39,27 @@ export const UserRoles = { Admin: "Administrator", }; +export const ReportAccessRoles = { + ProductManager: "Product Manager", + ProjectManager: "Project Manager", + TalentManager: "Talent Manager", +}; + +const challengeReportAccessRoles = [ + ReportAccessRoles.ProductManager, + ReportAccessRoles.TalentManager, +] as const; + export const ScopeRoleAccess: Record = { - [Scopes.Identity.UsersByHandles]: ["Talent Manager", "Project Manager"], + [Scopes.Challenge.History]: challengeReportAccessRoles, + [Scopes.Challenge.Registrants]: challengeReportAccessRoles, + [Scopes.Challenge.SubmissionLinks]: challengeReportAccessRoles, + [Scopes.Challenge.RegisteredUsers]: challengeReportAccessRoles, + [Scopes.Challenge.Submitters]: challengeReportAccessRoles, + [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, + [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.Identity.UsersByHandles]: [ + ReportAccessRoles.TalentManager, + ReportAccessRoles.ProjectManager, + ], }; diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts index 9019dcc..a84926b 100644 --- a/src/auth/guards/permissions.guard.ts +++ b/src/auth/guards/permissions.guard.ts @@ -7,20 +7,10 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { SCOPES_KEY } from "../decorators/scopes.decorator"; -import { ScopeRoleAccess, UserRoles } from "../../app-constants"; +import { hasAccessToScopes } from "../permissions.util"; @Injectable() export class PermissionsGuard implements CanActivate { - private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), - ); - private static readonly scopedRoleAccess = new Map( - Object.entries(ScopeRoleAccess).map(([scope, roles]) => [ - scope.toLowerCase(), - new Set(roles.map((role) => role.toLowerCase())), - ]), - ); - constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { @@ -39,68 +29,12 @@ export class PermissionsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if (authUser.isMachine) { - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes, requiredScopes)) { - return true; - } - } else { - const roles: string[] = authUser.roles ?? []; - if (this.isAdmin(roles)) { - return true; - } - - if (this.hasRequiredRoleAccess(roles, requiredScopes)) { - return true; - } - - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes, requiredScopes)) { - return true; - } + if (hasAccessToScopes(authUser, requiredScopes)) { + return true; } throw new ForbiddenException( "You do not have the required permissions to access this resource.", ); } - - private hasRequiredScope( - scopes: string[], - requiredScopes: string[], - ): boolean { - if (!scopes?.length) { - return false; - } - - const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); - return requiredScopes.some((scope) => - normalizedScopes.includes(scope?.toLowerCase()), - ); - } - - private isAdmin(roles: string[]): boolean { - return roles.some((role) => - PermissionsGuard.adminRoles.has(role?.toLowerCase()), - ); - } - - private hasRequiredRoleAccess( - roles: string[], - requiredScopes: string[], - ): boolean { - if (!roles?.length || !requiredScopes?.length) { - return false; - } - - const normalizedRoles = roles.map((role) => role?.toLowerCase()); - return requiredScopes.some((scope) => { - const allowedRoles = PermissionsGuard.scopedRoleAccess.get( - scope?.toLowerCase(), - ); - return ( - !!allowedRoles && normalizedRoles.some((role) => allowedRoles.has(role)) - ); - }); - } } diff --git a/src/auth/guards/topcoder-reports.guard.ts b/src/auth/guards/topcoder-reports.guard.ts index ba6b435..0ce80cb 100644 --- a/src/auth/guards/topcoder-reports.guard.ts +++ b/src/auth/guards/topcoder-reports.guard.ts @@ -6,14 +6,11 @@ import { UnauthorizedException, } from "@nestjs/common"; -import { Scopes, UserRoles } from "../../app-constants"; +import { Scopes } from "../../app-constants"; +import { hasAccessToScopes } from "../permissions.util"; @Injectable() export class TopcoderReportsGuard implements CanActivate { - private static readonly adminRoles = new Set( - Object.values(UserRoles).map((role) => role.toLowerCase()), - ); - canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const authUser = request.authUser; @@ -22,19 +19,9 @@ export class TopcoderReportsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if (authUser.isMachine) { - const scopes: string[] = authUser.scopes ?? []; - if (this.hasRequiredScope(scopes)) { - return true; - } - - throw new ForbiddenException( - "You do not have the required permissions to access this resource.", - ); - } - - const roles: string[] = authUser.roles ?? []; - if (this.isAdmin(roles)) { + if ( + hasAccessToScopes(authUser, [Scopes.TopcoderReports, Scopes.AllReports]) + ) { return true; } @@ -42,18 +29,4 @@ export class TopcoderReportsGuard implements CanActivate { "You do not have the required permissions to access this resource.", ); } - - private hasRequiredScope(scopes: string[]): boolean { - const normalizedScopes = scopes.map((scope) => scope?.toLowerCase()); - return ( - normalizedScopes.includes(Scopes.TopcoderReports.toLowerCase()) || - normalizedScopes.includes(Scopes.AllReports.toLowerCase()) - ); - } - - private isAdmin(roles: string[]): boolean { - return roles.some((role) => - TopcoderReportsGuard.adminRoles.has(role?.toLowerCase()), - ); - } } diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts new file mode 100644 index 0000000..6a03323 --- /dev/null +++ b/src/auth/permissions.util.spec.ts @@ -0,0 +1,45 @@ +import { Scopes } from "../app-constants"; +import { hasAccessToScopes, hasRequiredRoleAccess } from "./permissions.util"; + +describe("permissions.util", () => { + it("allows topcoder-prefixed project manager roles for bulk member lookup", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Project Manager"], + }, + [Scopes.Identity.UsersByHandles], + ), + ).toBe(true); + }); + + it("allows topcoder-prefixed talent manager roles for bulk member lookup", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Identity.UsersByHandles], + ), + ).toBe(true); + }); + + it("normalizes comma-separated role claims before checking scoped access", () => { + expect( + hasRequiredRoleAccess("Topcoder Talent Manager, Topcoder User", [ + Scopes.Identity.UsersByHandles, + ]), + ).toBe(true); + }); + + it("does not promote manager roles to all-reports access", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.AllReports], + ), + ).toBe(false); + }); +}); diff --git a/src/auth/permissions.util.ts b/src/auth/permissions.util.ts new file mode 100644 index 0000000..955fabf --- /dev/null +++ b/src/auth/permissions.util.ts @@ -0,0 +1,140 @@ +import { ScopeRoleAccess, UserRoles } from "../app-constants"; + +export type AuthUserLike = { + isMachine?: boolean; + roles?: string[] | string; + role?: string[] | string; + scopes?: string[] | string; +}; + +const topcoderRolePrefixPattern = /^topcoder\s+/i; + +const adminRoles = new Set( + Object.values(UserRoles).map((role) => role.toLowerCase()), +); + +const scopedRoleAccess = new Map( + Object.entries(ScopeRoleAccess).map(([scope, roles]) => [ + scope.toLowerCase(), + new Set(roles.map((role) => role.toLowerCase())), + ]), +); + +function normalizeClaims( + values: readonly string[] | string | undefined, + separator: RegExp, +): string[] { + const normalizedValues = Array.isArray(values) + ? values + : typeof values === "string" + ? values.split(separator) + : []; + + return normalizedValues + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => !!value); +} + +function normalizeRoles( + values: readonly string[] | string | undefined, +): string[] { + return normalizeClaims(values, /,/).map((role) => + role.replace(topcoderRolePrefixPattern, ""), + ); +} + +function normalizeScopes( + values: readonly string[] | string | undefined, +): string[] { + return normalizeClaims(values, /\s+/); +} + +function getNormalizedRoles(authUser?: AuthUserLike): string[] { + return [ + ...normalizeRoles(authUser?.roles), + ...normalizeRoles(authUser?.role), + ]; +} + +/** + * Returns true when the caller has any of the required scopes. + * Scope comparisons are case-insensitive. + */ +export function hasRequiredScope( + scopes: readonly string[] | string | undefined, + requiredScopes: readonly string[] = [], +): boolean { + const normalizedScopes = new Set(normalizeScopes(scopes)); + + if (!normalizedScopes.size || !requiredScopes.length) { + return false; + } + + return requiredScopes.some((scope) => + normalizedScopes.has(scope?.toLowerCase()), + ); +} + +/** + * Returns true when the caller has one of the configured administrator roles. + */ +export function hasAdminRole( + roles: readonly string[] | string | undefined, +): boolean { + return normalizeRoles(roles).some((role) => adminRoles.has(role)); +} + +/** + * Returns true when any required scope is mapped to one of the caller's roles. + */ +export function hasRequiredRoleAccess( + roles: readonly string[] | string | undefined, + requiredScopes: readonly string[] = [], +): boolean { + const normalizedRoles = normalizeRoles(roles); + + if (!normalizedRoles.length || !requiredScopes.length) { + return false; + } + + return requiredScopes.some((scope) => { + const allowedRoles = scopedRoleAccess.get(scope?.toLowerCase()); + return ( + !!allowedRoles && normalizedRoles.some((role) => allowedRoles.has(role)) + ); + }); +} + +/** + * Evaluates report access using the same rules as the request guards: + * machines need scopes, admins get full access, and human users can also + * inherit access from role-to-scope mappings. + */ +export function hasAccessToScopes( + authUser: AuthUserLike | undefined, + requiredScopes: readonly string[] = [], +): boolean { + if (!requiredScopes.length) { + return true; + } + + if (!authUser) { + return false; + } + + if (authUser.isMachine) { + return hasRequiredScope(authUser.scopes, requiredScopes); + } + + const roles = getNormalizedRoles(authUser); + + if (hasAdminRole(roles)) { + return true; + } + + if (hasRequiredRoleAccess(roles, requiredScopes)) { + return true; + } + + return hasRequiredScope(authUser.scopes, requiredScopes); +} diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index ec41bb1..aeefc48 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -14,6 +14,10 @@ import { ChallengeUsersPathParamDto, } from "./dtos/challenge-users.dto"; +type ChallengeUserReportQueryRow = ChallengeUserRecordDto & { + isMarathonMatch?: boolean | null; +}; + @Injectable() export class ChallengesReportsService { private readonly logger = new Logger(ChallengesReportsService.name); @@ -84,7 +88,7 @@ export class ChallengesReportsService { /** * Retrieves all users registered for the specified challenge. * @param filters Path params containing challengeId. - * @returns Registered user records with handle, email, country, and MM score when applicable. + * @returns Registered user records with handle, email, and country details. * @throws Does not throw. Logs query errors and returns an empty array. */ async getRegisteredUsers( @@ -108,7 +112,7 @@ export class ChallengesReportsService { /** * Retrieves users who submitted at least one submission for the specified challenge. * @param filters Path params containing challengeId. - * @returns Submitter records with handle, email, country, and MM score when applicable. + * @returns Submitter records with core profile fields and the export-specific score columns for the challenge type. * @throws Does not throw. Logs query errors and returns an empty array. */ async getSubmitters( @@ -118,11 +122,11 @@ export class ChallengesReportsService { const query = this.sql.load("reports/challenges/submitters.sql"); try { - const results = await this.db.query(query, [ + const results = await this.db.query(query, [ filters.challengeId, ]); - return results; + return this.formatChallengeUserReport(results); } catch (e) { this.logger.error(e); return []; @@ -132,7 +136,7 @@ export class ChallengesReportsService { /** * Retrieves users with at least one passing submission for the specified challenge. * @param filters Path params containing challengeId. - * @returns Valid submitter records with handle, email, country, and MM score when applicable. + * @returns Valid submitter records with core profile fields and the export-specific score columns for the challenge type. * @throws Does not throw. Logs query errors and returns an empty array. */ async getValidSubmitters( @@ -142,11 +146,11 @@ export class ChallengesReportsService { const query = this.sql.load("reports/challenges/valid-submitters.sql"); try { - const results = await this.db.query(query, [ + const results = await this.db.query(query, [ filters.challengeId, ]); - return results; + return this.formatChallengeUserReport(results); } catch (e) { this.logger.error(e); return []; @@ -156,7 +160,7 @@ export class ChallengesReportsService { /** * Retrieves winner records for the specified challenge. * @param filters Path params containing challengeId. - * @returns Winner records with handle, email, country, and MM score when applicable. + * @returns Winner records with core profile fields and the export-specific score columns for the challenge type. * @throws Does not throw. Logs query errors and returns an empty array. */ async getWinners( @@ -166,14 +170,50 @@ export class ChallengesReportsService { const query = this.sql.load("reports/challenges/winners.sql"); try { - const results = await this.db.query(query, [ + const results = await this.db.query(query, [ filters.challengeId, ]); - return results; + return this.formatChallengeUserReport(results); } catch (e) { this.logger.error(e); return []; } } + + /** + * 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. + * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. + */ + private formatChallengeUserReport( + records: ChallengeUserReportQueryRow[], + ): ChallengeUserRecordDto[] { + if (!records.length) { + return []; + } + + const isMarathonMatch = records.some( + (record) => record.isMarathonMatch === true, + ); + + return records.map((record) => { + const normalized: ChallengeUserRecordDto = { + userId: record.userId, + handle: record.handle, + email: record.email ?? null, + country: record.country ?? null, + }; + + if (isMarathonMatch) { + normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalRank = record.finalRank ?? null; + return normalized; + } + + normalized.submissionScore = record.submissionScore ?? null; + return normalized; + }); + } } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index a881dcd..fd8426e 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -16,7 +16,8 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. - * For Marathon Match challenges, submissionScore contains the best aggregate score. + * Standard challenge submission-based reports expose submissionScore. + * Marathon Match submission-based reports expose provisionalScore and finalRank. */ export interface ChallengeUserRecordDto { userId: number; @@ -24,4 +25,6 @@ export interface ChallengeUserRecordDto { email: string | null; country: string | null; submissionScore?: number | null; + provisionalScore?: number | null; + finalRank?: number | null; } diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts new file mode 100644 index 0000000..85f0589 --- /dev/null +++ b/src/reports/report-directory.data.spec.ts @@ -0,0 +1,73 @@ +import { Scopes, UserRoles } from "../app-constants"; +import { + REPORTS_DIRECTORY, + getAccessibleReportsDirectory, +} from "./report-directory.data"; + +describe("getAccessibleReportsDirectory", () => { + it("returns the full directory for administrators", () => { + expect( + getAccessibleReportsDirectory({ + roles: [UserRoles.Admin], + }), + ).toEqual(REPORTS_DIRECTORY); + }); + + it("returns public reports plus all challenge reports for product managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: ["Product Manager"], + }); + + expect(Object.keys(directory).sort()).toEqual(["challenges", "statistics"]); + expect(directory.challenges?.reports).toHaveLength( + REPORTS_DIRECTORY.challenges?.reports.length ?? 0, + ); + expect(directory.identity).toBeUndefined(); + expect(directory.sfdc).toBeUndefined(); + expect(directory.topcoder).toBeUndefined(); + }); + + it("returns challenge reports and role-mapped identity reports for talent managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: ["Talent Manager"], + }); + + expect(Object.keys(directory).sort()).toEqual([ + "challenges", + "identity", + "statistics", + ]); + expect(directory.identity?.reports.map((report) => report.path)).toEqual([ + "/identity/users-by-handles", + ]); + }); + + it("returns bulk member lookup for topcoder project managers", () => { + const directory = getAccessibleReportsDirectory({ + roles: ["Project Manager"], + }); + + expect(directory.identity?.reports.map((report) => report.path)).toEqual([ + "/identity/users-by-handles", + ]); + }); + + it("returns only scope-matched reports plus public reports for machine tokens", () => { + const directory = getAccessibleReportsDirectory({ + isMachine: true, + scopes: [Scopes.Challenge.SubmissionLinks], + }); + + expect(directory.challenges?.reports.map((report) => report.path)).toEqual([ + "/challenges/submission-links", + ]); + expect(directory.identity).toBeUndefined(); + expect(directory.sfdc).toBeUndefined(); + expect(directory.statistics?.reports.length).toBeGreaterThan(0); + expect(directory.topcoder).toBeUndefined(); + }); + + it("returns an empty directory when no JWT user is present", () => { + expect(getAccessibleReportsDirectory()).toEqual({}); + }); +}); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index bac5c35..24ee606 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -1,3 +1,5 @@ +import { Scopes as AppScopes } from "../app-constants"; +import { AuthUserLike, hasAccessToScopes } from "../auth/permissions.util"; import { ChallengeStatus } from "./challenges/dtos/challenge-status.enum"; export type ReportGroupKey = @@ -44,34 +46,111 @@ export type ReportGroup = { reports: AvailableReport[]; }; -export type ReportsDirectory = Record; +export type ReportsDirectory = Partial>; + +type RegisteredReport = AvailableReport & { + requiredScopes: readonly string[]; +}; + +type RegisteredReportGroup = Omit & { + reports: RegisteredReport[]; +}; + +type RegisteredReportsDirectory = Record; const report = ( name: string, path: string, description: string, + requiredScopes: readonly string[] = [], parameters: ReportParameter[] = [], -): AvailableReport => ({ +): RegisteredReport => ({ name, path, description, method: "GET", parameters, + requiredScopes, }); const postReport = ( name: string, path: string, description: string, + requiredScopes: readonly string[] = [], parameters: ReportParameter[] = [], -): AvailableReport => ({ +): RegisteredReport => ({ name, path, description, method: "POST", parameters, + requiredScopes, }); +const challengeReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const identityReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const identityPostReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + postReport( + name, + path, + description, + [AppScopes.AllReports, scope], + parameters, + ); + +const sfdcReport = ( + name: string, + path: string, + description: string, + scope: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report(name, path, description, [AppScopes.AllReports, scope], parameters); + +const topcoderReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): RegisteredReport => + report( + name, + path, + description, + [AppScopes.AllReports, AppScopes.TopcoderReports], + parameters, + ); + +const publicReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): RegisteredReport => report(name, path, description, [], parameters); + const challengeStatusParam: ReportParameter = { name: "challengeStatus", type: "enum[]", @@ -273,51 +352,58 @@ const groupNameParam: ReportParameter = { location: "query", }; -export const REPORTS_DIRECTORY: ReportsDirectory = { +const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challenges: { label: "Challenges Reports", basePath: "/challenges", reports: [ - report( + challengeReport( "Challenge History", "/challenges", "Return the challenge history report", + AppScopes.Challenge.History, challengeHistoryFilters, ), - report( + challengeReport( "Challenge Registrants", "/challenges/registrants", "Return the challenge registrants history report", + AppScopes.Challenge.Registrants, challengeHistoryFilters, ), - report( + challengeReport( "Submission Links", "/challenges/submission-links", "Return the submission links report", + AppScopes.Challenge.SubmissionLinks, submissionLinksFilters, ), - report( + challengeReport( "Challenge Registered Users", "/challenges/:challengeId/registered-users", "Return the challenge registered users report", + AppScopes.Challenge.RegisteredUsers, [challengeIdParam], ), - report( + challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report", + "Return the challenge submitters report. Marathon Match exports include provisionalScore and finalRank ordered by the current effective rank.", + AppScopes.Challenge.Submitters, [challengeIdParam], ), - report( + challengeReport( "Challenge Valid Submitters", "/challenges/:challengeId/valid-submitters", - "Return the challenge valid submitters report", + "Return the challenge valid submitters report. Marathon Match exports include provisionalScore and finalRank ordered by the current effective rank.", + AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), - report( + challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and finalRank.", + AppScopes.Challenge.Winners, [challengeIdParam], ), ], @@ -326,22 +412,25 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "Identity Reports", basePath: "/identity", reports: [ - report( + identityReport( "Users by Role", "/identity/users-by-role", "Export user ID, handle, and email for all users assigned to the specified role", + AppScopes.Identity.UsersByRole, [roleIdParam, roleNameParam], ), - report( + identityReport( "Users by Group", "/identity/users-by-group", "Export user ID, handle, and email for all users belonging to the specified group", + AppScopes.Identity.UsersByGroup, [groupIdParam, groupNameParam], ), - postReport( + identityPostReport( "Users by Handles", "/identity/users-by-handles", "Export user ID, handle, email, and country for each supplied handle; unknown handles return empty fields", + AppScopes.Identity.UsersByHandles, [handlesBodyParam], ), ], @@ -350,16 +439,18 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "SFDC Reports", basePath: "/sfdc", reports: [ - report( + sfdcReport( "Payments", "/sfdc/payments", "SFDC Payments report", + AppScopes.SFDC.PaymentsReport, paymentsFilters, ), - report( + sfdcReport( "BA Fees", "/sfdc/ba-fees", "Report of BA to fee / member payment", + AppScopes.SFDC.BA, baFeesDateParams, ), ], @@ -368,162 +459,167 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "Statistics", basePath: "/statistics", reports: [ - report( + publicReport( "SRM Top Rated", "/statistics/srm/top-rated", "Highest rated SRMs (static)", ), - report( + publicReport( "SRM Country Ratings", "/statistics/srm/country-ratings", "SRM country ratings (static)", ), - report( + publicReport( "SRM Competitions Count", "/statistics/srm/competitions-count", "SRM number of competitions (static)", ), - report( + publicReport( "MM Top Rated", "/statistics/mm/top-rated", "Highest rated Marathon Matches (static)", ), - report( + publicReport( "MM Country Ratings", "/statistics/mm/country-ratings", "Marathon Match country ratings (static)", ), - report( + publicReport( "MM Top 10 Finishes", "/statistics/mm/top-10-finishes", "Marathon Match Top 10 finishes (static)", ), - report( + publicReport( "MM Competitions Count", "/statistics/mm/competitions-count", "Marathon Match number of competitions (static)", ), - report( + publicReport( "Member Count", "/statistics/general/member-count", "Total number of member records", ), - report( + publicReport( "Total Prizes", "/statistics/general/total-prizes", "Total amount of all payments", ), - report( + publicReport( "Completed Challenges", "/statistics/general/completed-challenges", "Total number of completed challenges", ), - report( + publicReport( "Countries Represented", "/statistics/general/countries-represented", "Member count by country (desc)", ), - report( + publicReport( "First Place by Country", "/statistics/general/first-place-by-country", "First place finishes by country (desc)", ), - report( + publicReport( "Copiloted Challenges", "/statistics/general/copiloted-challenges", "Copiloted challenges by member (desc)", ), - report( + publicReport( "Reviews by Member", "/statistics/general/reviews-by-member", "Review participation by member (desc)", ), - report( + publicReport( "UI Design Wins", "/statistics/design/ui-design-wins", "Design 'Challenge' wins by member (desc)", ), - report( + publicReport( "Design First2Finish Wins", "/statistics/design/f2f-wins", "Design First2Finish wins by member (desc)", ), - report( + publicReport( "LUX First Place Wins", "/statistics/design/lux-first-place-wins", "Design LUX first place wins by member (desc)", ), - report( + publicReport( "LUX Placements", "/statistics/design/lux-placements", "Design LUX placements by member (desc)", ), - report( + publicReport( "RUX Placements", "/statistics/design/rux-placements", "Design RUX placements by member (desc)", ), - report( + publicReport( "First-time Design Submitters", "/statistics/design/first-time-submitters", "First-time design submitters in last 3 months", ), - report( + publicReport( "Design Countries Represented", "/statistics/design/countries-represented", "Design submitters by country (desc)", ), - report( + publicReport( "Design First Place by Country", "/statistics/design/first-place-by-country", "Design first place finishes by country (desc)", ), - report( + publicReport( "RUX First Place Wins", "/statistics/design/rux-first-place-wins", "RUX first place design challenge wins by member (desc)", ), - report( + publicReport( "Wireframe Wins", "/statistics/design/wireframe-wins", "Design wireframe challenge wins by member (desc)", ), - report( + publicReport( "Development Challenge Wins", "/statistics/development/code-wins", "Development challenge wins by member (desc)", ), - report( + publicReport( "Development First2Finish Wins", "/statistics/development/f2f-wins", "Development First2Finish wins by member (desc)", ), - report( + publicReport( "Prototype Wins", "/statistics/development/prototype-wins", "Development prototype challenge wins by member (desc)", ), - report( + publicReport( "Development First Place Wins", "/statistics/development/first-place-wins", "Development overall wins by member (desc)", ), - report( + publicReport( "First-time Development Submitters", "/statistics/development/first-time-submitters", "First-time development submitters in last 3 months", ), - report( + publicReport( "Development Countries Represented", "/statistics/development/countries-represented", "Development submitters by country (desc)", ), - report( + publicReport( + "Development First Place by Country", + "/statistics/development/first-place-by-country", + "Development first place finishes by country (desc)", + ), + publicReport( "Development Challenges by Technology", "/statistics/development/challenges-technology", "Development challenges by standardized skill (desc)", ), - report( + publicReport( "QA Wins", "/statistics/qa/wins", "Quality Assurance challenge wins by member (desc)", @@ -534,138 +630,138 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { label: "Topcoder Reports", basePath: "/topcoder", reports: [ - report( + topcoderReport( "Member Count", "/topcoder/member-count", "Total number of active members", ), - report( + topcoderReport( "Registrant Countries", "/topcoder/registrant-countries", "Countries of all registrants for the specified challenge", [registrantCountriesParam], ), - report( + topcoderReport( "challenge_submitter_data", "/topcoder/challenge_submitter_data", "Submitter profile data for a challenge, with Marathon Match placements and scores", [challengeSubmitterDataParam], ), - report( + topcoderReport( "Marathon Match Stats", "/topcoder/mm-stats/:handle", "Marathon match performance snapshot for a specific handle", [marathonMatchHandleParam], ), - report( + topcoderReport( "Total Copilots", "/topcoder/total-copilots", "Total number of Copilots", ), - report( + topcoderReport( "Weekly Active Copilots", "/topcoder/weekly-active-copilots", "Weekly challenge and copilot counts by track for the last six months", ), - report( + topcoderReport( "Weekly Member Participation", "/topcoder/weekly-member-participation", "Weekly distinct registrants and submitters for the provided date range (defaults to last five weeks)", [paymentsStartDateParam, paymentsEndDateParam], ), - report( + topcoderReport( "Member Payment Accrual", "/topcoder/member-payment-accrual", "Member payment accruals for the provided date range (defaults to last 3 months)", [paymentsStartDateParam, paymentsEndDateParam], ), - report( + topcoderReport( "Recent Member Data", "/topcoder/recent-member-data", "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", [paymentsStartDateParam], ), - report( + topcoderReport( "90 Day Member Spend", "/topcoder/90-day-member-spend", "Total gross amount paid to members in the last 90 days", ), - report( + topcoderReport( "90 Day Members Paid", "/topcoder/90-day-members-paid", "Total number of distinct members paid in the last 90 days", ), - report( + topcoderReport( "90 Day New Members", "/topcoder/90-day-new-members", "Total number of new active members created in the last 90 days", ), - report( + topcoderReport( "90 Day Active Copilots", "/topcoder/90-day-active-copilots", "Total number of distinct copilots active in the last 90 days", ), - report( + topcoderReport( "90 Day User Login", "/topcoder/90-day-user-login", "Total number of active members who logged in during the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Volume", "/topcoder/90-day-challenge-volume", "Total number of challenges launched in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Duration", "/topcoder/90-day-challenge-duration", "Total duration and count of completed challenges in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Registrants", "/topcoder/90-day-challenge-registrants", "Distinct challenge registrants and submitters in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Submitters", "/topcoder/90-day-challenge-submitters", "Distinct challenge registrants and submitters in the last 90 days", ), - report( + topcoderReport( "90 Day Challenge Member Cost", "/topcoder/90-day-challenge-member-cost", "Member payment totals and averages for challenges completed in the last 90 days", ), - report( + topcoderReport( "90 Day Task Member Cost", "/topcoder/90-day-task-member-cost", "Member payment totals and averages for tasks completed in the last 90 days", ), - report( + topcoderReport( "90 Day Fulfillment", "/topcoder/90-day-fulfillment", "Share of challenges completed versus cancelled in the last 90 days", ), - report( + topcoderReport( "90 Day Fulfillment With Tasks", "/topcoder/90-day-fulfillment-with-tasks", "Share of challenges and tasks completed versus cancelled in the last 90 days", ), - report( + topcoderReport( "Weekly Challenge Fulfillment", "/topcoder/weekly-challenge-fulfillment", "Weekly share of challenges completed versus cancelled for the last four weeks", ), - report( + topcoderReport( "Weekly Challenge Volume", "/topcoder/weekly-challenge-volume", "Weekly challenge counts by task indicator for the last four weeks", ), - report( + topcoderReport( "90 Day Membership Participation Funnel", "/topcoder/90-day-membership-participation-funnel", "New member counts with design and development participation indicators for the last 90 days", ), - report( + topcoderReport( "Membership Participation Funnel Data", "/topcoder/membership-participation-funnel-data", "Weekly new member counts with design and development participation indicators for the last four weeks", @@ -673,3 +769,81 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { ], }, }; + +function toAvailableReport( + reportDefinition: RegisteredReport, +): AvailableReport { + return { + description: reportDefinition.description, + method: reportDefinition.method, + name: reportDefinition.name, + parameters: reportDefinition.parameters, + path: reportDefinition.path, + }; +} + +function toReportGroup(group: RegisteredReportGroup): ReportGroup { + return { + ...group, + reports: group.reports.map(toAvailableReport), + }; +} + +/** + * Lists every scope that can unlock at least one catalog entry. + * The directory endpoints use this to allow callers who can access any report. + */ +export const REPORTS_DIRECTORY_REQUIRED_SCOPES = Array.from( + new Set( + Object.values(REGISTERED_REPORTS_DIRECTORY).flatMap((group) => + group.reports.flatMap((reportDefinition) => + reportDefinition.requiredScopes.filter( + (scope) => scope !== AppScopes.AllReports, + ), + ), + ), + ), +); + +export const REPORTS_DIRECTORY: ReportsDirectory = Object.fromEntries( + Object.entries(REGISTERED_REPORTS_DIRECTORY).map(([key, group]) => [ + key, + toReportGroup(group), + ]), +) as ReportsDirectory; + +/** + * Returns the subset of the report catalog that the authenticated caller can run. + * Empty groups are omitted from the response. + */ +export function getAccessibleReportsDirectory( + authUser?: AuthUserLike, +): ReportsDirectory { + if (!authUser) { + return {}; + } + + const accessibleGroups = Object.entries(REGISTERED_REPORTS_DIRECTORY).flatMap( + ([key, group]) => { + const accessibleReports = group.reports.filter((reportDefinition) => + hasAccessToScopes(authUser, reportDefinition.requiredScopes), + ); + + if (!accessibleReports.length) { + return []; + } + + return [ + [ + key, + { + ...group, + reports: accessibleReports.map(toAvailableReport), + }, + ] as const, + ]; + }, + ); + + return Object.fromEntries(accessibleGroups) as ReportsDirectory; +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 493ec76..453d2b1 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -1,33 +1,41 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Controller, Get, Req, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { PermissionsGuard } from "src/auth/guards/permissions.guard"; import { Scopes } from "src/auth/decorators/scopes.decorator"; import { Scopes as AppScopes } from "src/app-constants"; -import { REPORTS_DIRECTORY, ReportsDirectory } from "./report-directory.data"; +import { AuthUserLike } from "src/auth/permissions.util"; +import { + REPORTS_DIRECTORY_REQUIRED_SCOPES, + ReportsDirectory, + getAccessibleReportsDirectory, +} from "./report-directory.data"; @ApiTags("Reports") @Controller() export class ReportsController { @Get() @UseGuards(PermissionsGuard) - @Scopes(AppScopes.AllReports) + @Scopes(AppScopes.AllReports, ...REPORTS_DIRECTORY_REQUIRED_SCOPES) @ApiBearerAuth() @ApiOperation({ - summary: "List available report endpoints grouped by sub-path", + summary: + "List available report endpoints grouped by sub-path and filtered by the caller's permissions", }) - getReports(): ReportsDirectory { - return REPORTS_DIRECTORY; + getReports(@Req() request: { authUser?: AuthUserLike }): ReportsDirectory { + return getAccessibleReportsDirectory(request.authUser); } @Get("/directory") @UseGuards(PermissionsGuard) - @Scopes(AppScopes.AllReports) + @Scopes(AppScopes.AllReports, ...REPORTS_DIRECTORY_REQUIRED_SCOPES) @ApiBearerAuth() @ApiOperation({ summary: - "List available report endpoints grouped by sub-path (alias for /v6/reports)", + "List available report endpoints grouped by sub-path and filtered by the caller's permissions (alias for /v6/reports)", }) - getReportsDirectory(): ReportsDirectory { - return REPORTS_DIRECTORY; + getReportsDirectory( + @Req() request: { authUser?: AuthUserLike }, + ): ReportsDirectory { + return getAccessibleReportsDirectory(request.authUser); } } From d120cfaac7c0d0c406e0826faec4ac15f470678c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Mar 2026 15:15:07 +1100 Subject: [PATCH 21/42] Fix for winner calculation in MMs --- sql/reports/challenges/winners.sql | 20 +++++--------------- src/reports/report-directory.data.ts | 2 +- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index bf85b1c..a9ae4df 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -66,19 +66,13 @@ mm_member_scores AS ( FROM submission_metrics AS sm GROUP BY sm."memberId" ), -mm_ranked_scores AS ( +mm_winner_scores AS ( SELECT mms."memberId", CASE WHEN mms.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mms.provisional_score_raw::numeric, 2) - END AS "provisionalScore", - CASE - WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL - ELSE RANK() OVER ( - ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST - ) - END AS "finalRank" + END AS "provisionalScore" FROM mm_member_scores AS mms ) SELECT @@ -113,7 +107,7 @@ SELECT ELSE NULL END AS "provisionalScore", CASE - WHEN wm.is_marathon_match THEN mrs."finalRank" + WHEN wm.is_marathon_match THEN wm.placement ELSE NULL END AS "finalRank" FROM winner_members AS wm @@ -136,17 +130,13 @@ LEFT JOIN lookups."Country" AS comp_id ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") LEFT JOIN standard_member_scores AS sms ON sms."memberId" = wm."memberId" -LEFT JOIN mm_ranked_scores AS mrs +LEFT JOIN mm_winner_scores AS mrs ON mrs."memberId" = wm."memberId" ORDER BY - CASE - WHEN wm.is_marathon_match THEN mrs."finalRank" - ELSE wm.placement - END ASC NULLS LAST, + wm.placement ASC NULLS LAST, CASE WHEN wm.is_marathon_match THEN NULL ELSE sms."submissionScore" END DESC NULLS LAST, - wm.placement ASC NULLS LAST, "handle" ASC NULLS LAST, "userId" ASC NULLS LAST; diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 24ee606..d3cb6dd 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -402,7 +402,7 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", AppScopes.Challenge.Winners, [challengeIdParam], ), From be6f298758ba82a9b3c900582028c75daf74b111 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Wed, 11 Mar 2026 18:21:56 +0100 Subject: [PATCH 22/42] PM-4288 #time 3h understanding the codebase and implementing sending data in the endpoint response --- sql/reports/topcoder/completed-profiles.sql | 10 +++++++++- src/reports/topcoder/topcoder-reports.service.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index abb3c43..9fa4d6e 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -19,7 +19,15 @@ SELECT m."userId" AS "userId", m.handle, COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", - m.country AS "countryName" + m.country AS "countryName", + ( + SELECT mtp.value + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + LIMIT 1 + ) AS "openToWork" FROM members.member m INNER JOIN member_skills ms ON ms.user_id = m."userId" WHERE m.description IS NOT NULL diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index c1df7da..e5685c2 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -91,6 +91,7 @@ type CompletedProfileRow = { handle: string | null; countryCode: string | null; countryName: string | null; + openToWork?: { availability?: string; preferredRoles?: string[] } | null; }; type ChallengeSubmitterDataRow = { @@ -654,6 +655,7 @@ export class TopcoderReportsService { handle: row.handle || "", countryCode: row.countryCode || undefined, countryName: row.countryName || undefined, + openToWork: row.openToWork ?? null, })); } From 19b2e60e4dc85a0130550262b189ae9969acdc25 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Wed, 11 Mar 2026 18:22:21 +0100 Subject: [PATCH 23/42] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5f26f74..b4441dc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - PM-4158_completed-profiles-report + - pm-4288 # Production builds are exectuted only on tagged commits to the # master branch. From c65e92ce611e2d29553585c3f91c971a63d97028 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Wed, 11 Mar 2026 20:45:34 +0100 Subject: [PATCH 24/42] PM-4288 #time 1h filter by open to work implemented --- sql/reports/topcoder/completed-profiles.sql | 48 ++++++++++--------- .../topcoder/dto/completed-profiles.dto.ts | 10 +++- .../topcoder/topcoder-reports.controller.ts | 4 +- .../topcoder/topcoder-reports.service.ts | 5 +- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 9fa4d6e..541032b 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -20,22 +20,31 @@ SELECT m.handle, COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", m.country AS "countryName", - ( - SELECT mtp.value - FROM members."memberTraits" mt - INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - WHERE mt."userId" = m."userId" - AND mtp.key = 'openToWork' - LIMIT 1 - ) AS "openToWork" + ot.open_to_work_value AS "openToWork", + ot.is_open_to_work AS "isOpenToWork" FROM members.member m INNER JOIN member_skills ms ON ms.user_id = m."userId" +LEFT JOIN LATERAL ( + SELECT + mtp.value AS open_to_work_value, + ( + mtp.value::jsonb ? 'availability' + AND btrim(mtp.value->>'availability') <> '' + ) AS is_open_to_work + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id + WHERE mt."userId" = m."userId" + AND mtp.key = 'openToWork' + ORDER BY mt."updatedAt" DESC + LIMIT 1 +) ot 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 ($2::boolean IS NULL OR ot.is_open_to_work = $2::boolean) -- Check work history exists AND EXISTS ( SELECT 1 @@ -51,21 +60,14 @@ WHERE m.description IS NOT NULL WHERE mt."userId" = m."userId" ) -- Check engagement availability exists - AND EXISTS ( - SELECT 1 - FROM members."memberTraits" mt - INNER JOIN members."memberTraitPersonalization" mtp ON mtp."memberTraitId" = mt.id - WHERE mt."userId" = m."userId" - AND mtp.key = 'openToWork' - AND mtp.value IS NOT NULL - AND ( - NOT (mtp.value::jsonb ? 'availability') - OR ( - mtp.value::jsonb ? 'availability' - AND mtp.value::jsonb ? 'preferredRoles' - AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 - ) - ) + AND ot.open_to_work_value IS NOT NULL + AND ( + NOT (ot.open_to_work_value::jsonb ? 'availability') + OR ( + ot.open_to_work_value::jsonb ? 'availability' + AND ot.open_to_work_value::jsonb ? 'preferredRoles' + AND jsonb_array_length(ot.open_to_work_value::jsonb -> 'preferredRoles') > 0 + ) ) -- Check location exists AND EXISTS ( diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts index e040935..b21c034 100644 --- a/src/reports/topcoder/dto/completed-profiles.dto.ts +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsString } from "class-validator"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; export class CompletedProfilesQueryDto { @ApiPropertyOptional({ @@ -9,4 +9,12 @@ export class CompletedProfilesQueryDto { @IsOptional() @IsString() countryCode?: string; + + @ApiPropertyOptional({ + description: "Filter to members who are currently open to work", + example: true, + }) + @IsOptional() + @IsBoolean() + openToWork?: boolean; } diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index cd3edfd..2edd2e4 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -258,7 +258,7 @@ export class TopcoderReportsController { summary: "List of members with 100% completed profiles", }) getCompletedProfiles(@Query() query: CompletedProfilesQueryDto) { - const { countryCode } = query; - return this.reports.getCompletedProfiles(countryCode); + const { countryCode, openToWork } = query; + return this.reports.getCompletedProfiles(countryCode, openToWork); } } diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index e5685c2..7210832 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -91,6 +91,7 @@ type CompletedProfileRow = { handle: string | null; countryCode: string | null; countryName: string | null; + isOpenToWork?: boolean | null; openToWork?: { availability?: string; preferredRoles?: string[] } | null; }; @@ -644,10 +645,11 @@ export class TopcoderReportsService { }; } - async getCompletedProfiles(countryCode?: string) { + async getCompletedProfiles(countryCode?: string, openToWork?: boolean) { const query = this.sql.load("reports/topcoder/completed-profiles.sql"); const rows = await this.db.query(query, [ countryCode || null, + typeof openToWork === "boolean" ? openToWork : null, ]); return rows.map((row) => ({ @@ -656,6 +658,7 @@ export class TopcoderReportsService { countryCode: row.countryCode || undefined, countryName: row.countryName || undefined, openToWork: row.openToWork ?? null, + isOpenToWork: row.isOpenToWork ?? false, })); } From b3c58ae6c4fe2c8b984511eb4359bf00c63ef1c1 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Wed, 11 Mar 2026 20:59:49 +0100 Subject: [PATCH 25/42] added a transform for the boolean query param --- src/reports/topcoder/dto/completed-profiles.dto.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts index b21c034..1dbb282 100644 --- a/src/reports/topcoder/dto/completed-profiles.dto.ts +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -1,4 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; import { IsBoolean, IsOptional, IsString } from "class-validator"; export class CompletedProfilesQueryDto { @@ -15,6 +16,11 @@ export class CompletedProfilesQueryDto { example: true, }) @IsOptional() + @Transform(({ value }) => + value === undefined || value === null + ? undefined + : value === true || value === "true", + ) @IsBoolean() openToWork?: boolean; } From f320ebc5a747ed59c03419d5d840bde86319c4c7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 12 Mar 2026 10:41:57 +1100 Subject: [PATCH 26/42] Update categorization of reports --- src/reports/report-directory.data.spec.ts | 20 ++++++++++++ src/reports/report-directory.data.ts | 38 ++++++++++++++++------- src/reports/reports.controller.ts | 4 +-- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index a89113b..09742a3 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -67,6 +67,26 @@ describe("getAccessibleReportsDirectory", () => { expect(directory.topcoder).toBeUndefined(); }); + it("returns all topcoder-scoped report categories for machine tokens", () => { + const directory = getAccessibleReportsDirectory({ + isMachine: true, + scopes: [Scopes.TopcoderReports], + }); + + expect( + directory.topcoder?.reports.map((report) => report.path), + ).not.toContain("/topcoder/recent-member-data"); + expect( + directory.topcoder?.reports.map((report) => report.path), + ).not.toContain("/topcoder/member-payment-accrual"); + expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/topcoder/recent-member-data", + ]); + expect(directory.admin?.reports.map((report) => report.path)).toEqual([ + "/topcoder/member-payment-accrual", + ]); + }); + it("returns an empty directory when no JWT user is present", () => { expect(getAccessibleReportsDirectory()).toEqual({}); }); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index d3cb6dd..c11167e 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -7,6 +7,8 @@ export type ReportGroupKey = | "sfdc" | "statistics" | "topcoder" + | "member" + | "admin" | "identity"; type HttpMethod = "GET" | "POST"; @@ -669,18 +671,6 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { "Weekly distinct registrants and submitters for the provided date range (defaults to last five weeks)", [paymentsStartDateParam, paymentsEndDateParam], ), - topcoderReport( - "Member Payment Accrual", - "/topcoder/member-payment-accrual", - "Member payment accruals for the provided date range (defaults to last 3 months)", - [paymentsStartDateParam, paymentsEndDateParam], - ), - topcoderReport( - "Recent Member Data", - "/topcoder/recent-member-data", - "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", - [paymentsStartDateParam], - ), topcoderReport( "90 Day Member Spend", "/topcoder/90-day-member-spend", @@ -768,6 +758,30 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { ), ], }, + member: { + label: "Member Reports", + basePath: "/topcoder", + reports: [ + topcoderReport( + "Recent Member Data", + "/topcoder/recent-member-data", + "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", + [paymentsStartDateParam], + ), + ], + }, + admin: { + label: "Admin Reports", + basePath: "/topcoder", + reports: [ + topcoderReport( + "Member Payment Accrual", + "/topcoder/member-payment-accrual", + "Member payment accruals for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + ], + }, }; function toAvailableReport( diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 453d2b1..eb3702c 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -19,7 +19,7 @@ export class ReportsController { @ApiBearerAuth() @ApiOperation({ summary: - "List available report endpoints grouped by sub-path and filtered by the caller's permissions", + "List available report endpoints grouped by category and filtered by the caller's permissions", }) getReports(@Req() request: { authUser?: AuthUserLike }): ReportsDirectory { return getAccessibleReportsDirectory(request.authUser); @@ -31,7 +31,7 @@ export class ReportsController { @ApiBearerAuth() @ApiOperation({ summary: - "List available report endpoints grouped by sub-path and filtered by the caller's permissions (alias for /v6/reports)", + "List available report endpoints grouped by category and filtered by the caller's permissions (alias for /v6/reports)", }) getReportsDirectory( @Req() request: { authUser?: AuthUserLike }, From e5771b2790639f1d8c72b855ed0c8e518e947f0c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 12 Mar 2026 13:09:40 +1100 Subject: [PATCH 27/42] Move some reports around for new requirements --- README.md | 4 +- src/reports/report-directory.data.spec.ts | 4 +- src/reports/report-directory.data.ts | 8 +-- .../topcoder/topcoder-reports.controller.ts | 56 +++++++++---------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index d59cf23..add20e6 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ This repository houses the reports API for all Topcoder and Topgear reports on the Topcoder platform. The reports are pulled directly from live data, not a data warehouse, so they should be up-to-date when they are generated and the response is returned. Reports return JSON data by default. Endpoints that support CSV can also return -CSV when the request sets `Accept: text/csv` (including the Challenges and -Topcoder report groups). +CSV when the request sets `Accept: text/csv` (including the Challenges, +Topcoder, Member, and Admin report groups). ## Security diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 09742a3..b4d0df8 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -80,10 +80,10 @@ describe("getAccessibleReportsDirectory", () => { directory.topcoder?.reports.map((report) => report.path), ).not.toContain("/topcoder/member-payment-accrual"); expect(directory.member?.reports.map((report) => report.path)).toEqual([ - "/topcoder/recent-member-data", + "/member/recent-member-data", ]); expect(directory.admin?.reports.map((report) => report.path)).toEqual([ - "/topcoder/member-payment-accrual", + "/admin/member-payment-accrual", ]); }); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index c11167e..f02d59b 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -760,11 +760,11 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { }, member: { label: "Member Reports", - basePath: "/topcoder", + basePath: "/member", reports: [ topcoderReport( "Recent Member Data", - "/topcoder/recent-member-data", + "/member/recent-member-data", "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", [paymentsStartDateParam], ), @@ -772,11 +772,11 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { }, admin: { label: "Admin Reports", - basePath: "/topcoder", + basePath: "/admin", reports: [ topcoderReport( "Member Payment Accrual", - "/topcoder/member-payment-accrual", + "/admin/member-payment-accrual", "Member payment accruals for the provided date range (defaults to last 3 months)", [paymentsStartDateParam, paymentsEndDateParam], ), diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index cd3edfd..1e6a143 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -21,17 +21,17 @@ import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.i @ApiBearerAuth() @UseGuards(TopcoderReportsGuard) @UseInterceptors(CsvResponseInterceptor) -@Controller("/topcoder") +@Controller() export class TopcoderReportsController { constructor(private readonly reports: TopcoderReportsService) {} - @Get("/member-count") + @Get("/topcoder/member-count") @ApiOperation({ summary: "Total number of active members" }) getMemberCount() { return this.reports.getMemberCount(); } - @Get("/registrant-countries") + @Get("/topcoder/registrant-countries") @ApiOperation({ summary: "Countries of all registrants for the specified challenge", }) @@ -40,7 +40,7 @@ export class TopcoderReportsController { return this.reports.getRegistrantCountries(challengeId); } - @Get("/challenge_submitter_data") + @Get("/topcoder/challenge_submitter_data") @ApiOperation({ summary: "Submitter profile data for a challenge, with Marathon Match placements and scores", @@ -50,7 +50,7 @@ export class TopcoderReportsController { return this.reports.getChallengeSubmitterData(challengeId); } - @Get("/mm-stats/:handle") + @Get("/topcoder/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", }) @@ -58,13 +58,13 @@ export class TopcoderReportsController { return this.reports.getMarathonMatchStats(handle); } - @Get("/total-copilots") + @Get("/topcoder/total-copilots") @ApiOperation({ summary: "Total number of Copilots" }) getTotalCopilots() { return this.reports.getTotalCopilots(); } - @Get("/weekly-active-copilots") + @Get("/topcoder/weekly-active-copilots") @ApiOperation({ summary: "Weekly challenge and copilot counts by track for the last six months", @@ -73,7 +73,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyActiveCopilots(); } - @Get("/weekly-member-participation") + @Get("/topcoder/weekly-member-participation") @ApiOperation({ summary: "Weekly distinct registrants and submitters for the provided date range (defaults to last five weeks)", @@ -85,7 +85,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyMemberParticipation(startDate, endDate); } - @Get("/member-payment-accrual") + @Get("/admin/member-payment-accrual") @ApiOperation({ summary: "Member payment accruals for the provided date range (defaults to last 3 months)", @@ -95,7 +95,7 @@ export class TopcoderReportsController { return this.reports.getMemberPaymentAccrual(startDate, endDate); } - @Get("/recent-member-data") + @Get("/member/recent-member-data") @ApiOperation({ summary: "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", @@ -105,7 +105,7 @@ export class TopcoderReportsController { return this.reports.getRecentMemberData(startDate); } - @Get("/90-day-member-spend") + @Get("/topcoder/90-day-member-spend") @ApiOperation({ summary: "Total gross amount paid to members in the last 90 days", }) @@ -113,7 +113,7 @@ export class TopcoderReportsController { return this.reports.get90DayMemberSpend(); } - @Get("/90-day-members-paid") + @Get("/topcoder/90-day-members-paid") @ApiOperation({ summary: "Total number of distinct members paid in the last 90 days", }) @@ -121,7 +121,7 @@ export class TopcoderReportsController { return this.reports.get90DayMembersPaid(); } - @Get("/90-day-new-members") + @Get("/topcoder/90-day-new-members") @ApiOperation({ summary: "Total number of new active members created in the last 90 days", }) @@ -129,7 +129,7 @@ export class TopcoderReportsController { return this.reports.get90DayNewMembers(); } - @Get("/90-day-active-copilots") + @Get("/topcoder/90-day-active-copilots") @ApiOperation({ summary: "Total number of distinct copilots active in the last 90 days", }) @@ -137,7 +137,7 @@ export class TopcoderReportsController { return this.reports.get90DayActiveCopilots(); } - @Get("/90-day-user-login") + @Get("/topcoder/90-day-user-login") @ApiOperation({ summary: "Total number of active members who logged in during the last 90 days", @@ -146,7 +146,7 @@ export class TopcoderReportsController { return this.reports.get90DayUserLogin(); } - @Get("/90-day-challenge-volume") + @Get("/topcoder/90-day-challenge-volume") @ApiOperation({ summary: "Total number of challenges launched in the last 90 days", }) @@ -154,7 +154,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeVolume(); } - @Get("/90-day-challenge-duration") + @Get("/topcoder/90-day-challenge-duration") @ApiOperation({ summary: "Total duration and count of completed challenges in the last 90 days", @@ -163,7 +163,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeDuration(); } - @Get("/90-day-challenge-registrants") + @Get("/topcoder/90-day-challenge-registrants") @ApiOperation({ summary: "Distinct challenge registrants and submitters in the last 90 days", @@ -172,7 +172,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeRegistrants(); } - @Get("/90-day-challenge-submitters") + @Get("/topcoder/90-day-challenge-submitters") @ApiOperation({ summary: "Distinct challenge registrants and submitters in the last 90 days", @@ -181,7 +181,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeSubmitters(); } - @Get("/90-day-challenge-member-cost") + @Get("/topcoder/90-day-challenge-member-cost") @ApiOperation({ summary: "Member payment totals and averages for challenges completed in the last 90 days", @@ -190,7 +190,7 @@ export class TopcoderReportsController { return this.reports.get90DayChallengeMemberCost(); } - @Get("/90-day-task-member-cost") + @Get("/topcoder/90-day-task-member-cost") @ApiOperation({ summary: "Member payment totals and averages for tasks completed in the last 90 days", @@ -199,7 +199,7 @@ export class TopcoderReportsController { return this.reports.get90DayTaskMemberCost(); } - @Get("/90-day-fulfillment") + @Get("/topcoder/90-day-fulfillment") @ApiOperation({ summary: "Share of challenges completed versus cancelled in the last 90 days", @@ -208,7 +208,7 @@ export class TopcoderReportsController { return this.reports.get90DayFulfillment(); } - @Get("/90-day-fulfillment-with-tasks") + @Get("/topcoder/90-day-fulfillment-with-tasks") @ApiOperation({ summary: "Share of challenges and tasks completed versus cancelled in the last 90 days", @@ -217,7 +217,7 @@ export class TopcoderReportsController { return this.reports.get90DayFulfillmentWithTasks(); } - @Get("/weekly-challenge-fulfillment") + @Get("/topcoder/weekly-challenge-fulfillment") @ApiOperation({ summary: "Weekly share of challenges completed versus cancelled for the last four weeks", @@ -226,7 +226,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyChallengeFulfillment(); } - @Get("/weekly-challenge-volume") + @Get("/topcoder/weekly-challenge-volume") @ApiOperation({ summary: "Weekly challenge counts by task indicator for the last four weeks", @@ -235,7 +235,7 @@ export class TopcoderReportsController { return this.reports.getWeeklyChallengeVolume(); } - @Get("/90-day-membership-participation-funnel") + @Get("/topcoder/90-day-membership-participation-funnel") @ApiOperation({ summary: "New member counts with design and development participation indicators for the last 90 days", @@ -244,7 +244,7 @@ export class TopcoderReportsController { return this.reports.get90DayMembershipParticipationFunnel(); } - @Get("/membership-participation-funnel-data") + @Get("/topcoder/membership-participation-funnel-data") @ApiOperation({ summary: "Weekly new member counts with design and development participation indicators for the last four weeks", @@ -253,7 +253,7 @@ export class TopcoderReportsController { return this.reports.getMembershipParticipationFunnelData(); } - @Get("/completed-profiles") + @Get("/topcoder/completed-profiles") @ApiOperation({ summary: "List of members with 100% completed profiles", }) From dd31672f295b87b5befa2a9dc57e896d0144f3fe Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 12 Mar 2026 13:44:41 +1100 Subject: [PATCH 28/42] Fix for MM scoring output --- sql/reports/challenges/submitters.sql | 38 ++++++++++++------- sql/reports/challenges/valid-submitters.sql | 38 ++++++++++++------- .../challenges/dtos/challenge-users.dto.ts | 4 +- src/reports/report-directory.data.ts | 4 +- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index a75f0f8..9231bc8 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -9,7 +9,9 @@ WITH challenge_context AS ( ), submission_metrics AS ( SELECT + s.id AS submission_id, s."memberId", + COALESCE(s."submittedDate", s."createdAt") AS submission_timestamp, COALESCE( final_review."aggregateScore", s."finalScore"::double precision, @@ -31,10 +33,12 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT MAX(rs."aggregateScore") AS provisional_score + SELECT rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 ) AS provisional_review ON TRUE ), submitter_members AS MATERIALIZED ( @@ -61,28 +65,36 @@ standard_member_scores AS ( FROM submission_metrics AS sm GROUP BY sm."memberId" ), -mm_member_scores AS ( - SELECT +mm_latest_submission_scores AS ( + SELECT DISTINCT ON (sm."memberId") sm."memberId", - MAX(sm.provisional_score) AS provisional_score_raw, - MAX(sm.final_score_raw) AS final_score_raw + sm.provisional_score AS provisional_score_raw, + sm.final_score_raw, + COALESCE(sm.final_score_raw, sm.provisional_score) AS effective_score_raw, + sm.submission_timestamp FROM submission_metrics AS sm - GROUP BY sm."memberId" + ORDER BY + sm."memberId", + sm.submission_timestamp DESC NULLS LAST, + sm.submission_id DESC ), mm_ranked_scores AS ( SELECT - mms."memberId", + mlss."memberId", CASE - WHEN mms.provisional_score_raw IS NULL THEN NULL - ELSE ROUND(mms.provisional_score_raw::numeric, 2) + WHEN mlss.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", CASE - WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL - ELSE RANK() OVER ( - ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST + WHEN mlss.effective_score_raw IS NULL THEN NULL + ELSE ROW_NUMBER() OVER ( + ORDER BY + mlss.effective_score_raw DESC NULLS LAST, + mlss.submission_timestamp ASC NULLS LAST, + mlss."memberId" ASC ) END AS "finalRank" - FROM mm_member_scores AS mms + FROM mm_latest_submission_scores AS mlss ) SELECT COALESCE( diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index aca73af..c223e44 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -9,7 +9,9 @@ WITH challenge_context AS ( ), submission_metrics AS ( SELECT + s.id AS submission_id, s."memberId", + COALESCE(s."submittedDate", s."createdAt") AS submission_timestamp, COALESCE( final_review."aggregateScore", s."finalScore"::double precision, @@ -35,10 +37,12 @@ submission_metrics AS ( LIMIT 1 ) AS final_review ON TRUE LEFT JOIN LATERAL ( - SELECT MAX(rs."aggregateScore") AS provisional_score + SELECT rs."aggregateScore" AS provisional_score FROM reviews."reviewSummation" AS rs WHERE rs."submissionId" = s.id AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 ) AS provisional_review ON TRUE LEFT JOIN LATERAL ( SELECT TRUE AS is_passing @@ -78,28 +82,36 @@ standard_member_scores AS ( FROM valid_submission_metrics AS vsm GROUP BY vsm."memberId" ), -mm_member_scores AS ( - SELECT +mm_latest_submission_scores AS ( + SELECT DISTINCT ON (vsm."memberId") vsm."memberId", - MAX(vsm.provisional_score) AS provisional_score_raw, - MAX(vsm.final_score_raw) AS final_score_raw + vsm.provisional_score AS provisional_score_raw, + vsm.final_score_raw, + COALESCE(vsm.final_score_raw, vsm.provisional_score) AS effective_score_raw, + vsm.submission_timestamp FROM valid_submission_metrics AS vsm - GROUP BY vsm."memberId" + ORDER BY + vsm."memberId", + vsm.submission_timestamp DESC NULLS LAST, + vsm.submission_id DESC ), mm_ranked_scores AS ( SELECT - mms."memberId", + mlss."memberId", CASE - WHEN mms.provisional_score_raw IS NULL THEN NULL - ELSE ROUND(mms.provisional_score_raw::numeric, 2) + WHEN mlss.provisional_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", CASE - WHEN COALESCE(mms.final_score_raw, mms.provisional_score_raw) IS NULL THEN NULL - ELSE RANK() OVER ( - ORDER BY COALESCE(mms.final_score_raw, mms.provisional_score_raw) DESC NULLS LAST + WHEN mlss.effective_score_raw IS NULL THEN NULL + ELSE ROW_NUMBER() OVER ( + ORDER BY + mlss.effective_score_raw DESC NULLS LAST, + mlss.submission_timestamp ASC NULLS LAST, + mlss."memberId" ASC ) END AS "finalRank" - FROM mm_member_scores AS mms + FROM mm_latest_submission_scores AS mlss ) SELECT COALESCE( diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index fd8426e..33bf7b3 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,7 +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 and finalRank. + * Marathon Match submission-based reports expose provisionalScore from the + * latest submission and finalRank by current effective score, breaking ties by + * earlier submission time. */ export interface ChallengeUserRecordDto { userId: number; diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index f02d59b..b3af434 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -390,14 +390,14 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report. Marathon Match exports include provisionalScore and finalRank ordered by the current effective rank.", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and 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 include provisionalScore and finalRank ordered by the current effective rank.", + "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.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), From 7969839f3bddf81f9ae4237a397c24063cae5ac2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 12 Mar 2026 15:58:55 +1100 Subject: [PATCH 29/42] Talent Manager permission updates --- src/app-constants.ts | 4 + .../guards/topcoder-reports.guard.spec.ts | 98 +++++++++++++++++++ src/auth/guards/topcoder-reports.guard.ts | 14 ++- src/auth/permissions.util.spec.ts | 11 +++ src/reports/report-directory.data.spec.ts | 6 +- src/reports/report-directory.data.ts | 7 +- .../topcoder/topcoder-reports.controller.ts | 8 ++ 7 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/auth/guards/topcoder-reports.guard.spec.ts diff --git a/src/app-constants.ts b/src/app-constants.ts index 86d5ff0..c7e1365 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -6,6 +6,9 @@ export const Scopes = { TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", TopcoderReports: "reports:topcoder", + Member: { + RecentMemberData: "reports:member-recent-member-data", + }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", TopgearChallengeStatsByUser: "reports:topgear-challenge-stats-by-user", TopgearChallengeRegistrantDetails: @@ -58,6 +61,7 @@ export const ScopeRoleAccess: Record = { [Scopes.Challenge.Submitters]: challengeReportAccessRoles, [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Identity.UsersByHandles]: [ UserRoles.TalentManager, UserRoles.ProjectManager, diff --git a/src/auth/guards/topcoder-reports.guard.spec.ts b/src/auth/guards/topcoder-reports.guard.spec.ts new file mode 100644 index 0000000..5d417bd --- /dev/null +++ b/src/auth/guards/topcoder-reports.guard.spec.ts @@ -0,0 +1,98 @@ +import { ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Scopes as AppScopes, UserRoles } from "../../app-constants"; +import { Scopes as RequiredScopes } from "../decorators/scopes.decorator"; +import { TopcoderReportsGuard } from "./topcoder-reports.guard"; + +/** + * Test controller used to attach scope metadata to representative topcoder handlers. + * This mirrors how the real controller exposes class-level and method-level scopes. + */ +@RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) +class TestTopcoderReportsController { + /** + * Represents the Recent Member 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.RecentMemberData, + ) + getRecentMemberData(): undefined { + return undefined; + } + + /** + * Represents a standard topcoder report route without extra role mappings. + * Returns no value because the handler body is not exercised in these unit tests. + */ + get90DayMemberSpend(): undefined { + return undefined; + } + + /** + * Represents the Completed Profiles route that keeps a dedicated role exception. + * Returns no value because the handler body is not exercised in these unit tests. + */ + getCompletedProfiles(): undefined { + return undefined; + } +} + +type TestHandlerName = keyof TestTopcoderReportsController; + +/** + * Builds the minimal execution context surface that the guard reads in these tests. + * @param handlerName Handler name whose metadata should be evaluated. + * @param authUser Auth user payload to expose on the mocked HTTP request. + * @returns ExecutionContext-like object suitable for TopcoderReportsGuard.canActivate. + */ +function createExecutionContext( + handlerName: TestHandlerName, + authUser: { roles?: string[]; scopes?: string[]; isMachine?: boolean }, +): ExecutionContext { + return { + getClass: () => TestTopcoderReportsController, + getHandler: () => TestTopcoderReportsController.prototype[handlerName], + switchToHttp: () => ({ + getRequest: () => ({ + authUser, + }), + }), + } as unknown as ExecutionContext; +} + +describe("TopcoderReportsGuard", () => { + const guard = new TopcoderReportsGuard(new Reflector()); + + it("allows talent managers to access recent member data via route scopes", () => { + expect( + guard.canActivate( + createExecutionContext("getRecentMemberData", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + + it("does not expand talent manager access to standard topcoder reports", () => { + expect(() => + guard.canActivate( + createExecutionContext("get90DayMemberSpend", { + roles: [UserRoles.TalentManager], + }), + ), + ).toThrow(ForbiddenException); + }); + + it("preserves the completed profiles talent manager exception", () => { + expect( + guard.canActivate( + createExecutionContext("getCompletedProfiles", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); +}); diff --git a/src/auth/guards/topcoder-reports.guard.ts b/src/auth/guards/topcoder-reports.guard.ts index 3c31da2..ddfe7f3 100644 --- a/src/auth/guards/topcoder-reports.guard.ts +++ b/src/auth/guards/topcoder-reports.guard.ts @@ -5,8 +5,10 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; import { Scopes, UserRoles } from "../../app-constants"; +import { SCOPES_KEY } from "../decorators/scopes.decorator"; import { AuthUserLike, getNormalizedRoles, @@ -18,6 +20,9 @@ export class TopcoderReportsGuard implements CanActivate { private static readonly completedProfilesRoles = new Set([ UserRoles.TalentManager.toLowerCase(), ]); + + constructor(private readonly reflector: Reflector) {} + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const authUser = request.authUser; @@ -26,9 +31,12 @@ export class TopcoderReportsGuard implements CanActivate { throw new UnauthorizedException("You are not authenticated."); } - if ( - hasAccessToScopes(authUser, [Scopes.TopcoderReports, Scopes.AllReports]) - ) { + const requiredScopes = this.reflector.getAllAndOverride( + SCOPES_KEY, + [context.getHandler(), context.getClass()], + ) ?? [Scopes.TopcoderReports, Scopes.AllReports]; + + if (hasAccessToScopes(authUser, requiredScopes)) { return true; } diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index 6a03323..e4017a8 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -24,6 +24,17 @@ describe("permissions.util", () => { ).toBe(true); }); + it("allows topcoder-prefixed talent manager roles for recent member data", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Member.RecentMemberData], + ), + ).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 b4d0df8..1ad5bc4 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -27,7 +27,7 @@ describe("getAccessibleReportsDirectory", () => { expect(directory.topcoder).toBeUndefined(); }); - it("returns challenge reports and role-mapped identity reports for talent managers", () => { + it("returns challenge, member, and role-mapped identity reports for talent managers", () => { const directory = getAccessibleReportsDirectory({ roles: [UserRoles.TalentManager], }); @@ -35,11 +35,15 @@ describe("getAccessibleReportsDirectory", () => { expect(Object.keys(directory).sort()).toEqual([ "challenges", "identity", + "member", "statistics", ]); expect(directory.identity?.reports.map((report) => report.path)).toEqual([ "/identity/users-by-handles", ]); + expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/recent-member-data", + ]); }); it("returns bulk member lookup for topcoder project managers", () => { diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index b3af434..5e91355 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -762,10 +762,15 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { label: "Member Reports", basePath: "/member", reports: [ - topcoderReport( + report( "Recent Member Data", "/member/recent-member-data", "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.RecentMemberData, + ], [paymentsStartDateParam], ), ], diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 1e6a143..ece104a 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -16,11 +16,14 @@ import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participa import { CompletedProfilesQueryDto } from "./dto/completed-profiles.dto"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; +import { Scopes as RequiredScopes } from "../../auth/decorators/scopes.decorator"; +import { Scopes as AppScopes } from "../../app-constants"; @ApiTags("Topcoder Reports") @ApiBearerAuth() @UseGuards(TopcoderReportsGuard) @UseInterceptors(CsvResponseInterceptor) +@RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) @Controller() export class TopcoderReportsController { constructor(private readonly reports: TopcoderReportsService) {} @@ -96,6 +99,11 @@ export class TopcoderReportsController { } @Get("/member/recent-member-data") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.RecentMemberData, + ) @ApiOperation({ summary: "Members who registered and were paid since the start date (defaults to Jan 1, 2024)", From c0df2f73b0de9a51cdb14f4aeb6a9400ab292401 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 12 Mar 2026 17:39:01 +0200 Subject: [PATCH 30/42] Update Trivy action version to 0.35.0 --- .github/workflows/trivy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 38d108d..9cbcf52 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@latest + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true From 48187fdd11eb804791ca55df497c8039db57429b Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 16:49:16 +0100 Subject: [PATCH 31/42] PM-4288 #time 30m tweeked the count query to support open to work filter --- sql/reports/topcoder/completed-profiles-count.sql | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 6a0ce9c..6902431 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -43,6 +43,15 @@ WHERE m.description IS NOT NULL AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 ) ) + AND ( + $2::boolean IS NULL + OR ( + ( + mtp.value::jsonb ? 'availability' + AND btrim(mtp.value->>'availability') <> '' + ) = $2::boolean + ) + ) ) AND EXISTS ( SELECT 1 From 7504b984b8903d73394c3b3abd72ff668135580c Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 16:49:57 +0100 Subject: [PATCH 32/42] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b4441dc..8b16634 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - pm-4288 + - pm-4288_1 # Production builds are exectuted only on tagged commits to the # master branch. From 649bc5b90f951f1e1b4ffcdf7ece6291cdbbd806 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 16:57:55 +0100 Subject: [PATCH 33/42] fix: added back the open to work props --- src/reports/topcoder/topcoder-reports.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index bfacded..326509e 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -97,6 +97,8 @@ type CompletedProfileRow = { city: string | null; skillCount: string | number | null; principalSkills: string[] | null; + isOpenToWork?: boolean | null; + openToWork?: { availability?: string; preferredRoles?: string[] } | null; }; type CompletedProfilesCountRow = { @@ -699,6 +701,8 @@ export class TopcoderReportsService { ? Number(row.skillCount) : undefined, principalSkills: row.principalSkills || undefined, + openToWork: row.openToWork ?? null, + isOpenToWork: row.isOpenToWork ?? false, })); return { From 60c1ff00a95e3f54c0525b1d58435fd2e00955dd Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 20:31:05 +0100 Subject: [PATCH 34/42] PM-4203 #time 3h implemented skils filter in the completed profiles API --- .circleci/config.yml | 2 +- .../topcoder/completed-profiles-count.sql | 4 +++- sql/reports/topcoder/completed-profiles.sql | 4 +++- .../topcoder/dto/completed-profiles.dto.ts | 18 ++++++++++++++++++ .../topcoder/topcoder-reports.controller.ts | 12 +++++++++++- .../topcoder/topcoder-reports.service.ts | 6 ++++++ 6 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b16634..e7ade66 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - pm-4288_1 + - pm-4203 # 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 6902431..38600e3 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -2,7 +2,8 @@ WITH member_skills AS ( SELECT us.user_id, - COUNT(*) AS skill_count + COUNT(*) AS skill_count, + ARRAY_AGG(DISTINCT us.skill_id) AS skill_ids FROM skills.user_skill us GROUP BY us.user_id HAVING COUNT(*) >= 3 @@ -16,6 +17,7 @@ WHERE m.description 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::int[] IS NULL OR ms.skill_ids && $3::int[]) AND EXISTS ( SELECT 1 FROM members."memberTraits" mt diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index bd7a23f..f00c466 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -10,7 +10,8 @@ WITH member_skills AS ( SELECT us.user_id, - COUNT(*) AS skill_count + COUNT(*) AS skill_count, + ARRAY_AGG(DISTINCT us.skill_id) AS skill_ids FROM skills.user_skill us GROUP BY us.user_id HAVING COUNT(*) >= 3 -- Filter early to reduce dataset @@ -60,6 +61,7 @@ WHERE m.description IS NOT NULL AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($2::boolean IS NULL OR ot.is_open_to_work = $2::boolean) + AND ($5::int[] IS NULL OR ms.skill_ids && $5::int[]) -- Check work history exists AND EXISTS ( SELECT 1 diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts index c4acd17..d8fff2c 100644 --- a/src/reports/topcoder/dto/completed-profiles.dto.ts +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -29,6 +29,24 @@ export class CompletedProfilesQueryDto { @IsBoolean() openToWork?: boolean; + @ApiPropertyOptional({ + name: "skillId", + description: "Filter by member skill IDs", + type: String, + isArray: true, + example: ["123", "456"], + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null) { + return undefined; + } + const values = Array.isArray(value) ? value : [value]; + return values.map((v) => String(v)).filter((v) => v.trim().length > 0); + }) + @IsNumberString({}, { each: true }) + skillId?: string[]; + @ApiPropertyOptional({ description: "Page number (1-based)", example: "1", diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 0293f7e..630ec70 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -266,15 +266,25 @@ export class TopcoderReportsController { summary: "List of members with 100% completed profiles", }) getCompletedProfiles(@Query() query: CompletedProfilesQueryDto) { - const { countryCode, page, perPage, openToWork } = query; + const { countryCode, page, perPage, openToWork, skillId } = query; const parsedPage = Math.max(Number(page || 1), 1); const parsedPerPage = Math.min(Math.max(Number(perPage || 50), 1), 200); + const rawSkillIds = Array.isArray(skillId) + ? skillId + : skillId !== undefined && skillId !== null + ? [skillId] + : []; + const skillIds = rawSkillIds + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id)); + return this.reports.getCompletedProfiles( countryCode, parsedPage, parsedPerPage, openToWork, + skillIds.length > 0 ? skillIds : undefined, ); } } diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 326509e..5c4fd2f 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -660,6 +660,7 @@ export class TopcoderReportsService { page = 1, perPage = 50, openToWork?: boolean, + skillIds?: number[], ) { const safePage = Number.isFinite(page) ? Math.max(Math.floor(page), 1) : 1; const safePerPage = Number.isFinite(perPage) @@ -667,6 +668,9 @@ export class TopcoderReportsService { : 50; const offset = (safePage - 1) * safePerPage; + const hasSkillIds = Array.isArray(skillIds) && skillIds.length > 0; + const skillIdsParam = hasSkillIds ? skillIds : null; + const countQuery = this.sql.load( "reports/topcoder/completed-profiles-count.sql", ); @@ -675,6 +679,7 @@ export class TopcoderReportsService { [ countryCode || null, typeof openToWork === "boolean" ? openToWork : null, + skillIdsParam, ], ); const total = Number(countRows?.[0]?.total ?? 0); @@ -685,6 +690,7 @@ export class TopcoderReportsService { typeof openToWork === "boolean" ? openToWork : null, safePerPage, offset, + skillIdsParam, ]); const data = rows.map((row) => ({ From e64cb12fb038f6e0b582eec139d5c91ddef110fb Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 20:55:50 +0100 Subject: [PATCH 35/42] fix: query --- sql/reports/topcoder/completed-profiles-count.sql | 2 +- sql/reports/topcoder/completed-profiles.sql | 2 +- src/reports/topcoder/dto/completed-profiles.dto.ts | 4 ++-- src/reports/topcoder/topcoder-reports.controller.ts | 4 +--- src/reports/topcoder/topcoder-reports.service.ts | 2 +- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 38600e3..acda0aa 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -17,7 +17,7 @@ WHERE m.description 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::int[] IS NULL OR ms.skill_ids && $3::int[]) + AND ($3 IS NULL OR ms.skill_ids && $3) AND EXISTS ( SELECT 1 FROM members."memberTraits" mt diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index f00c466..45856c4 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -61,7 +61,7 @@ WHERE m.description IS NOT NULL AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($2::boolean IS NULL OR ot.is_open_to_work = $2::boolean) - AND ($5::int[] IS NULL OR ms.skill_ids && $5::int[]) + AND ($5 IS NULL OR ms.skill_ids && $5) -- Check work history exists AND EXISTS ( SELECT 1 diff --git a/src/reports/topcoder/dto/completed-profiles.dto.ts b/src/reports/topcoder/dto/completed-profiles.dto.ts index d8fff2c..f0d7ed4 100644 --- a/src/reports/topcoder/dto/completed-profiles.dto.ts +++ b/src/reports/topcoder/dto/completed-profiles.dto.ts @@ -34,7 +34,7 @@ export class CompletedProfilesQueryDto { description: "Filter by member skill IDs", type: String, isArray: true, - example: ["123", "456"], + example: ["4b0f5f0a-1234-5678-9abc-def012345678"], }) @IsOptional() @Transform(({ value }) => { @@ -44,7 +44,7 @@ export class CompletedProfilesQueryDto { const values = Array.isArray(value) ? value : [value]; return values.map((v) => String(v)).filter((v) => v.trim().length > 0); }) - @IsNumberString({}, { each: true }) + @IsString({ each: true }) skillId?: string[]; @ApiPropertyOptional({ diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 630ec70..4e9797a 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -275,9 +275,7 @@ export class TopcoderReportsController { : skillId !== undefined && skillId !== null ? [skillId] : []; - const skillIds = rawSkillIds - .map((id) => Number(id)) - .filter((id) => Number.isFinite(id)); + const skillIds = rawSkillIds.filter((id) => id && id.trim().length > 0); return this.reports.getCompletedProfiles( countryCode, diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 5c4fd2f..8870a25 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -660,7 +660,7 @@ export class TopcoderReportsService { page = 1, perPage = 50, openToWork?: boolean, - skillIds?: number[], + skillIds?: string[], ) { const safePage = Number.isFinite(page) ? Math.max(Math.floor(page), 1) : 1; const safePerPage = Number.isFinite(perPage) From 28e16a898cb7da6fb818ff44a9fe164f3076bb8d Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Thu, 12 Mar 2026 21:15:30 +0100 Subject: [PATCH 36/42] fix: query --- sql/reports/topcoder/completed-profiles-count.sql | 4 ++-- sql/reports/topcoder/completed-profiles.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index acda0aa..3d9c572 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -3,7 +3,7 @@ WITH member_skills AS ( SELECT us.user_id, COUNT(*) AS skill_count, - ARRAY_AGG(DISTINCT us.skill_id) AS skill_ids + ARRAY_AGG(DISTINCT us.skill_id::uuid) AS skill_ids FROM skills.user_skill us GROUP BY us.user_id HAVING COUNT(*) >= 3 @@ -17,7 +17,7 @@ WHERE m.description 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 IS NULL OR ms.skill_ids && $3) + AND ($3::uuid[] IS NULL OR ms.skill_ids && $3::uuid[]) AND EXISTS ( SELECT 1 FROM members."memberTraits" mt diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 45856c4..df95777 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -11,7 +11,7 @@ WITH member_skills AS ( SELECT us.user_id, COUNT(*) AS skill_count, - ARRAY_AGG(DISTINCT us.skill_id) AS skill_ids + ARRAY_AGG(DISTINCT us.skill_id::uuid) AS skill_ids FROM skills.user_skill us GROUP BY us.user_id HAVING COUNT(*) >= 3 -- Filter early to reduce dataset @@ -61,7 +61,7 @@ WHERE m.description IS NOT NULL AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($2::boolean IS NULL OR ot.is_open_to_work = $2::boolean) - AND ($5 IS NULL OR ms.skill_ids && $5) + AND ($5::uuid[] IS NULL OR ms.skill_ids && $5::uuid[]) -- Check work history exists AND EXISTS ( SELECT 1 From 9118a9f2956f6bb15fbfc6627632f75f9505d191 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Fri, 13 Mar 2026 19:35:45 +0100 Subject: [PATCH 37/42] PM-4288 #time 20m send availableForGigs as part of completed profiles response --- .circleci/config.yml | 2 +- sql/reports/topcoder/completed-profiles-count.sql | 13 ++++--------- sql/reports/topcoder/completed-profiles.sql | 8 ++++++-- src/reports/topcoder/topcoder-reports.service.ts | 10 +++++++++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b16634..882b715 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - pm-4288_1 + - pm-4288_2 # 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 6902431..03024f3 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -16,6 +16,10 @@ WHERE m.description 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 ( + $2::boolean IS NULL + OR m."availableForGigs" = $2::boolean + ) AND EXISTS ( SELECT 1 FROM members."memberTraits" mt @@ -43,15 +47,6 @@ WHERE m.description IS NOT NULL AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 ) ) - AND ( - $2::boolean IS NULL - OR ( - ( - mtp.value::jsonb ? 'availability' - AND btrim(mtp.value->>'availability') <> '' - ) = $2::boolean - ) - ) ) AND EXISTS ( SELECT 1 diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index bd7a23f..861780f 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -24,7 +24,8 @@ SELECT COALESCE(m."homeCountryCode", m."competitionCountryCode") AS "countryCode", m.country AS "countryName", ot.open_to_work_value AS "openToWork", - ot.is_open_to_work AS "isOpenToWork", + m."availableForGigs" AS "isOpenToWork", + m."availableForGigs" AS "availableForGigs", ma.city, ms.skill_count AS "skillCount" FROM members.member m @@ -59,7 +60,10 @@ WHERE m.description 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 ($2::boolean IS NULL OR ot.is_open_to_work = $2::boolean) + AND ( + $2::boolean IS NULL + OR m."availableForGigs" = $2::boolean + ) -- Check work history exists AND EXISTS ( SELECT 1 diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 326509e..633a44a 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -97,6 +97,7 @@ type CompletedProfileRow = { city: string | null; skillCount: string | number | null; principalSkills: string[] | null; + availableForGigs?: boolean | null; isOpenToWork?: boolean | null; openToWork?: { availability?: string; preferredRoles?: string[] } | null; }; @@ -701,8 +702,15 @@ export class TopcoderReportsService { ? Number(row.skillCount) : undefined, principalSkills: row.principalSkills || undefined, + availableForGigs: + typeof row.availableForGigs === "boolean" + ? row.availableForGigs + : null, openToWork: row.openToWork ?? null, - isOpenToWork: row.isOpenToWork ?? false, + isOpenToWork: + typeof row.availableForGigs === "boolean" + ? row.availableForGigs + : false, })); return { From f3100b354f86002b915dc671805f0cfff50d3bb6 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Fri, 13 Mar 2026 20:06:03 +0100 Subject: [PATCH 38/42] fix: param names --- sql/reports/topcoder/completed-profiles.sql | 3 +-- src/reports/topcoder/topcoder-reports.service.ts | 9 ++------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 861780f..9e07522 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -25,7 +25,6 @@ SELECT m.country AS "countryName", ot.open_to_work_value AS "openToWork", m."availableForGigs" AS "isOpenToWork", - m."availableForGigs" AS "availableForGigs", ma.city, ms.skill_count AS "skillCount" FROM members.member m @@ -62,7 +61,7 @@ WHERE m.description IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ( $2::boolean IS NULL - OR m."availableForGigs" = $2::boolean + OR m."isOpenToWork" = $2::boolean ) -- Check work history exists AND EXISTS ( diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 633a44a..6342939 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -97,7 +97,6 @@ type CompletedProfileRow = { city: string | null; skillCount: string | number | null; principalSkills: string[] | null; - availableForGigs?: boolean | null; isOpenToWork?: boolean | null; openToWork?: { availability?: string; preferredRoles?: string[] } | null; }; @@ -702,14 +701,10 @@ export class TopcoderReportsService { ? Number(row.skillCount) : undefined, principalSkills: row.principalSkills || undefined, - availableForGigs: - typeof row.availableForGigs === "boolean" - ? row.availableForGigs - : null, openToWork: row.openToWork ?? null, isOpenToWork: - typeof row.availableForGigs === "boolean" - ? row.availableForGigs + typeof row.isOpenToWork === "boolean" + ? row.isOpenToWork : false, })); From 2193749f956d6198c0f5d879d48210588ed01be2 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Fri, 13 Mar 2026 20:17:31 +0100 Subject: [PATCH 39/42] fix: query --- sql/reports/topcoder/completed-profiles.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 9e07522..6e88500 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -61,7 +61,7 @@ WHERE m.description IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ( $2::boolean IS NULL - OR m."isOpenToWork" = $2::boolean + OR m."availableForGigs" = $2::boolean ) -- Check work history exists AND EXISTS ( From a4c60254543d8a9acf53db040ec06deb32f04af6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 15 Mar 2026 20:52:17 +1100 Subject: [PATCH 40/42] Stats updates --- sql/reports/topcoder/mm-stats.sql | 25 +++++++++++++++++++------ src/statistics/mm-data.service.ts | 28 ++++++++++++++++++++++++++++ src/statistics/srm-data.service.ts | 24 ++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/sql/reports/topcoder/mm-stats.sql b/sql/reports/topcoder/mm-stats.sql index 64f4255..c31961c 100644 --- a/sql/reports/topcoder/mm-stats.sql +++ b/sql/reports/topcoder/mm-stats.sql @@ -45,15 +45,28 @@ LEFT JOIN LATERAL ( WHERE mmr."userId" = mb."userId" ) AS max_rating ON TRUE LEFT JOIN LATERAL ( - SELECT mmar.* + SELECT COUNT(*) AS competitions + FROM members."memberStatsHistory" AS msh + WHERE msh."userId" = mb."userId" + AND msh."trackId" = 'DATA_SCIENCE' + AND msh."typeId" = 'MARATHON_MATCH' + AND msh."newRating" IS NOT NULL +) AS marathon_stats_history ON TRUE +LEFT JOIN LATERAL ( + SELECT + ms.rating, + ms."globalRank" AS rank, + ms.challenges, + ms.wins, + ms."topFiveFinishes", + ms."avgRank", + marathon_stats_history.competitions FROM members."memberStats" AS ms - JOIN members."memberDataScienceStats" AS mds - ON mds."memberStatsId" = ms.id - JOIN members."memberMarathonStats" AS mmar - ON mmar."dataScienceStatsId" = mds.id WHERE ms."userId" = mb."userId" + AND ms."trackId" = 'DATA_SCIENCE' + AND ms."typeId" = 'MARATHON_MATCH' ORDER BY - CASE WHEN ms."isPrivate" THEN 1 ELSE 0 END, + ms."isPrivate" ASC, ms."updatedAt" DESC NULLS LAST, ms.id DESC LIMIT 1 diff --git a/src/statistics/mm-data.service.ts b/src/statistics/mm-data.service.ts index 0e74d6d..9063f46 100644 --- a/src/statistics/mm-data.service.ts +++ b/src/statistics/mm-data.service.ts @@ -2,28 +2,56 @@ import { Injectable } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; +/** + * Serves Marathon Match statistics from checked-in JSON snapshots. + * + * These endpoints intentionally do not query `memberStats` or + * `memberStatsHistory` live. The files under `data/statistics/mm` are static + * report artifacts that can be refreshed independently from the API runtime. + */ @Injectable() export class MmDataService { private baseDir = path.resolve(process.cwd(), "data/statistics/mm"); + /** + * Load a Marathon Match statistics snapshot from disk. + * @param fileName snapshot file name under `data/statistics/mm` + * @returns parsed JSON payload exposed by the statistics endpoints + */ private loadJson(fileName: string): T { const fullPath = path.join(this.baseDir, fileName); const raw = fs.readFileSync(fullPath, "utf-8"); return JSON.parse(raw) as T; } + /** + * Get the highest-rated Marathon Match snapshot. + * @returns parsed contents of `highest-rated.json` + */ getTopRated() { return this.loadJson("highest-rated.json"); } + /** + * Get the Marathon Match country ratings snapshot. + * @returns parsed contents of `country-ratings.json` + */ getCountryRatings() { return this.loadJson("country-ratings.json"); } + /** + * Get the Marathon Match top-10 finishes snapshot. + * @returns parsed contents of `top-10-finishes.json` + */ getTop10Finishes() { return this.loadJson("top-10-finishes.json"); } + /** + * Get the Marathon Match competitions-count snapshot. + * @returns parsed contents of `competitions-count.json` + */ getCompetitionsCount() { return this.loadJson("competitions-count.json"); } diff --git a/src/statistics/srm-data.service.ts b/src/statistics/srm-data.service.ts index 934e951..22702cf 100644 --- a/src/statistics/srm-data.service.ts +++ b/src/statistics/srm-data.service.ts @@ -2,24 +2,48 @@ import { Injectable } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; +/** + * Serves SRM statistics from checked-in JSON snapshots. + * + * These endpoints intentionally do not query `memberStats` or + * `memberStatsHistory` live. The files under `data/statistics/srm` are static + * report artifacts that can be refreshed independently from the API runtime. + */ @Injectable() export class SrmDataService { private baseDir = path.resolve(process.cwd(), "data/statistics/srm"); + /** + * Load an SRM statistics snapshot from disk. + * @param fileName snapshot file name under `data/statistics/srm` + * @returns parsed JSON payload exposed by the statistics endpoints + */ private loadJson(fileName: string): T { const fullPath = path.join(this.baseDir, fileName); const raw = fs.readFileSync(fullPath, "utf-8"); return JSON.parse(raw) as T; } + /** + * Get the highest-rated SRM snapshot. + * @returns parsed contents of `highest-rated.json` + */ getTopRated() { return this.loadJson("highest-rated.json"); } + /** + * Get the SRM country ratings snapshot. + * @returns parsed contents of `country-ratings.json` + */ getCountryRatings() { return this.loadJson("country-ratings.json"); } + /** + * Get the SRM competitions-count snapshot. + * @returns parsed contents of `competitions-count.json` + */ getCompetitionsCount() { return this.loadJson("competitions-count.json"); } From 66dc60bb6449ec4b961b6be2179ef424761e2acf Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Tue, 17 Mar 2026 19:28:44 +0100 Subject: [PATCH 41/42] #time 45m modified the skills filter query --- sql/reports/topcoder/completed-profiles-count.sql | 2 +- sql/reports/topcoder/completed-profiles.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 3493549..97c432b 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -17,7 +17,7 @@ WHERE m.description 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[]) + AND ($3::uuid[] IS NULL OR ms.skill_ids @> $3::uuid[]) AND ( $2::boolean IS NULL OR m."availableForGigs" = $2::boolean diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index 91b03ed..dfc7191 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -60,7 +60,7 @@ WHERE m.description 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[]) + AND ($5::uuid[] IS NULL OR ms.skill_ids @> $5::uuid[]) AND ( $2::boolean IS NULL OR m."availableForGigs" = $2::boolean From 280c2fd4773a898f9dcf1dc6ab90af17b8ff47e8 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Tue, 17 Mar 2026 19:29:12 +0100 Subject: [PATCH 42/42] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e7ade66..f8b519c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - pm-1127_1 - - pm-4203 + - pm-4203_1 # Production builds are exectuted only on tagged commits to the # master branch.