diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a40302..e6d288d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,12 +64,7 @@ workflows: branches: only: - develop - - pm-1127_1 - - PM-4305 - - PM-4490 - - PM-4491-fix - - PM-3497_talent-search - - PM-4886 + - PM-4949 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/sql/reports/topcoder/member-payment-accrual.sql b/sql/reports/payment/member-payment-accrual.sql similarity index 60% rename from sql/reports/topcoder/member-payment-accrual.sql rename to sql/reports/payment/member-payment-accrual.sql index aec6c3a..10780f4 100644 --- a/sql/reports/topcoder/member-payment-accrual.sql +++ b/sql/reports/payment/member-payment-accrual.sql @@ -26,7 +26,6 @@ recent_payments AS ( SELECT w.winning_id, w.winner_id, - w.type, w.description, w.category, w.external_id AS challenge_id, @@ -52,36 +51,57 @@ recent_payments AS ( WHERE w.type = 'PAYMENT' AND p.created_at >= pr.start_date AND p.created_at <= pr.end_date +), +categorized_payments AS ( + SELECT + rp.*, + CASE + WHEN rp.category = 'TAAS_PAYMENT' THEN 'TaaS Payment' + WHEN rp.category = 'TOPGEAR_PAYMENT' THEN 'Topgear Payment' + WHEN rp.category = 'ENGAGEMENT_PAYMENT' THEN 'Engagement Payment' + WHEN rp.category IN ( + 'TASK_PAYMENT', + 'TASK_REVIEW_PAYMENT', + 'TASK_COPILOT_PAYMENT', + 'DEPLOYMENT_TASK_PAYMENT', + 'PROJECT_DEPLOYMENT_TASK_PAYMENT' + ) THEN 'Task Payment' + ELSE 'Challenge Payment' + END AS payment_type + FROM recent_payments rp ) SELECT - rp.payment_created_at AS payment_created_at, - rp.payment_id, - rp.description AS payment_description, - rp.challenge_id, - rp.payment_status, - rp.type AS payment_type, + cp.payment_created_at AS payment_created_at, + cp.payment_id, + cp.description AS payment_description, + cp.challenge_id, + cp.payment_status, + cp.payment_type, mem.handle AS payee_handle, pm.name AS payment_method, ba."name" AS billing_account_name, cl."name" AS customer_name, ba."subcontractingEndCustomer" AS reporting_account_name, - rp.winner_id AS member_id, - to_char(c."createdAt", 'YYYY-MM-DD') AS challenge_created_date, - rp.gross_amount AS user_payment_gross_amount -FROM recent_payments rp + cp.winner_id AS member_id, +CASE + WHEN cp.payment_type = 'Engagement Payment' THEN to_char(cp.payment_created_at, 'YYYY-MM-DD') + ELSE to_char(c."createdAt", 'YYYY-MM-DD') + END AS challenge_created_date, cp.gross_amount AS user_payment_gross_amount +FROM categorized_payments cp LEFT JOIN challenges."Challenge" c - ON c."id" = rp.challenge_id + ON c."id" = cp.challenge_id LEFT JOIN challenges."ChallengeBilling" cb ON cb."challengeId" = c."id" LEFT JOIN "billing-accounts"."BillingAccount" ba ON ba."id" = COALESCE( - NULLIF(rp.billing_account, '')::int, + NULLIF(cp.billing_account, '')::int, NULLIF(cb."billingAccountId", '')::int ) LEFT JOIN "billing-accounts"."Client" cl ON cl."id" = ba."clientId" LEFT JOIN finance.payment_method pm - ON pm.payment_method_id = rp.payment_method_id + ON pm.payment_method_id = cp.payment_method_id LEFT JOIN members.member mem - ON mem."userId"::text = rp.winner_id + ON mem."userId"::text = cp.winner_id +WHERE ($3::text[] IS NULL OR cp.payment_type = ANY($3::text[])) ORDER BY payment_created_at DESC; diff --git a/src/app.module.ts b/src/app.module.ts index 4635441..fa7f2f9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports import { IdentityReportsModule } from "./reports/identity/identity-reports.module"; import { ReportsModule } from "./reports/reports.module"; import { MemberSearchModule } from "./reports/member/member-search.module"; +import { PaymentReportsModule } from "./reports/payment/payment-reports.module"; @Module({ imports: [ @@ -25,6 +26,7 @@ import { MemberSearchModule } from "./reports/member/member-search.module"; IdentityReportsModule, ReportsModule, MemberSearchModule, + PaymentReportsModule, HealthModule, ], }) diff --git a/src/reports/member/dto/member-search.dto.ts b/src/reports/member/dto/member-search.dto.ts index d84e299..5d89c9b 100644 --- a/src/reports/member/dto/member-search.dto.ts +++ b/src/reports/member/dto/member-search.dto.ts @@ -75,6 +75,14 @@ export class MemberSearchBodyDto { @IsBoolean() verifiedProfile?: boolean; + @ApiPropertyOptional({ + description: + "When true, apply 100% profile completeness checks after lightweight filters are applied.", + }) + @IsOptional() + @IsBoolean() + profileComplete?: boolean; + @ApiPropertyOptional({ description: "Filter by multiple country names or country codes (case-insensitive).", diff --git a/src/reports/member/member-search.service.spec.ts b/src/reports/member/member-search.service.spec.ts index d83ae52..6f662fa 100644 --- a/src/reports/member/member-search.service.spec.ts +++ b/src/reports/member/member-search.service.spec.ts @@ -145,6 +145,56 @@ describe("MemberSearchService", () => { 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 () => { + mockDbService.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]); + + await service.search({ + countries: ["us"], + profileComplete: true, + page: 3, + limit: 7, + }); + + const enabledDataSql = mockDbService.query.mock.calls[0][0] as string; + const enabledDataParams = mockDbService.query.mock.calls[0][1] as unknown[]; + const enabledCountSql = mockDbService.query.mock.calls[1][0] as string; + const enabledCountParams = mockDbService.query.mock.calls[1][1] as unknown[]; + + expect(enabledDataSql).toContain("profile_complete_filtered AS ("); + expect(enabledDataSql).toContain( + "INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"", + ); + expect(enabledCountSql).toContain("FROM profile_complete_filtered pcf"); + expect(enabledCountSql).not.toContain( + "INNER JOIN profile_complete_filtered pcf ON pcf.user_id = fm.user_id", + ); + expect(enabledDataParams).toEqual([["us"], 7, 14]); + expect(enabledCountParams).toEqual([["us"]]); + + mockDbService.query.mockReset(); + mockDbService.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]); + + await service.search({ + countries: ["us"], + page: 3, + limit: 7, + }); + + const disabledDataSql = mockDbService.query.mock.calls[0][0] as string; + const disabledCountSql = mockDbService.query.mock.calls[1][0] as string; + + expect(disabledDataSql).not.toContain("profile_complete_filtered AS ("); + expect(disabledDataSql).not.toContain( + "INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"", + ); + expect(disabledCountSql).toContain("FROM filtered_members fm"); + expect(disabledCountSql).not.toContain("profile_complete_filtered pcf"); + }); + it("deduplicates skills and keeps last wins value when building skill query", async () => { const skillA = "550e8400-e29b-41d4-a716-446655440000"; const skillB = "550e8400-e29b-41d4-a716-446655440001"; diff --git a/src/reports/member/member-search.service.ts b/src/reports/member/member-search.service.ts index 1d9e38c..d760b7b 100644 --- a/src/reports/member/member-search.service.ts +++ b/src/reports/member/member-search.service.ts @@ -52,6 +52,7 @@ export class MemberSearchService { openToWork, recentlyActive, verifiedProfile, + profileComplete, countries, sortBy = "matchIndex", sortOrder = "desc", @@ -203,7 +204,7 @@ member_address AS ( id DESC )`); - // ------------------------------------------------- dynamic WHERE + // ------------------------------------------------- dynamic WHERE (easy filters first) const where: string[] = [`m.status = 'ACTIVE'`]; if (openToWork === true) { @@ -243,6 +244,75 @@ member_address AS ( ); } + const whereClause = where.join(" AND "); + ctes.push(`filtered_members AS ( + SELECT m."userId" AS user_id + FROM members.member m + ${skillJoin} + WHERE ${whereClause} +)`); + + if (profileComplete === true) { + ctes.push(`profile_complete_filtered AS ( + SELECT fm.user_id + FROM filtered_members fm + INNER JOIN members.member m2 ON m2."userId" = fm.user_id + WHERE m2.description IS NOT NULL + AND btrim(m2.description) <> '' + AND m2."homeCountryCode" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM members."memberAddress" ma2 + WHERE ma2."userId" = m2."userId" + AND ma2.city IS NOT NULL + AND btrim(ma2.city) <> '' + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt2 + INNER JOIN members."memberTraitWork" mw2 ON mw2."memberTraitId" = mt2.id + WHERE mt2."userId" = m2."userId" + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt2 + INNER JOIN members."memberTraitEducation" me2 ON me2."memberTraitId" = mt2.id + WHERE mt2."userId" = m2."userId" + ) + AND EXISTS ( + SELECT 1 + FROM members."memberTraits" mt2 + INNER JOIN members."memberTraitPersonalization" mtp2 ON mtp2."memberTraitId" = mt2.id + WHERE mt2."userId" = m2."userId" + AND mtp2.key = 'openToWork' + AND mtp2.value IS NOT NULL + AND ( + NOT (mtp2.value::jsonb ? 'availability') + OR ( + mtp2.value::jsonb ? 'availability' + AND mtp2.value::jsonb ? 'preferredRoles' + AND jsonb_typeof(mtp2.value::jsonb -> 'preferredRoles') = 'array' + AND jsonb_array_length(mtp2.value::jsonb -> 'preferredRoles') > 0 + ) + ) + ) + AND EXISTS ( + SELECT 1 + FROM skills.user_skill us2 + INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id + WHERE us2.user_id = m2."userId" + AND LOWER(usdm2.name) = 'principal' + ) + AND EXISTS ( + SELECT 1 + FROM skills.user_skill us2 + INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id + WHERE us2.user_id = m2."userId" + AND LOWER(usdm2.name) = 'additional' + ) +)`); + } + // Snapshot param count BEFORE adding pagination — count query stops here const filterParamCount = params.length; @@ -251,12 +321,15 @@ member_address AS ( // ---------------------------------------------------------------- queries const ctesBlock = ctes.join(",\n"); - const whereClause = where.join(" AND "); const direction = sortOrder === "asc" ? "ASC" : "DESC"; const orderByClause = sortBy === "handle" ? `m.handle ${direction}, "matchIndex" DESC NULLS LAST` : `"matchIndex" ${direction} NULLS LAST, m.handle ASC`; + const profileCompleteJoin = + profileComplete === true + ? `INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m."userId"` + : ""; const dataQuery = ` WITH ${ctesBlock} @@ -278,18 +351,17 @@ SELECT ${matchedSkillsExpr} AS "matchedSkills", ${matchIndexExpr} AS "matchIndex" FROM members.member m +INNER JOIN filtered_members fm ON fm.user_id = m."userId" +${profileCompleteJoin} ${skillJoin} LEFT JOIN member_address maddr ON maddr."userId" = m."userId" -WHERE ${whereClause} ORDER BY ${orderByClause} LIMIT ${pLimit} OFFSET ${pOffset}`; const countQuery = ` WITH ${ctesBlock} SELECT COUNT(*)::integer AS total -FROM members.member m -${skillJoin} -WHERE ${whereClause}`; +FROM ${profileComplete === true ? "profile_complete_filtered pcf" : "filtered_members fm"}`; const [rows, countRows] = await Promise.all([ this.db.query(dataQuery, params), diff --git a/src/reports/topcoder/dto/member-payment-accrual.dto.ts b/src/reports/payment/dto/member-payment-accrual.dto.ts similarity index 100% rename from src/reports/topcoder/dto/member-payment-accrual.dto.ts rename to src/reports/payment/dto/member-payment-accrual.dto.ts diff --git a/src/reports/payment/guards/admin-payment-reports.guard.ts b/src/reports/payment/guards/admin-payment-reports.guard.ts new file mode 100644 index 0000000..45783db --- /dev/null +++ b/src/reports/payment/guards/admin-payment-reports.guard.ts @@ -0,0 +1,41 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { + AuthUserLike, + getNormalizedRoles, + hasAdminRole, +} from "../../../auth/permissions.util"; + +@Injectable() +export class AdminPaymentReportsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const authUser: AuthUserLike | undefined = context + .switchToHttp() + .getRequest().authUser; + + if (!authUser) { + throw new UnauthorizedException("You are not authenticated."); + } + + if (authUser.isMachine) { + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + const roles = getNormalizedRoles(authUser); + + if (hasAdminRole(roles)) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } +} diff --git a/src/reports/payment/payment-reports.controller.ts b/src/reports/payment/payment-reports.controller.ts new file mode 100644 index 0000000..e3157b3 --- /dev/null +++ b/src/reports/payment/payment-reports.controller.ts @@ -0,0 +1,95 @@ +import { + Controller, + Get, + Query, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentReportsService } from "./payment-reports.service"; +import { MemberPaymentAccrualQueryDto } from "./dto/member-payment-accrual.dto"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; +import { AdminPaymentReportsGuard } from "./guards/admin-payment-reports.guard"; + +@ApiTags("Payment Reports") +@ApiBearerAuth() +@UseGuards(AdminPaymentReportsGuard) +@UseInterceptors(CsvResponseInterceptor) +@Controller() +export class PaymentReportsController { + constructor(private readonly reports: PaymentReportsService) {} + + @Get("/payment/member-payment-accrual") + @ApiOperation({ + summary: + "Member payment accruals for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrual(@Query() query: MemberPaymentAccrualQueryDto) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate); + } + + @Get("/payment/member-payment-accrual-taas") + @ApiOperation({ + summary: + "Member payment accruals for TaaS payments for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrualTaas(@Query() query: MemberPaymentAccrualQueryDto) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate, [ + "TaaS Payment", + ]); + } + + @Get("/payment/member-payment-accrual-topgear") + @ApiOperation({ + summary: + "Member payment accruals for Topgear payments for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrualTopgear(@Query() query: MemberPaymentAccrualQueryDto) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate, [ + "Topgear Payment", + ]); + } + + @Get("/payment/member-payment-accrual-engagement") + @ApiOperation({ + summary: + "Member payment accruals for engagement payments for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrualEngagement( + @Query() query: MemberPaymentAccrualQueryDto, + ) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate, [ + "Engagement Payment", + ]); + } + + @Get("/payment/member-payment-accrual-task") + @ApiOperation({ + summary: + "Member payment accruals for task payments for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrualTask(@Query() query: MemberPaymentAccrualQueryDto) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate, [ + "Task Payment", + ]); + } + + @Get("/payment/member-payment-accrual-challenge") + @ApiOperation({ + summary: + "Member payment accruals for challenge payments (contest, review board, copilot, checkpoint, and related challenge payouts) for the provided date range (defaults to last 3 months)", + }) + getMemberPaymentAccrualChallenge( + @Query() query: MemberPaymentAccrualQueryDto, + ) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate, [ + "Challenge Payment", + ]); + } +} diff --git a/src/reports/payment/payment-reports.module.ts b/src/reports/payment/payment-reports.module.ts new file mode 100644 index 0000000..2a39fe8 --- /dev/null +++ b/src/reports/payment/payment-reports.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { PaymentReportsController } from "./payment-reports.controller"; +import { AdminPaymentReportsGuard } from "./guards/admin-payment-reports.guard"; +import { PaymentReportsService } from "./payment-reports.service"; + +@Module({ + controllers: [PaymentReportsController], + providers: [ + PaymentReportsService, + SqlLoaderService, + AdminPaymentReportsGuard, + CsvSerializer, + CsvResponseInterceptor, + ], +}) +export class PaymentReportsModule {} diff --git a/src/reports/payment/payment-reports.service.spec.ts b/src/reports/payment/payment-reports.service.spec.ts new file mode 100644 index 0000000..978c934 --- /dev/null +++ b/src/reports/payment/payment-reports.service.spec.ts @@ -0,0 +1,75 @@ +import { DbService } from "../../db/db.service"; +import { SqlLoaderService } from "../../common/sql-loader.service"; +import { PaymentReportsService } from "./payment-reports.service"; + +describe("PaymentReportsService", () => { + let service: PaymentReportsService; + let db: { query: jest.Mock }; + let sql: { load: jest.Mock }; + + beforeEach(() => { + db = { + query: jest.fn().mockResolvedValue([ + { + payment_created_at: new Date("2024-01-15T12:00:00.000Z"), + payment_id: "payment-123", + payment_description: "Challenge payout", + challenge_id: "3001", + payment_status: "OWED", + payment_type: "Challenge Payment", + payee_handle: "tourist", + payment_method: "PayPal", + billing_account_name: "BA One", + customer_name: "Client One", + reporting_account_name: "End Customer One", + member_id: "4001", + challenge_created_date: "2024-01-01", + user_payment_gross_amount: "150.50", + }, + ]), + }; + + sql = { + load: jest.fn().mockReturnValue("SELECT 1"), + }; + + service = new PaymentReportsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + ); + }); + + it("loads the payment SQL and maps the accrual rows", async () => { + await expect( + service.getMemberPaymentAccrual("2024-01-01", "2024-01-31", [ + "Challenge Payment", + ]), + ).resolves.toEqual([ + { + paymentCreatedAt: "2024-01-15T12:00:00.000Z", + paymentId: "payment-123", + paymentDescription: "Challenge payout", + challengeId: "3001", + paymentStatus: "OWED", + paymentType: "Challenge Payment", + payeeHandle: "tourist", + payeePaymentMethod: "PayPal", + billingAccountName: "BA One", + customerName: "Client One", + reportingAccountName: "End Customer One", + memberId: "4001", + challengeCreatedAt: "2024-01-01", + userPaymentGrossAmount: 150.5, + }, + ]); + + expect(sql.load).toHaveBeenCalledWith( + "reports/payment/member-payment-accrual.sql", + ); + expect(db.query).toHaveBeenCalledWith("SELECT 1", [ + "2024-01-01", + "2024-01-31", + ["Challenge Payment"], + ]); + }); +}); diff --git a/src/reports/payment/payment-reports.service.ts b/src/reports/payment/payment-reports.service.ts new file mode 100644 index 0000000..8ab1d79 --- /dev/null +++ b/src/reports/payment/payment-reports.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from "@nestjs/common"; +import { DbService } from "../../db/db.service"; +import { SqlLoaderService } from "../../common/sql-loader.service"; + +type MemberPaymentAccrualRow = { + payment_created_at: Date | string | null; + payment_id: string | null; + payment_description: string | null; + challenge_id: string | null; + payment_status: string | null; + payment_type: string | null; + payee_handle: string | null; + payment_method: string | null; + billing_account_name: string | null; + customer_name: string | null; + reporting_account_name: string | null; + member_id: string | null; + challenge_created_date: string | null; + user_payment_gross_amount: string | number | null; +}; + +@Injectable() +export class PaymentReportsService { + constructor( + private readonly db: DbService, + private readonly sql: SqlLoaderService, + ) {} + + async getMemberPaymentAccrual( + startDate?: string, + endDate?: string, + paymentTypes?: string[], + ) { + const query = this.sql.load("reports/payment/member-payment-accrual.sql"); + const rows = await this.db.query(query, [ + startDate ?? null, + endDate ?? null, + paymentTypes?.length ? paymentTypes : null, + ]); + + return rows.map((row) => ({ + paymentCreatedAt: this.normalizeDate(row.payment_created_at), + paymentId: row.payment_id ?? null, + paymentDescription: row.payment_description ?? null, + challengeId: row.challenge_id ?? null, + paymentStatus: row.payment_status ?? null, + paymentType: row.payment_type ?? null, + payeeHandle: row.payee_handle ?? null, + payeePaymentMethod: row.payment_method ?? null, + billingAccountName: row.billing_account_name ?? null, + customerName: row.customer_name ?? null, + reportingAccountName: row.reporting_account_name ?? null, + memberId: row.member_id ?? null, + challengeCreatedAt: row.challenge_created_date ?? null, + userPaymentGrossAmount: this.toNullableNumber( + row.user_payment_gross_amount, + ), + })); + } + + private toNullableNumber(value: string | number | null | undefined) { + if (value === null || value === undefined) { + return null; + } + + return Number(value); + } + + private normalizeDate(value: Date | string | null | undefined) { + if (value === null || value === undefined) { + return null; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + return value; + } +} diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 8e233bf..7d255df 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -6,11 +6,19 @@ import { describe("getAccessibleReportsDirectory", () => { it("returns the full directory for administrators", () => { - expect( - getAccessibleReportsDirectory({ - roles: [AdminRoles.Admin], - }), - ).toEqual(REPORTS_DIRECTORY); + const directory = getAccessibleReportsDirectory({ + roles: [AdminRoles.Admin], + }); + + expect(directory).toEqual(REPORTS_DIRECTORY); + expect(directory.payment?.reports.map((report) => report.path)).toEqual([ + "/payment/member-payment-accrual", + "/payment/member-payment-accrual-taas", + "/payment/member-payment-accrual-topgear", + "/payment/member-payment-accrual-engagement", + "/payment/member-payment-accrual-task", + "/payment/member-payment-accrual-challenge", + ]); }); it("returns public reports plus all challenge reports for product managers", () => { @@ -85,14 +93,12 @@ describe("getAccessibleReportsDirectory", () => { expect( directory.topcoder?.reports.map((report) => report.path), ).not.toContain("/topcoder/member-payment-accrual"); + expect(directory.payment).toBeUndefined(); expect(directory.member?.reports.map((report) => report.path)).toEqual([ "/member/engagement-data", "/member/recent-member-data", "/member/search", ]); - expect(directory.admin?.reports.map((report) => report.path)).toEqual([ - "/admin/member-payment-accrual", - ]); }); it("returns an empty directory when no JWT user is present", () => { diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 27a7e99..eab8d1d 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -1,5 +1,10 @@ import { Scopes as AppScopes } from "../app-constants"; -import { AuthUserLike, hasAccessToScopes } from "../auth/permissions.util"; +import { + AuthUserLike, + getNormalizedRoles, + hasAccessToScopes, + hasAdminRole, +} from "../auth/permissions.util"; import { ChallengeStatus } from "./challenges/dtos/challenge-status.enum"; export type ReportGroupKey = @@ -8,7 +13,7 @@ export type ReportGroupKey = | "statistics" | "topcoder" | "member" - | "admin" + | "payment" | "identity"; type HttpMethod = "GET" | "POST"; @@ -52,6 +57,7 @@ export type ReportsDirectory = Partial>; type RegisteredReport = AvailableReport & { requiredScopes: readonly string[]; + adminOnly?: boolean; }; type RegisteredReportGroup = Omit & { @@ -146,6 +152,16 @@ const topcoderReport = ( parameters, ); +const adminOnlyTopcoderReport = ( + name: string, + path: string, + description: string, + parameters: ReportParameter[] = [], +): RegisteredReport => ({ + ...topcoderReport(name, path, description, parameters), + adminOnly: true, +}); + const publicReport = ( name: string, path: string, @@ -803,16 +819,46 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { ), ], }, - admin: { - label: "Admin Reports", - basePath: "/admin", + payment: { + label: "Payment Reports", + basePath: "/payment", reports: [ - topcoderReport( + adminOnlyTopcoderReport( "Member Payment Accrual", - "/admin/member-payment-accrual", + "/payment/member-payment-accrual", "Member payment accruals for the provided date range (defaults to last 3 months)", [paymentsStartDateParam, paymentsEndDateParam], ), + adminOnlyTopcoderReport( + "Member Payment Accrual-TaaS", + "/payment/member-payment-accrual-taas", + "Member payment accruals for TaaS payments for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + adminOnlyTopcoderReport( + "Member Payment Accrual-Topgear", + "/payment/member-payment-accrual-topgear", + "Member payment accruals for Topgear payments for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + adminOnlyTopcoderReport( + "Member Payment Accrual-Engagement", + "/payment/member-payment-accrual-engagement", + "Member payment accruals for engagement payments for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + adminOnlyTopcoderReport( + "Member Payment Accrual-Task", + "/payment/member-payment-accrual-task", + "Member payment accruals for task payments for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), + adminOnlyTopcoderReport( + "Member Payment Accrual-Challenge", + "/payment/member-payment-accrual-challenge", + "Member payment accruals for challenge payments (contest, review board, copilot, checkpoint, and related challenge payouts) for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], + ), ], }, }; @@ -870,11 +916,18 @@ export function getAccessibleReportsDirectory( return {}; } + const normalizedRoles = getNormalizedRoles(authUser); + const isAdmin = hasAdminRole(normalizedRoles); + const accessibleGroups = Object.entries(REGISTERED_REPORTS_DIRECTORY).flatMap( ([key, group]) => { - const accessibleReports = group.reports.filter((reportDefinition) => - hasAccessToScopes(authUser, reportDefinition.requiredScopes), - ); + const accessibleReports = group.reports.filter((reportDefinition) => { + if (reportDefinition.adminOnly && !isAdmin) { + return false; + } + + return hasAccessToScopes(authUser, reportDefinition.requiredScopes); + }); if (!accessibleReports.length) { return []; diff --git a/src/reports/sfdc/sfdc-reports.controller.spec.ts b/src/reports/sfdc/sfdc-reports.controller.spec.ts index 2ae4c69..2de3b51 100644 --- a/src/reports/sfdc/sfdc-reports.controller.spec.ts +++ b/src/reports/sfdc/sfdc-reports.controller.spec.ts @@ -1,7 +1,10 @@ import "reflect-metadata"; import { BadRequestException } from "@nestjs/common"; +import { INTERCEPTORS_METADATA } from "@nestjs/common/constants"; import { Test, TestingModule } from "@nestjs/testing"; import { plainToInstance } from "class-transformer"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; import { BaFeesReportQueryDto, ChallengesReportQueryDto, @@ -55,6 +58,8 @@ describe("SfdcReportsController", () => { const moduleRef: TestingModule = await Test.createTestingModule({ controllers: [SfdcReportsController], providers: [ + CsvSerializer, + CsvResponseInterceptor, { provide: SfdcReportsService, useValue: mockSfdcReportsService, @@ -69,6 +74,13 @@ describe("SfdcReportsController", () => { expect(controller).toBeDefined(); }); + it("enables CSV response serialization for report downloads", () => { + const interceptors = + Reflect.getMetadata(INTERCEPTORS_METADATA, SfdcReportsController) ?? []; + + expect(interceptors).toContain(CsvResponseInterceptor); + }); + it("returns the challenges report on success", async () => { mockSfdcReportsService.getChallengesReport.mockResolvedValue( normalizedChallengeData, @@ -626,11 +638,8 @@ describe("SfdcReportsController", () => { await controller.getBaFeesReport(mockBaFeesQueryDto.byBillingAccount); - expect(mockSfdcReportsService.getBaFeesReport).toHaveBeenCalledWith( - expect.objectContaining({ - groupBy: undefined, - }), - ); + const [query] = mockSfdcReportsService.getBaFeesReport.mock.calls[0]; + expect(query.groupBy).toBeUndefined(); }); }); }); diff --git a/src/reports/sfdc/sfdc-reports.controller.ts b/src/reports/sfdc/sfdc-reports.controller.ts index d922967..59db229 100644 --- a/src/reports/sfdc/sfdc-reports.controller.ts +++ b/src/reports/sfdc/sfdc-reports.controller.ts @@ -1,8 +1,15 @@ -import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { + Controller, + Get, + Query, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, + ApiProduces, ApiResponse, ApiTags, } from "@nestjs/swagger"; @@ -10,6 +17,7 @@ import { import { PermissionsGuard } from "../../auth/guards/permissions.guard"; import { Scopes } from "../../auth/decorators/scopes.decorator"; import { Scopes as AppScopes } from "../../app-constants"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; import { SfdcReportsService } from "./sfdc-reports.service"; import { @@ -31,6 +39,8 @@ import { import { ResponseDto } from "src/dto/api-response.dto"; @ApiTags("Sfdc Reports") +@ApiProduces("application/json", "text/csv") +@UseInterceptors(CsvResponseInterceptor) @Controller("/sfdc") export class SfdcReportsController { constructor(private readonly reportsService: SfdcReportsService) {} diff --git a/src/reports/sfdc/sfdc-reports.module.spec.ts b/src/reports/sfdc/sfdc-reports.module.spec.ts index 7bbcba4..2c7ae10 100644 --- a/src/reports/sfdc/sfdc-reports.module.spec.ts +++ b/src/reports/sfdc/sfdc-reports.module.spec.ts @@ -1,5 +1,8 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; +import { CsvSerializer } from "src/common/csv/csv-serializer"; import { SqlLoaderService } from "src/common/sql-loader.service"; +import { DbModule } from "../../db/db.module"; import { DbService } from "../../db/db.service"; import { SfdcReportsController } from "./sfdc-reports.controller"; import { SfdcReportsModule } from "./sfdc-reports.module"; @@ -23,9 +26,10 @@ describe("SfdcReportsModule", () => { mockSqlLoaderService.load.mockReturnValue("SELECT 1"); moduleRef = await Test.createTestingModule({ - imports: [SfdcReportsModule], - providers: [{ provide: DbService, useValue: mockDbService }], + imports: [DbModule, SfdcReportsModule], }) + .overrideProvider(DbService) + .useValue(mockDbService) .overrideProvider(SqlLoaderService) .useValue(mockSqlLoaderService) .compile(); @@ -41,6 +45,12 @@ describe("SfdcReportsModule", () => { expect(moduleRef.get(SqlLoaderService)).toBe( mockSqlLoaderService, ); + expect(moduleRef.get(CsvSerializer)).toBeInstanceOf( + CsvSerializer, + ); + expect( + moduleRef.get(CsvResponseInterceptor), + ).toBeInstanceOf(CsvResponseInterceptor); }); it("injects mocked dependencies into the service", async () => { diff --git a/src/reports/sfdc/sfdc-reports.module.ts b/src/reports/sfdc/sfdc-reports.module.ts index e6d85e5..7e2f3f5 100644 --- a/src/reports/sfdc/sfdc-reports.module.ts +++ b/src/reports/sfdc/sfdc-reports.module.ts @@ -1,10 +1,17 @@ import { Module } from "@nestjs/common"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; +import { SqlLoaderService } from "../../common/sql-loader.service"; import { SfdcReportsController } from "./sfdc-reports.controller"; import { SfdcReportsService } from "./sfdc-reports.service"; -import { SqlLoaderService } from "../../common/sql-loader.service"; @Module({ controllers: [SfdcReportsController], - providers: [SfdcReportsService, SqlLoaderService], + providers: [ + SfdcReportsService, + SqlLoaderService, + CsvSerializer, + CsvResponseInterceptor, + ], }) export class SfdcReportsModule {} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index dff2e30..9c476d4 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -10,7 +10,6 @@ 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"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; import { CompletedProfilesQueryDto } from "./dto/completed-profiles.dto"; @@ -88,16 +87,6 @@ export class TopcoderReportsController { return this.reports.getWeeklyMemberParticipation(startDate, endDate); } - @Get("/admin/member-payment-accrual") - @ApiOperation({ - summary: - "Member payment accruals for the provided date range (defaults to last 3 months)", - }) - getMemberPaymentAccrual(@Query() query: MemberPaymentAccrualQueryDto) { - const { startDate, endDate } = query; - return this.reports.getMemberPaymentAccrual(startDate, endDate); - } - @Get("/member/recent-member-data") @RequiredScopes( AppScopes.AllReports, diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index eb884db..8fc095a 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -28,23 +28,6 @@ type MarathonMatchStatsRow = { marathon_submission_rate: string | number | null; }; -type MemberPaymentAccrualRow = { - payment_created_at: Date | string | null; - payment_id: string | null; - payment_description: string | null; - challenge_id: string | null; - payment_status: string | null; - payment_type: string | null; - payee_handle: string | null; - payment_method: string | null; - billing_account_name: string | null; - customer_name: string | null; - reporting_account_name: string | null; - member_id: string | null; - challenge_created_date: string | null; - user_payment_gross_amount: string | number | null; -}; - type RecentMemberDataRow = { handle: string | null; email: string | null; @@ -555,32 +538,6 @@ export class TopcoderReportsService implements OnModuleDestroy { })); } - async getMemberPaymentAccrual(startDate?: string, endDate?: string) { - const query = this.sql.load("reports/topcoder/member-payment-accrual.sql"); - const rows = await this.db.query(query, [ - startDate ?? null, - endDate ?? null, - ]); - return rows.map((row) => ({ - paymentCreatedAt: this.normalizeDate(row.payment_created_at), - paymentId: row.payment_id ?? null, - paymentDescription: row.payment_description ?? null, - challengeId: row.challenge_id ?? null, - paymentStatus: row.payment_status ?? null, - paymentType: row.payment_type ?? null, - payeeHandle: row.payee_handle ?? null, - payeePaymentMethod: row.payment_method ?? null, - billingAccountName: row.billing_account_name ?? null, - customerName: row.customer_name ?? null, - reportingAccountName: row.reporting_account_name ?? null, - memberId: row.member_id ?? null, - challengeCreatedAt: row.challenge_created_date ?? null, - userPaymentGrossAmount: this.toNullableNumber( - row.user_payment_gross_amount, - ), - })); - } - async getRecentMemberData(startDate?: string) { const query = this.sql.load("reports/topcoder/recent-member-data.sql"); const rows = await this.db.query(query, [