diff --git a/.circleci/config.yml b/.circleci/config.yml index f8b519c..d065519 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,8 @@ workflows: only: - develop - pm-1127_1 - - pm-4203_1 + - PM-4305 + - PM-4491-fix # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/README.md b/README.md index add20e6..9f2673e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ Create a `.env` file in the root of the project. You can copy the example struct # This is used by Prisma to connect to your local PostgreSQL instance. DATABASE_URL="postgresql://user:password@localhost:5432/lookups?schema=public" +# Engagements database used by the Engagement Data member report. +ENGAGEMENTS_DB_URL="postgresql://user:password@localhost:5432/engagements" + +# The same report also reads member/profile/project data from the main +# DATABASE_URL connection, including members.member, members.memberAddress, +# members.memberPhone, identity.country, and projects.projects. + # Old tc-payments database URL (used by member-tax CSV export script) OLD_PAYMENTS_DATABASE_URL="postgresql://user:password@localhost:5432/tc_payments?schema=public" diff --git a/sql/reports/challenges/registered-users.sql b/sql/reports/challenges/registered-users.sql index 2a82024..12e7f66 100644 --- a/sql/reports/challenges/registered-users.sql +++ b/sql/reports/challenges/registered-users.sql @@ -28,6 +28,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), rm."memberHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, diff --git a/sql/reports/challenges/registrants-history.sql b/sql/reports/challenges/registrants-history.sql index f38e0ef..eb3c5ef 100644 --- a/sql/reports/challenges/registrants-history.sql +++ b/sql/reports/challenges/registrants-history.sql @@ -91,9 +91,25 @@ SELECT ELSE null END AS "challengeCompletedDate", r."registrantHandle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(sub."registrantFinalScore", sum."registrantFinalScore") AS "registrantFinalScore" FROM registrants r +LEFT JOIN identity."user" u + ON r."memberId" ~ '^[0-9]+$' + AND u.user_id = r."memberId"::numeric + +LEFT JOIN members."member" mem + ON r."memberId" ~ '^[0-9]+$' + AND mem."userId" = r."memberId"::bigint LEFT JOIN LATERAL ( SELECT MAX(cw.handle) AS "winnerHandle", diff --git a/sql/reports/challenges/submitters.sql b/sql/reports/challenges/submitters.sql index 9231bc8..3a19f08 100644 --- a/sql/reports/challenges/submitters.sql +++ b/sql/reports/challenges/submitters.sql @@ -18,7 +18,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id @@ -85,6 +88,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -110,6 +117,14 @@ SELECT sm."memberHandle" ) AS "handle", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE( comp_code.name, comp_id.name, @@ -127,6 +142,10 @@ SELECT WHEN sm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN sm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN sm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/valid-submitters.sql b/sql/reports/challenges/valid-submitters.sql index c223e44..1f4ee22 100644 --- a/sql/reports/challenges/valid-submitters.sql +++ b/sql/reports/challenges/valid-submitters.sql @@ -18,7 +18,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw, + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw, ( passing_review.is_passing IS TRUE OR COALESCE(s."finalScore"::double precision, 0) > 98 @@ -102,6 +105,10 @@ mm_ranked_scores AS ( WHEN mlss.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mlss.provisional_score_raw::numeric, 2) END AS "provisionalScore", + CASE + WHEN mlss.final_score_raw IS NULL THEN NULL + ELSE ROUND(mlss.final_score_raw::numeric, 2) + END AS "finalScore", CASE WHEN mlss.effective_score_raw IS NULL THEN NULL ELSE ROW_NUMBER() OVER ( @@ -126,6 +133,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), vsm."memberHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, @@ -144,6 +159,10 @@ SELECT WHEN vsm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN vsm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN vsm.is_marathon_match THEN mrs."finalRank" ELSE NULL diff --git a/sql/reports/challenges/winners.sql b/sql/reports/challenges/winners.sql index a9ae4df..8dc628a 100644 --- a/sql/reports/challenges/winners.sql +++ b/sql/reports/challenges/winners.sql @@ -16,7 +16,10 @@ submission_metrics AS ( s."initialScore"::double precision ) AS standard_score, provisional_review.provisional_score, - final_review."aggregateScore" AS final_score_raw + COALESCE( + final_review."aggregateScore", + s."finalScore"::double precision + ) AS final_score_raw FROM challenge_context AS cc JOIN reviews."submission" AS s ON s."challengeId" = cc.id @@ -72,7 +75,11 @@ mm_winner_scores AS ( CASE WHEN mms.provisional_score_raw IS NULL THEN NULL ELSE ROUND(mms.provisional_score_raw::numeric, 2) - END AS "provisionalScore" + END AS "provisionalScore", + CASE + WHEN mms.final_score_raw IS NULL THEN NULL + ELSE ROUND(mms.final_score_raw::numeric, 2) + END AS "finalScore" FROM mm_member_scores AS mms ) SELECT @@ -88,6 +95,14 @@ SELECT NULLIF(TRIM(mem.handle), ''), wm."winnerHandle" ) AS "handle", + COALESCE( + NULLIF(TRIM(u.first_name), ''), + NULLIF(TRIM(mem."firstName"), '') + ) AS "firstName", + COALESCE( + NULLIF(TRIM(u.last_name), ''), + NULLIF(TRIM(mem."lastName"), '') + ) AS "lastName", COALESCE(e.address, NULLIF(TRIM(mem.email), '')) AS "email", COALESCE( comp_code.name, @@ -106,6 +121,10 @@ SELECT WHEN wm.is_marathon_match THEN mrs."provisionalScore" ELSE NULL END AS "provisionalScore", + CASE + WHEN wm.is_marathon_match THEN mrs."finalScore" + ELSE NULL + END AS "finalScore", CASE WHEN wm.is_marathon_match THEN wm.placement ELSE NULL diff --git a/sql/reports/topcoder/completed-profiles-count.sql b/sql/reports/topcoder/completed-profiles-count.sql index 97c432b..453128b 100644 --- a/sql/reports/topcoder/completed-profiles-count.sql +++ b/sql/reports/topcoder/completed-profiles-count.sql @@ -13,8 +13,6 @@ FROM members.member m INNER JOIN member_skills ms ON ms.user_id = m."userId" WHERE m.description IS NOT NULL AND m.description <> '' - AND m."photoURL" IS NOT NULL - AND m."photoURL" <> '' AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($3::uuid[] IS NULL OR ms.skill_ids @> $3::uuid[]) diff --git a/sql/reports/topcoder/completed-profiles.sql b/sql/reports/topcoder/completed-profiles.sql index dfc7191..cfbe8dd 100644 --- a/sql/reports/topcoder/completed-profiles.sql +++ b/sql/reports/topcoder/completed-profiles.sql @@ -56,8 +56,6 @@ LEFT JOIN LATERAL ( ) ma ON TRUE WHERE m.description IS NOT NULL AND m.description <> '' - AND m."photoURL" IS NOT NULL - AND m."photoURL" <> '' AND m."homeCountryCode" IS NOT NULL AND ($1::text IS NULL OR COALESCE(m."homeCountryCode", m."competitionCountryCode") = $1) AND ($5::uuid[] IS NULL OR ms.skill_ids @> $5::uuid[]) diff --git a/sql/reports/topcoder/engagement-data-members.sql b/sql/reports/topcoder/engagement-data-members.sql new file mode 100644 index 0000000..84c5eaa --- /dev/null +++ b/sql/reports/topcoder/engagement-data-members.sql @@ -0,0 +1,56 @@ +SELECT + m."userId"::text AS user_id, + NULLIF(BTRIM(m.handle), '') AS handle, + NULLIF(BTRIM(m."firstName"), '') AS first_name, + NULLIF(BTRIM(m."lastName"), '') AS last_name, + NULLIF(BTRIM(m.email), '') AS email, + COALESCE(NULLIF(BTRIM(c.country_name), ''), NULLIF(BTRIM(m.country), '')) + AS country, + preferred_address.street_addr_1, + preferred_address.street_addr_2, + preferred_address.city, + preferred_address.state_code, + preferred_address.zip, + preferred_phone.phone_number +FROM members.member m +LEFT JOIN identity.country c + ON c.country_code = m."homeCountryCode" +LEFT JOIN LATERAL ( + SELECT + NULLIF(BTRIM(a."streetAddr1"), '') AS street_addr_1, + NULLIF(BTRIM(a."streetAddr2"), '') AS street_addr_2, + NULLIF(BTRIM(a.city), '') AS city, + NULLIF(BTRIM(a."stateCode"), '') AS state_code, + NULLIF(BTRIM(a.zip), '') AS zip + FROM members."memberAddress" a + WHERE a."userId" = m."userId" + ORDER BY + CASE + WHEN UPPER(a.type) = 'HOME' THEN 0 + WHEN UPPER(a.type) = 'BILLING' THEN 1 + ELSE 2 + END, + a."updatedAt" DESC NULLS LAST, + a."createdAt" DESC NULLS LAST, + a.id DESC + LIMIT 1 +) preferred_address + ON TRUE +LEFT JOIN LATERAL ( + SELECT NULLIF(BTRIM(p.number), '') AS phone_number + FROM members."memberPhone" p + WHERE p."userId" = m."userId" + AND NULLIF(BTRIM(p.number), '') IS NOT NULL + ORDER BY + CASE + WHEN POSITION('mobile' IN LOWER(p.type)) > 0 THEN 0 + ELSE 1 + END, + p."updatedAt" DESC NULLS LAST, + p."createdAt" DESC NULLS LAST, + p.id ASC + LIMIT 1 +) preferred_phone + ON TRUE +WHERE m."userId"::text = ANY($1::text[]) +ORDER BY LOWER(m.handle), m."userId"; diff --git a/sql/reports/topcoder/engagement-data-projects.sql b/sql/reports/topcoder/engagement-data-projects.sql new file mode 100644 index 0000000..76ee553 --- /dev/null +++ b/sql/reports/topcoder/engagement-data-projects.sql @@ -0,0 +1,7 @@ +SELECT + p.id::text AS project_id, + NULLIF(BTRIM(p.name), '') AS project_name +FROM projects.projects p +WHERE p.id::text = ANY($1::text[]) + AND p."deletedAt" IS NULL +ORDER BY LOWER(p.name), p.id; diff --git a/sql/reports/topcoder/engagement-data.sql b/sql/reports/topcoder/engagement-data.sql new file mode 100644 index 0000000..85f4df0 --- /dev/null +++ b/sql/reports/topcoder/engagement-data.sql @@ -0,0 +1,74 @@ +WITH assignment_members AS ( + SELECT + NULLIF(BTRIM(a."memberId"), '') AS member_id, + NULLIF(BTRIM(a."memberHandle"), '') AS fallback_handle, + NULLIF(BTRIM(e."projectId"), '') AS project_id + FROM engagements."EngagementAssignment" a + JOIN engagements."Engagement" e + ON e.id = a."engagementId" + WHERE NULLIF(BTRIM(a."memberId"), '') IS NOT NULL +), +assigned_summary AS ( + SELECT + member_id, + MAX(fallback_handle) AS fallback_handle, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT project_id), NULL) AS assigned_project_ids + FROM assignment_members + GROUP BY member_id +), +ranked_public_applications AS ( + SELECT + NULLIF(BTRIM(app."userId"), '') AS member_id, + NULLIF(BTRIM(app.handle), '') AS application_handle, + NULLIF(BTRIM(app.email), '') AS application_email, + NULLIF(BTRIM(app.address), '') AS application_address, + NULLIF(BTRIM(app."mobileNumber"), '') AS application_phone, + NULLIF(BTRIM(app.name), '') AS application_name, + ROW_NUMBER() OVER ( + PARTITION BY app."userId" + ORDER BY app."updatedAt" DESC, app."createdAt" DESC, app.id DESC + ) AS rn + FROM engagements."EngagementApplication" app + JOIN engagements."Engagement" e + ON e.id = app."engagementId" + WHERE COALESCE(e."isPrivate", false) = false + AND NULLIF(BTRIM(app."userId"), '') IS NOT NULL +), +latest_public_application AS ( + SELECT + member_id, + application_handle, + application_email, + application_address, + application_phone, + application_name + FROM ranked_public_applications + WHERE rn = 1 +), +eligible_members AS ( + SELECT member_id + FROM assigned_summary + UNION + SELECT member_id + FROM latest_public_application +) +SELECT + em.member_id, + COALESCE(assigned_summary.fallback_handle, latest_public_application.application_handle) + AS fallback_handle, + latest_public_application.application_email, + latest_public_application.application_address, + latest_public_application.application_phone, + latest_public_application.application_name, + (assigned_summary.member_id IS NOT NULL) AS has_assignment, + COALESCE(assigned_summary.assigned_project_ids, ARRAY[]::text[]) AS assigned_project_ids +FROM eligible_members em +LEFT JOIN assigned_summary + ON assigned_summary.member_id = em.member_id +LEFT JOIN latest_public_application + ON latest_public_application.member_id = em.member_id +ORDER BY COALESCE( + assigned_summary.fallback_handle, + latest_public_application.application_handle, + em.member_id +); diff --git a/src/app-constants.ts b/src/app-constants.ts index c7e1365..8ce6c89 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -7,6 +7,7 @@ export const Scopes = { AllReports: "reports:all", TopcoderReports: "reports:topcoder", Member: { + EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", }, TopgearChallengeTechnology: "reports:topgear-challenge-technology", @@ -61,6 +62,7 @@ export const ScopeRoleAccess: Record = { [Scopes.Challenge.Submitters]: challengeReportAccessRoles, [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.Member.EngagementData]: [UserRoles.TalentManager], [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Identity.UsersByHandles]: [ UserRoles.TalentManager, diff --git a/src/auth/guards/topcoder-reports.guard.spec.ts b/src/auth/guards/topcoder-reports.guard.spec.ts index 5d417bd..aa89333 100644 --- a/src/auth/guards/topcoder-reports.guard.spec.ts +++ b/src/auth/guards/topcoder-reports.guard.spec.ts @@ -10,6 +10,19 @@ import { TopcoderReportsGuard } from "./topcoder-reports.guard"; */ @RequiredScopes(AppScopes.AllReports, AppScopes.TopcoderReports) class TestTopcoderReportsController { + /** + * Represents the Engagement Data route for guard metadata tests. + * Returns no value because the handler body is not exercised in these unit tests. + */ + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ) + getEngagementData(): undefined { + return undefined; + } + /** * Represents the Recent Member Data route for guard metadata tests. * Returns no value because the handler body is not exercised in these unit tests. @@ -66,6 +79,16 @@ function createExecutionContext( describe("TopcoderReportsGuard", () => { const guard = new TopcoderReportsGuard(new Reflector()); + it("allows talent managers to access engagement data via route scopes", () => { + expect( + guard.canActivate( + createExecutionContext("getEngagementData", { + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + it("allows talent managers to access recent member data via route scopes", () => { expect( guard.canActivate( diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index e4017a8..7e32e40 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -35,6 +35,17 @@ describe("permissions.util", () => { ).toBe(true); }); + it("allows topcoder-prefixed talent manager roles for engagement data", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.Member.EngagementData], + ), + ).toBe(true); + }); + it("normalizes comma-separated role claims before checking scoped access", () => { expect( hasRequiredRoleAccess("Topcoder Talent Manager, Topcoder User", [ diff --git a/src/reports/challenges/challenge-export-sql.spec.ts b/src/reports/challenges/challenge-export-sql.spec.ts new file mode 100644 index 0000000..15b92b1 --- /dev/null +++ b/src/reports/challenges/challenge-export-sql.spec.ts @@ -0,0 +1,20 @@ +import { SqlLoaderService } from "src/common/sql-loader.service"; + +describe("Challenge export SQL", () => { + const sqlLoader = new SqlLoaderService(); + + it.each([ + "reports/challenges/submitters.sql", + "reports/challenges/valid-submitters.sql", + "reports/challenges/winners.sql", + ])( + "falls back to submission.finalScore in %s when no final review summary exists", + (sqlPath) => { + const sql = sqlLoader.load(sqlPath); + + expect(sql).toMatch( + /COALESCE\(\s*final_review\."aggregateScore",\s*s\."finalScore"::double precision\s*\)\s+AS final_score_raw/, + ); + }, + ); +}); diff --git a/src/reports/challenges/challenges-reports.service.spec.ts b/src/reports/challenges/challenges-reports.service.spec.ts new file mode 100644 index 0000000..999ac5d --- /dev/null +++ b/src/reports/challenges/challenges-reports.service.spec.ts @@ -0,0 +1,162 @@ +import { ChallengesReportsService } from "./challenges-reports.service"; + +describe("ChallengesReportsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn(), + }; + + let service: ChallengesReportsService; + + beforeEach(() => { + db.query.mockReset(); + sql.load.mockReset(); + sql.load.mockReturnValue("SELECT 1"); + service = new ChallengesReportsService(db as any, sql as any); + }); + + it("returns Marathon Match submitters with provisional and final score columns", async () => { + db.query.mockResolvedValue([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", + country: "Australia", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", + country: "China", + isMarathonMatch: true, + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + + const result = await service.getSubmitters({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/submitters.sql"); + expect(Object.keys(result[0])).toEqual([ + "userId", + "handle", + "email", + "firstName", + "lastName", + "country", + "provisionalScore", + "finalScore", + "finalRank", + ]); + expect(result).toEqual([ + { + userId: 88770025, + handle: "devtest1400", + email: "jmgasper+devtest140@gmail.com", + firstName: "Dev", + lastName: "Test", + country: "Australia", + provisionalScore: null, + finalScore: 96.42, + finalRank: 1, + }, + { + userId: 22655076, + handle: "liuliquan", + email: "sathya+1@crowdfirst.org", + firstName: "Liu", + lastName: "Liquan", + country: "China", + provisionalScore: 89.18, + finalScore: null, + finalRank: 2, + }, + ]); + }); + + it("returns Marathon Match winners with final scores when available", async () => { + db.query.mockResolvedValue([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", + country: "Afghanistan", + isMarathonMatch: true, + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + + const result = await service.getWinners({ + challengeId: "be34aea8-325f-4685-902d-0f356d5e76d0", + }); + + expect(sql.load).toHaveBeenCalledWith("reports/challenges/winners.sql"); + expect(result).toEqual([ + { + userId: 40158994, + handle: "TCConnCopilot", + email: "topcoderconnect+copilot@gmail.com", + firstName: "TC", + lastName: "Copilot", + country: "Afghanistan", + provisionalScore: null, + finalScore: 100, + finalRank: 1, + }, + ]); + }); + + it("keeps standard challenge exports on the submissionScore shape", async () => { + db.query.mockResolvedValue([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", + country: "Japan", + isMarathonMatch: false, + submissionScore: 34.34, + }, + ]); + + const result = await service.getValidSubmitters({ + challengeId: "3bb4d076-d2e7-4bd5-82ac-7eb4dd2d14a8", + }); + + expect(sql.load).toHaveBeenCalledWith( + "reports/challenges/valid-submitters.sql", + ); + expect(result).toEqual([ + { + userId: 88778748, + handle: "disnadiji", + email: "disnadiji+4@gmail.com", + firstName: "Disna", + lastName: "Diji", + country: "Japan", + submissionScore: 34.34, + }, + ]); + expect(result[0]).not.toHaveProperty("provisionalScore"); + expect(result[0]).not.toHaveProperty("finalScore"); + expect(result[0]).not.toHaveProperty("finalRank"); + }); +}); diff --git a/src/reports/challenges/challenges-reports.service.ts b/src/reports/challenges/challenges-reports.service.ts index aeefc48..a94d39a 100644 --- a/src/reports/challenges/challenges-reports.service.ts +++ b/src/reports/challenges/challenges-reports.service.ts @@ -184,7 +184,7 @@ export class ChallengesReportsService { /** * Normalizes raw challenge user report rows into the exported column shape. * @param records SQL rows for one challenge report, including the internal Marathon Match flag. - * @returns Export-ready records with either submissionScore or Marathon Match-specific columns. + * @returns Export-ready records with either submissionScore or the Marathon Match-specific score and ranking columns. * @throws Does not throw. It is used as a pure formatter inside the challenge report service methods. */ private formatChallengeUserReport( @@ -203,11 +203,14 @@ export class ChallengesReportsService { userId: record.userId, handle: record.handle, email: record.email ?? null, + firstName: record.firstName ?? null, + lastName: record.lastName ?? null, country: record.country ?? null, }; if (isMarathonMatch) { normalized.provisionalScore = record.provisionalScore ?? null; + normalized.finalScore = record.finalScore ?? null; normalized.finalRank = record.finalRank ?? null; return normalized; } diff --git a/src/reports/challenges/dtos/challenge-users.dto.ts b/src/reports/challenges/dtos/challenge-users.dto.ts index 33bf7b3..8e5dcfb 100644 --- a/src/reports/challenges/dtos/challenge-users.dto.ts +++ b/src/reports/challenges/dtos/challenge-users.dto.ts @@ -17,16 +17,19 @@ export class ChallengeUsersPathParamDto { /** * User record returned by challenge user reports including resolved country. * Standard challenge submission-based reports expose submissionScore. - * Marathon Match submission-based reports expose provisionalScore from the - * latest submission and finalRank by current effective score, breaking ties by - * earlier submission time. + * Marathon Match submission-based reports expose provisionalScore and + * finalScore from the latest submission, plus finalRank by current effective + * score, breaking ties by earlier submission time. */ export interface ChallengeUserRecordDto { userId: number; handle: string; email: string | null; + firstName: string | null; + lastName: string | null; country: string | null; submissionScore?: number | null; provisionalScore?: number | null; + finalScore?: number | null; finalRank?: number | null; } diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 1ad5bc4..cd82828 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -42,6 +42,7 @@ describe("getAccessibleReportsDirectory", () => { "/identity/users-by-handles", ]); expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/engagement-data", "/member/recent-member-data", ]); }); @@ -84,6 +85,7 @@ describe("getAccessibleReportsDirectory", () => { directory.topcoder?.reports.map((report) => report.path), ).not.toContain("/topcoder/member-payment-accrual"); expect(directory.member?.reports.map((report) => report.path)).toEqual([ + "/member/engagement-data", "/member/recent-member-data", ]); expect(directory.admin?.reports.map((report) => report.path)).toEqual([ diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 5e91355..7a3fb9e 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -390,21 +390,21 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { challengeReport( "Challenge Submitters", "/challenges/:challengeId/submitters", - "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.Submitters, [challengeIdParam], ), challengeReport( "Challenge Valid Submitters", "/challenges/:challengeId/valid-submitters", - "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and current effective rank, with earlier submission times winning score ties.", + "Return the challenge valid submitters report. Marathon Match exports use the latest submission provisionalScore and finalScore when available, plus the current effective rank, with earlier submission times winning score ties.", AppScopes.Challenge.ValidSubmitters, [challengeIdParam], ), challengeReport( "Challenge Winners", "/challenges/:challengeId/winners", - "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore and the challenge-result finalRank.", + "Return the challenge winners report with placement winners only. Marathon Match exports include provisionalScore, finalScore, and the challenge-result finalRank.", AppScopes.Challenge.Winners, [challengeIdParam], ), @@ -762,6 +762,16 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { label: "Member Reports", basePath: "/member", reports: [ + report( + "Engagement Data", + "/member/engagement-data", + "Members who have applied to public engagements or have any engagement assignments, including project names for assigned members", + [ + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ], + ), report( "Recent Member Data", "/member/recent-member-data", diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 4e9797a..dff2e30 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -113,6 +113,20 @@ export class TopcoderReportsController { return this.reports.getRecentMemberData(startDate); } + @Get("/member/engagement-data") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.Member.EngagementData, + ) + @ApiOperation({ + summary: + "Members who applied to public engagements or have engagement assignments, including project names for assigned members", + }) + getEngagementData() { + return this.reports.getEngagementData(); + } + @Get("/topcoder/90-day-member-spend") @ApiOperation({ summary: "Total gross amount paid to members in the last 90 days", diff --git a/src/reports/topcoder/topcoder-reports.service.spec.ts b/src/reports/topcoder/topcoder-reports.service.spec.ts new file mode 100644 index 0000000..b5a871c --- /dev/null +++ b/src/reports/topcoder/topcoder-reports.service.spec.ts @@ -0,0 +1,175 @@ +import { ConfigService } from "@nestjs/config"; +import { DbService } from "../../db/db.service"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { TopcoderReportsService } from "./topcoder-reports.service"; + +describe("TopcoderReportsService", () => { + let service: TopcoderReportsService; + let db: { query: jest.Mock }; + let sql: { load: jest.Mock }; + let config: { get: jest.Mock }; + let engagementsPoolQuery: jest.Mock; + + beforeEach(() => { + db = { + query: jest.fn((query: string) => { + if (query === "reports/topcoder/engagement-data-members.sql") { + return [ + { + user_id: "101", + handle: "assigned_user", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + country: "United States", + street_addr_1: "1 Main St", + street_addr_2: null, + city: "New York", + state_code: "NY", + zip: "10001", + phone_number: "+1 555 0101", + }, + { + user_id: "202", + handle: null, + first_name: null, + last_name: null, + email: null, + country: "Canada", + street_addr_1: null, + street_addr_2: null, + city: null, + state_code: null, + zip: null, + phone_number: null, + }, + ]; + } + + if (query === "reports/topcoder/engagement-data-projects.sql") { + return [ + { project_id: "p1", project_name: "Project Alpha" }, + { project_id: "p2", project_name: "Project Beta" }, + ]; + } + + throw new Error(`Unexpected query: ${query}`); + }), + }; + + sql = { + load: jest.fn((query: string) => query), + }; + + config = { + get: jest.fn((key: string, defaultValue?: string) => { + if (key === "ENGAGEMENTS_DB_URL") { + return "postgresql://localhost:5432/engagements"; + } + + return defaultValue; + }), + }; + + service = new TopcoderReportsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + config as unknown as ConfigService, + ); + + engagementsPoolQuery = jest.fn().mockResolvedValue({ + rows: [ + { + member_id: "101", + fallback_handle: "assigned_user", + application_email: "assigned-application@example.com", + application_address: "Assigned Application Address", + application_phone: "111-111-1111", + application_name: "Assigned User", + has_assignment: true, + assigned_project_ids: ["p2", "p1", "p1"], + }, + { + member_id: "202", + fallback_handle: "applicant_user", + application_email: "applicant@example.com", + application_address: "Applicant Address", + application_phone: "222-222-2222", + application_name: "Grace Hopper", + has_assignment: false, + assigned_project_ids: [], + }, + ], + }); + + Object.assign(service as object, { + engagementsPool: { + end: jest.fn(), + query: engagementsPoolQuery, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("builds engagement data rows with DB-backed enrichment, fallbacks, and project names", async () => { + await expect(service.getEngagementData()).resolves.toEqual([ + { + handle: "assigned_user", + firstName: "Ada", + lastName: "Lovelace", + country: "United States", + emailId: "ada@example.com", + phoneNumber: "+1 555 0101", + address: "1 Main St, New York, NY, 10001", + engagementExperience: "Assigned", + projectNames: "Project Alpha, Project Beta", + }, + { + handle: "applicant_user", + firstName: "Grace", + lastName: "Hopper", + country: "Canada", + emailId: "applicant@example.com", + phoneNumber: "222-222-2222", + address: "Applicant Address", + engagementExperience: "Applicant", + projectNames: "", + }, + ]); + + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data.sql", + ); + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data-members.sql", + ); + expect(sql.load).toHaveBeenCalledWith( + "reports/topcoder/engagement-data-projects.sql", + ); + expect(engagementsPoolQuery).toHaveBeenCalledWith( + "reports/topcoder/engagement-data.sql", + ); + expect(db.query).toHaveBeenCalledTimes(2); + expect(db.query).toHaveBeenNthCalledWith( + 1, + "reports/topcoder/engagement-data-members.sql", + [["101", "202"]], + ); + expect(db.query).toHaveBeenNthCalledWith( + 2, + "reports/topcoder/engagement-data-projects.sql", + [["p2", "p1"]], + ); + }); + + it("returns an empty list without running enrichment queries when no members qualify", async () => { + engagementsPoolQuery.mockResolvedValueOnce({ rows: [] }); + + await expect(service.getEngagementData()).resolves.toEqual([]); + + expect(db.query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index dd7cf31..81beb45 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1,7 +1,9 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { DbService } from "../../db/db.service"; import { SqlLoaderService } from "../../common/sql-loader.service"; import { alpha3ToCountryName } from "../../common/country.util"; +import { Pool } from "pg"; type RegistrantCountriesRow = { handle: string | null; @@ -115,13 +117,64 @@ type ChallengeSubmitterDataRow = { finalScore: string | number | null; }; +type EngagementDataBaseRow = { + member_id: string | null; + fallback_handle: string | null; + application_email: string | null; + application_address: string | null; + application_phone: string | null; + application_name: string | null; + has_assignment: boolean | null; + assigned_project_ids: string[] | null; +}; + +type EngagementMemberRow = { + user_id: string | number | null; + handle: string | null; + first_name: string | null; + last_name: string | null; + email: string | null; + country: string | null; + street_addr_1: string | null; + street_addr_2: string | null; + city: string | null; + state_code: string | null; + zip: string | null; + phone_number: string | null; +}; + +type FormattableAddress = { + streetAddr1?: string | null; + streetAddr2?: string | null; + city?: string | null; + stateCode?: string | null; + zip?: string | null; +}; + +type EngagementProjectRow = { + project_id: string | number | null; + project_name: string | null; +}; + +type ParsedName = { + firstName: string | null; + lastName: string | null; +}; + @Injectable() -export class TopcoderReportsService { +export class TopcoderReportsService implements OnModuleDestroy { + private engagementsPool?: Pool; + constructor( private readonly db: DbService, private readonly sql: SqlLoaderService, + private readonly config: ConfigService, ) {} + async onModuleDestroy() { + await this.engagementsPool?.end(); + } + async getMemberCount() { const query = this.sql.load("reports/topcoder/member-count.sql"); const rows = await this.db.query<{ "user.count": string | number }>(query); @@ -587,6 +640,94 @@ export class TopcoderReportsService { }); } + /** + * Returns the engagement data member report requested by PM-4800. + * + * The base member list comes from the engagements database, while member + * profile/contact fields and project names are resolved directly from the + * main reports database so the export stays DB-only. + * + * @returns One row per member with the engagement experience summary fields. + * @throws Error when the engagements database URL is not configured. + */ + async getEngagementData() { + const rows = await this.queryEngagementDataRows(); + + if (!rows.length) { + return []; + } + + const memberIds = Array.from( + new Set( + rows + .map((row) => row.member_id?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ); + const projectIds = Array.from( + new Set( + rows.flatMap((row) => + (row.assigned_project_ids ?? []) + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ), + ); + + const [membersById, projectNamesById] = await Promise.all([ + this.fetchEngagementMembersByUserIds(memberIds), + this.fetchProjectNamesByIds(projectIds), + ]); + + return rows.map((row) => { + const memberId = row.member_id?.trim() ?? ""; + const member = membersById.get(memberId); + const parsedName = this.parseName(row.application_name); + const handle = + this.toOptionalString(member?.handle) ?? row.fallback_handle; + const projectNames = Array.from( + new Set( + (row.assigned_project_ids ?? []) + .map((projectId) => + projectNamesById.get(projectId?.trim() ?? "")?.trim(), + ) + .filter((value): value is string => Boolean(value)), + ), + ).sort((left, right) => left.localeCompare(right)); + + return { + handle: handle ?? null, + firstName: + this.toOptionalString(member?.first_name) ?? parsedName.firstName, + lastName: + this.toOptionalString(member?.last_name) ?? parsedName.lastName, + country: this.toOptionalString(member?.country), + emailId: + this.toOptionalString(member?.email) ?? row.application_email ?? null, + phoneNumber: + this.toOptionalString(member?.phone_number) ?? + row.application_phone ?? + null, + address: + this.formatAddress( + member + ? { + streetAddr1: member.street_addr_1, + streetAddr2: member.street_addr_2, + city: member.city, + stateCode: member.state_code, + zip: member.zip, + } + : null, + ) ?? + row.application_address ?? + null, + engagementExperience: row.has_assignment ? "Assigned" : "Applicant", + projectNames: projectNames.join(", "), + }; + }); + } + async getRegistrantCountries(challengeId: string) { const query = this.sql.load("reports/topcoder/registrant-countries.sql"); const rows = await this.db.query(query, [ @@ -709,9 +850,7 @@ export class TopcoderReportsService { principalSkills: row.principalSkills || undefined, openToWork: row.openToWork ?? null, isOpenToWork: - typeof row.isOpenToWork === "boolean" - ? row.isOpenToWork - : false, + typeof row.isOpenToWork === "boolean" ? row.isOpenToWork : false, })); return { @@ -723,6 +862,193 @@ export class TopcoderReportsService { }; } + /** + * Loads the raw engagement report rows from the engagements database. + * + * @returns Aggregated rows keyed by member id with applicant fallbacks and + * assigned project ids. + * @throws Error when the engagements database URL is not configured. + */ + private async queryEngagementDataRows(): Promise { + const query = this.sql.load("reports/topcoder/engagement-data.sql"); + const result = + await this.getEngagementsPool().query(query); + + return result.rows; + } + + /** + * Returns a cached pg pool for the engagements database. + * + * @returns Shared pool targeting the engagements database. + * @throws Error when ENGAGEMENTS_DB_URL is missing. + */ + private getEngagementsPool(): Pool { + if (this.engagementsPool) { + return this.engagementsPool; + } + + const connectionString = this.config.get("ENGAGEMENTS_DB_URL", ""); + if (!connectionString) { + throw new Error( + "ENGAGEMENTS_DB_URL must be configured to generate the engagement data report.", + ); + } + + this.engagementsPool = new Pool({ connectionString }); + return this.engagementsPool; + } + + /** + * Fetches member profile rows from the main reports database so the report + * can be enriched without member-api calls. + * + * @param userIds Report member ids from the engagements database. + * @returns Map of user id to member profile/contact row. + */ + private async fetchEngagementMembersByUserIds( + userIds: string[], + ): Promise> { + const normalizedUserIds = Array.from( + new Set( + userIds + .map((userId) => userId?.trim()) + .filter((userId): userId is string => Boolean(userId)), + ), + ); + + const membersById = new Map(); + + if (!normalizedUserIds.length) { + return membersById; + } + + const query = this.sql.load("reports/topcoder/engagement-data-members.sql"); + const rows = await this.db.query(query, [ + normalizedUserIds, + ]); + + rows.forEach((member) => { + const userId = + member?.user_id === undefined || member.user_id === null + ? null + : String(member.user_id).trim(); + + if (!userId) { + return; + } + + membersById.set(userId, member); + }); + + return membersById; + } + + /** + * Resolves project names for the assigned project ids included in the report. + * + * @param projectIds Project ids collected from engagement assignments. + * @returns Map of project id to project name. + */ + private async fetchProjectNamesByIds( + projectIds: string[], + ): Promise> { + const normalizedProjectIds = Array.from( + new Set( + projectIds + .map((projectId) => projectId?.trim()) + .filter((projectId): projectId is string => Boolean(projectId)), + ), + ); + + const projectNamesById = new Map(); + + if (!normalizedProjectIds.length) { + return projectNamesById; + } + + const query = this.sql.load( + "reports/topcoder/engagement-data-projects.sql", + ); + const rows = await this.db.query(query, [ + normalizedProjectIds, + ]); + + rows.forEach((project) => { + const projectId = + project?.project_id === undefined || project.project_id === null + ? null + : String(project.project_id).trim(); + const projectName = this.toOptionalString(project?.project_name); + + if (!projectId || !projectName) { + return; + } + + projectNamesById.set(projectId, projectName); + }); + + return projectNamesById; + } + + /** + * Formats a preferred address row into the report output string. + * + * @param address Address row selected for a member. + * @returns Comma-separated address string or null when unavailable. + */ + private formatAddress(address?: FormattableAddress | null): string | null { + if (!address) { + return null; + } + + const parts = [ + address.streetAddr1, + address.streetAddr2, + address.city, + address.stateCode, + address.zip, + ] + .map((value) => this.toOptionalString(value)) + .filter((value): value is string => Boolean(value)); + + return parts.length ? parts.join(", ") : null; + } + + /** + * Splits a full name into first and last name fallbacks. + * + * @param value Full-name string captured on the engagement application. + * @returns Parsed first and last name values. + */ + private parseName(value?: string | null): ParsedName { + const normalizedValue = this.toOptionalString(value); + if (!normalizedValue) { + return { firstName: null, lastName: null }; + } + + const parts = normalizedValue.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + return { firstName: parts[0], lastName: null }; + } + + return { + firstName: parts[0] ?? null, + lastName: parts.slice(1).join(" ") || null, + }; + } + + /** + * Normalizes optional string values by trimming whitespace and dropping blanks. + * + * @param value Candidate string value. + * @returns Trimmed string or null when empty. + */ + private toOptionalString(value?: string | null): string | null { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : null; + } + private toNullableNumberArray(value: unknown): number[] | null { if (value === null || value === undefined) { return null;