diff --git a/.circleci/config.yml b/.circleci/config.yml index e6d288d..a10c67e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,7 @@ workflows: branches: only: - develop - - PM-4949 + - PM-4931 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 82c7862..0000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file diff --git a/sql/reports/sfdc/billing-account-detail.sql b/sql/reports/sfdc/billing-account-detail.sql new file mode 100644 index 0000000..7de88f0 --- /dev/null +++ b/sql/reports/sfdc/billing-account-detail.sql @@ -0,0 +1,11 @@ +SELECT + ba.name, + ba.description, + ba."subcontractingEndCustomer" AS "subcontractingEndCustomer", + ba.status::text AS status, + ba."startDate" AS "startDate", + ba."endDate" AS "endDate", + ba.budget, + ba.markup +FROM "billing-accounts"."BillingAccount" ba +WHERE ba.id::text = TRIM($1) diff --git a/src/app-constants.ts b/src/app-constants.ts index d210b7c..c1bab63 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -55,6 +55,9 @@ const challengeReportAccessRoles = [ UserRoles.TalentManager, ] as const; +/** Human role mapping for SFDC report scopes (admins still bypass via `hasAdminRole`). */ +const sfdcReportsTalentManagerRoles = [UserRoles.TalentManager] as const; + export const ScopeRoleAccess: Record = { [Scopes.Challenge.History]: challengeReportAccessRoles, [Scopes.Challenge.Registrants]: challengeReportAccessRoles, @@ -63,6 +66,13 @@ export const ScopeRoleAccess: Record = { [Scopes.Challenge.Submitters]: challengeReportAccessRoles, [Scopes.Challenge.ValidSubmitters]: challengeReportAccessRoles, [Scopes.Challenge.Winners]: challengeReportAccessRoles, + [Scopes.SFDC.PaymentsReport]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.ChallengesReport]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.BA]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.TaasJobs]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.TaasResourceBookings]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.TaasMemberVerification]: sfdcReportsTalentManagerRoles, + [Scopes.SFDC.WesternUnionPayments]: sfdcReportsTalentManagerRoles, [Scopes.Member.EngagementData]: [UserRoles.TalentManager], [Scopes.Member.RecentMemberData]: [UserRoles.TalentManager], [Scopes.Member.MemberSearch]: [UserRoles.TalentManager], diff --git a/src/auth/permissions.util.spec.ts b/src/auth/permissions.util.spec.ts index 7e32e40..4135d59 100644 --- a/src/auth/permissions.util.spec.ts +++ b/src/auth/permissions.util.spec.ts @@ -64,4 +64,46 @@ describe("permissions.util", () => { ), ).toBe(false); }); + + it("allows talent manager role for SFDC payments report scope", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Talent Manager"], + }, + [Scopes.SFDC.PaymentsReport], + ), + ).toBe(true); + }); + + it("denies product manager role for SFDC payments report scope", () => { + expect( + hasAccessToScopes( + { + roles: ["Topcoder Product Manager"], + }, + [Scopes.SFDC.PaymentsReport], + ), + ).toBe(false); + }); + + it("allows administrator role for SFDC payments report scope", () => { + expect( + hasAccessToScopes( + { + roles: ["Administrator"], + }, + [Scopes.SFDC.PaymentsReport], + ), + ).toBe(true); + }); + + it("allows talent manager role for other SFDC report scopes", () => { + expect( + hasAccessToScopes( + { roles: ["Topcoder Talent Manager"] }, + [Scopes.SFDC.BA, Scopes.SFDC.ChallengesReport], + ), + ).toBe(true); + }); }); diff --git a/src/reports/member/dto/member-search-response.dto.ts b/src/reports/member/dto/member-search-response.dto.ts index 3e15d33..0141341 100644 --- a/src/reports/member/dto/member-search-response.dto.ts +++ b/src/reports/member/dto/member-search-response.dto.ts @@ -9,7 +9,7 @@ export class MatchedSkillDto { @ApiProperty({ description: - "True when the member has at least one challenge-win event for this skill.", + "True for platform-backed skill activity (wins and/or skill events). Matched skills only include these, not self-attested-only skills.", }) isVerified!: boolean; diff --git a/src/reports/member/member-search.service.spec.ts b/src/reports/member/member-search.service.spec.ts index 6f662fa..661ca28 100644 --- a/src/reports/member/member-search.service.spec.ts +++ b/src/reports/member/member-search.service.spec.ts @@ -142,7 +142,6 @@ describe("MemberSearchService", () => { expect(dataSql).not.toContain( 'EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId")', ); - expect(dataSql).not.toContain("COALESCE(m.verified, false) = true"); }); it("adds profileComplete CTE/join only when enabled and keeps count params free of pagination", async () => { @@ -226,7 +225,11 @@ describe("MemberSearchService", () => { expect(validationParams).toEqual([[skillA, skillB]]); expect(dataSql).toContain("requested_skills AS"); + expect(dataSql).toContain("FILTER (WHERE usd.wins > 0 OR usd.submitted > 0)"); expect(dataSql).toContain("INNER JOIN user_match_data umd"); + expect(dataSql).toContain("THEN COUNT(DISTINCT CASE"); + expect(dataSql).toContain("ELSE COUNT(DISTINCT CASE"); + expect(dataSql).toContain("(usd.wins >= rs.min_wins OR usd.submitted > 0)"); expect(dataParams).toContainEqual([skillA, skillB]); expect(dataParams).toContainEqual([5, 0]); expect(dataParams).toContain("AND"); diff --git a/src/reports/member/member-search.service.ts b/src/reports/member/member-search.service.ts index d760b7b..92bf277 100644 --- a/src/reports/member/member-search.service.ts +++ b/src/reports/member/member-search.service.ts @@ -141,31 +141,41 @@ qualifying_users AS ( GROUP BY usd.user_id HAVING CASE WHEN ${pSearchType} = 'AND' - THEN COUNT(DISTINCT CASE WHEN usd.wins >= rs.min_wins THEN usd.skill_id END) + THEN COUNT(DISTINCT CASE + WHEN (usd.wins >= rs.min_wins OR usd.submitted > 0) + THEN usd.skill_id END) = ${pNumSkills}::integer - ELSE COUNT(DISTINCT CASE WHEN usd.wins >= rs.min_wins THEN usd.skill_id END) >= 1 + ELSE COUNT(DISTINCT CASE + WHEN (usd.wins >= rs.min_wins OR usd.submitted > 0) + THEN usd.skill_id END) >= 1 END ), user_match_data AS ( SELECT usd.user_id, - SUM( - 1.0 - + LEAST(usd.wins::float / 100.0, 0.5) - + CASE WHEN usd.submitted > 0 - THEN (usd.wins::float / usd.submitted::float) * 0.5 - ELSE 0.0 - END + COALESCE( + SUM( + 1.0 + + LEAST(usd.wins::float / 100.0, 0.5) + + CASE WHEN usd.submitted > 0 + THEN (usd.wins::float / usd.submitted::float) * 0.5 + ELSE 0.0 + END + ) FILTER (WHERE usd.wins > 0 OR usd.submitted > 0), + 0.0 ) AS total_skill_points, - jsonb_agg( - jsonb_build_object( - 'id', usd.skill_id::text, - 'name', usd.skill_name, - 'isVerified', usd.wins > 0, - 'wins', usd.wins, - 'submitted', usd.submitted - ) - ORDER BY usd.skill_name + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', usd.skill_id::text, + 'name', usd.skill_name, + 'isVerified', (usd.wins > 0 OR usd.submitted > 0), + 'wins', usd.wins, + 'submitted', usd.submitted + ) + ORDER BY usd.skill_name + ) FILTER (WHERE usd.wins > 0 OR usd.submitted > 0), + '[]'::jsonb ) AS matched_skills FROM user_skill_data usd WHERE usd.user_id IN (SELECT user_id FROM qualifying_users) diff --git a/src/reports/sfdc/sfdc-reports.controller.ts b/src/reports/sfdc/sfdc-reports.controller.ts index 59db229..a3ecd32 100644 --- a/src/reports/sfdc/sfdc-reports.controller.ts +++ b/src/reports/sfdc/sfdc-reports.controller.ts @@ -23,6 +23,8 @@ import { SfdcReportsService } from "./sfdc-reports.service"; import { BaFeesReportQueryDto, BaFeesReportResponse, + BillingAccountProfileQueryDto, + BillingAccountProfileResponse, ChallengesReportQueryDto, ChallengesReportResponse, PaymentsReportQueryDto, @@ -83,6 +85,24 @@ export class SfdcReportsController { return report; } + @Get("/billing-accounts") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports, AppScopes.SFDC.PaymentsReport) + @ApiBearerAuth() + @ApiOperation({ + summary: "Billing account profile", + description: + "Returns billing account metadata from billing-accounts.BillingAccount when the ID exists.", + }) + @ApiResponse({ + status: 200, + description: "Billing account profile retrieved successfully", + type: ResponseDto, + }) + async getBillingAccountProfile(@Query() query: BillingAccountProfileQueryDto) { + return this.reportsService.getBillingAccountProfile(query); + } + @Get("/taas/jobs") @UseGuards(PermissionsGuard) @Scopes(AppScopes.AllReports, AppScopes.SFDC.TaasJobs) diff --git a/src/reports/sfdc/sfdc-reports.dto.ts b/src/reports/sfdc/sfdc-reports.dto.ts index 3f1d908..0ad9685 100644 --- a/src/reports/sfdc/sfdc-reports.dto.ts +++ b/src/reports/sfdc/sfdc-reports.dto.ts @@ -350,6 +350,55 @@ export class PaymentsReportResponse { challengeStatus: string | null; } +export class BillingAccountProfileQueryDto { + @ApiProperty({ + required: true, + description: + "Billing account identifier (matches finance.payment.billing_account and billing-accounts.BillingAccount.id)", + example: "80004349", + }) + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @IsString() + @IsNotEmpty() + billingAccountId: string; +} + +export class BillingAccountDetailResponse { + @ApiProperty() + name: string; + + @ApiProperty({ nullable: true, type: String }) + description: string | null; + + @ApiProperty({ nullable: true, type: String }) + subcontractingEndCustomer: string | null; + + @ApiProperty() + status: string; + + @ApiProperty({ nullable: true, type: String }) + startDate: string | null; + + @ApiProperty({ nullable: true, type: String }) + endDate: string | null; + + @ApiProperty() + budget: string; + + @ApiProperty() + markup: string; +} + +export class BillingAccountProfileResponse { + @ApiProperty({ + nullable: true, + type: BillingAccountDetailResponse, + description: + "Row from billing-accounts.BillingAccount when the ID exists; null if unknown.", + }) + billingAccount: BillingAccountDetailResponse | null; +} + export class TaasJobsReportQueryDto { @ApiProperty({ required: false, diff --git a/src/reports/sfdc/sfdc-reports.service.ts b/src/reports/sfdc/sfdc-reports.service.ts index 51e2a5d..233526a 100644 --- a/src/reports/sfdc/sfdc-reports.service.ts +++ b/src/reports/sfdc/sfdc-reports.service.ts @@ -4,6 +4,8 @@ import { Logger } from "src/common/logger"; import { BaFeesReportQueryDto, BaFeesReportResponse, + BillingAccountProfileQueryDto, + BillingAccountProfileResponse, ChallengesFilterMode, ChallengesReportQueryDto, ChallengesReportResponse, @@ -137,6 +139,21 @@ export class SfdcReportsService { })); } + async getBillingAccountProfile( + filters: BillingAccountProfileQueryDto, + ): Promise { + const billingAccountId = filters.billingAccountId.trim(); + const query = this.sql.load("reports/sfdc/billing-account-detail.sql"); + const rows = await this.db.query( + query, + [billingAccountId], + ); + + return { + billingAccount: rows[0] ?? null, + }; + } + async getTaasJobsReport(filters: TaasJobsReportQueryDto) { this.logger.debug("Starting getTaasJobsReport with filters:", filters);