diff --git a/README.md b/README.md index d4a452f..9a8c2b7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ DATABASE_URL="postgresql://user:password@localhost:5432/lookups?schema=public" AUTH_SECRET="mysecret" # A JSON array string of valid token issuers. -VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://api.topcoder.com"]' +VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]' ## Running the Application diff --git a/package.json b/package.json index e5a3084..3e1ff27 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "pg": "^8.16.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.2", - "tc-core-library-js": "github:appirio-tech/tc-core-library-js#security", + "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#master", "@nestjs/schematics": "^11.0.9", "@types/jest": "^29.5.8", "@nestjs/testing": "^11.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fee6765..7f5070c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^7.8.2 version: 7.8.2 tc-core-library-js: - specifier: github:appirio-tech/tc-core-library-js#security - version: https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/87058f286c5b12bc8063956901dd641a1163bf9c + specifier: github:topcoder-platform/tc-core-library-js#master + version: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -3221,8 +3221,8 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} - tc-core-library-js@https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/87058f286c5b12bc8063956901dd641a1163bf9c: - resolution: {tarball: https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/87058f286c5b12bc8063956901dd641a1163bf9c} + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4: + resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4} version: 3.0.1 engines: {node: '>= 14'} @@ -6343,7 +6343,7 @@ snapshots: dependencies: '@types/express': 4.17.23 '@types/jsonwebtoken': 9.0.10 - debug: 4.4.1 + debug: 4.4.3 jose: 4.15.9 limiter: 1.1.5 lru-memoizer: 2.3.0 @@ -7115,7 +7115,7 @@ snapshots: tapable@2.2.2: {} - tc-core-library-js@https://codeload.github.com/appirio-tech/tc-core-library-js/tar.gz/87058f286c5b12bc8063956901dd641a1163bf9c: + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4: dependencies: axios: 0.30.2 bunyan: 1.8.15 diff --git a/sql/reports/sfdc/payments.sql b/sql/reports/sfdc/payments.sql index 0ac49dd..8080ab7 100644 --- a/sql/reports/sfdc/payments.sql +++ b/sql/reports/sfdc/payments.sql @@ -40,4 +40,5 @@ WHERE AND ($8::numeric IS NULL OR p.total_amount >= $8::numeric) AND ($9::numeric IS NULL OR p.total_amount <= $9::numeric) AND ($10::text[] IS NULL OR c.status::text = ANY($10::text[])) + AND ($11::text[] IS NULL OR p.payment_status::text = ANY($11::text[])) ORDER BY p.created_at DESC diff --git a/sql/reports/topcoder/30-day-payments.sql b/sql/reports/topcoder/30-day-payments.sql index 58eebea..36b5852 100644 --- a/sql/reports/topcoder/30-day-payments.sql +++ b/sql/reports/topcoder/30-day-payments.sql @@ -31,15 +31,25 @@ recent_payments AS ( WHERE w.type = 'PAYMENT' AND p.installment_number = 1 AND p.payment_status = 'PAID' - AND COALESCE(p.date_paid, p.created_at) >= (CURRENT_DATE - INTERVAL '3 months') + AND p.created_at >= (CURRENT_DATE - INTERVAL '3 months') ) SELECT cl."name" AS customer, cl."codeName" AS client_codename, + COALESCE( + NULLIF(TRIM(proj.details::jsonb #>> '{taasDefinition,oppurtunityDetails,customerName}'), ''), + NULLIF(TRIM(proj.details::jsonb #>> '{project_data,group_customer_name}'), ''), + ba."name" + ) AS customer_name, COALESCE(c."projectId"::text, ba."projectId") AS project_id, proj.name AS project_name, ba.id::text AS billing_account_id, ba."name" AS billing_account_name, + COALESCE( + NULLIF(TRIM(proj.details::jsonb #>> '{taasDefinition,oppurtunityDetails,customerName}'), ''), + NULLIF(TRIM(proj.details::jsonb #>> '{project_data,group_customer_name}'), ''), + ba."name" + ) AS customer_name, rp.challenge_id, c."name" AS challenge_name, c."createdAt" AS challenge_created_at, @@ -73,4 +83,4 @@ LEFT JOIN projects.projects proj ON proj.id = c."projectId"::bigint LEFT JOIN members.member mem ON mem."userId"::text = rp.winner_id -ORDER BY payment_created_at DESC; \ No newline at end of file +ORDER BY payment_created_at DESC; diff --git a/src/auth/auth.middleware.ts b/src/auth/auth.middleware.ts index 51d6a2b..26f072b 100644 --- a/src/auth/auth.middleware.ts +++ b/src/auth/auth.middleware.ts @@ -2,12 +2,31 @@ import { Injectable, NestMiddleware, UnauthorizedException, + Logger, } from "@nestjs/common"; import { Response, NextFunction } from "express"; import { ConfigService } from "@nestjs/config"; import { middleware } from "tc-core-library-js"; const { jwtAuthenticator: authenticator } = middleware; +const logger = new Logger("AuthMiddleware"); + +function decodeTokenPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = + payload + "=".repeat((4 - (payload.length % 4)) % 4); + const decoded = Buffer.from(padded, "base64").toString("utf8"); + return JSON.parse(decoded); + } catch { + return null; + } +} + @Injectable() export class AuthMiddleware implements NestMiddleware { private jwtAuthenticator; @@ -19,7 +38,7 @@ export class AuthMiddleware implements NestMiddleware { ); let issuersValue = this.configService.get( "VALID_ISSUERS", - '["https://api.topcoder.com","https://topcoder-dev.auth0.com/"]', + '["https://api.topcoder.com","https://api.topcoder-dev.com","https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/"]', ); // The tc-core-library-js authenticator expects a string that is a valid JSON array. @@ -39,6 +58,15 @@ export class AuthMiddleware implements NestMiddleware { if (req.headers.authorization) { this.jwtAuthenticator(req, res, (err) => { if (err) { + const token = req.headers.authorization?.replace(/^Bearer\s+/i, ""); + const payload = token ? decodeTokenPayload(token) : null; + logger.warn({ + message: "JWT authentication failed", + error: err?.message, + tokenIss: payload?.["iss"], + tokenAud: payload?.["aud"], + validIssuers: this.configService.get("VALID_ISSUERS"), + }); return next(new UnauthorizedException(err.message)); } next(); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 119fc5c..af1684a 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -151,6 +151,14 @@ const maxPaymentParam: ReportParameter = { location: "query", }; +const paymentStatusParam: ReportParameter = { + name: "status", + type: "string[]", + description: + "Payment status codes to filter by (for example ON_HOLD, PROCESSING, CANCELLED). Leave blank to include all statuses.", + location: "query", +}; + const paymentsFilters = [ billingAccountIdsParam, challengeNameParam, @@ -160,6 +168,7 @@ const paymentsFilters = [ handlesParam, minPaymentParam, maxPaymentParam, + paymentStatusParam, ]; const baFeesDateParams: ReportParameter[] = [ diff --git a/src/reports/sfdc/sfdc-reports.controller.spec.ts b/src/reports/sfdc/sfdc-reports.controller.spec.ts index 8d12ec5..54e3935 100644 --- a/src/reports/sfdc/sfdc-reports.controller.spec.ts +++ b/src/reports/sfdc/sfdc-reports.controller.spec.ts @@ -170,6 +170,7 @@ describe("SfdcReportsController", () => { challengeIds: "e74c3e37-73c9-474e-a838-a38dd4738906", handles: "user_01", challengeStatus: "COMPLETED", + status: "ON_HOLD", }); await controller.getPaymentsReport(dto); @@ -180,6 +181,7 @@ describe("SfdcReportsController", () => { challengeIds: ["e74c3e37-73c9-474e-a838-a38dd4738906"], handles: ["user_01"], challengeStatus: ["COMPLETED"], + status: ["ON_HOLD"], }), ); }); diff --git a/src/reports/sfdc/sfdc-reports.dto.spec.ts b/src/reports/sfdc/sfdc-reports.dto.spec.ts index 5e36da2..0a528bb 100644 --- a/src/reports/sfdc/sfdc-reports.dto.spec.ts +++ b/src/reports/sfdc/sfdc-reports.dto.spec.ts @@ -239,6 +239,7 @@ describe("PaymentsReportQueryDto validation", () => { minPaymentAmount: 100, maxPaymentAmount: 1000, challengeStatus: ["COMPLETED", "ACTIVE"], + status: ["ON_HOLD", "PROCESSING"], }); expect(errors).toHaveLength(0); }); @@ -388,6 +389,27 @@ describe("PaymentsReportQueryDto validation", () => { expect(errors).toHaveLength(0); expect(dto.challengeStatus).toEqual(["COMPLETED"]); }); + + it("accepts payment status values", async () => { + const { errors } = await validatePaymentDto({ + status: ["ON_HOLD", "PROCESSING"], + }); + expect(errors).toHaveLength(0); + }); + + it("rejects empty payment status entries", async () => { + const { errors } = await validatePaymentDto({ status: [""] }); + expect(errors.some((err) => err.property === "status")).toBe(true); + }); + + it("transforms single payment status into array", async () => { + const { dto, errors } = await validatePaymentDto({ + // @ts-expect-error intentional single value for transform check + status: "PROCESSING", + }); + expect(errors).toHaveLength(0); + expect(dto.status).toEqual(["PROCESSING"]); + }); }); describe("TaasJobsReportQueryDto validation", () => { diff --git a/src/reports/sfdc/sfdc-reports.dto.ts b/src/reports/sfdc/sfdc-reports.dto.ts index bc1d947..5c44c44 100644 --- a/src/reports/sfdc/sfdc-reports.dto.ts +++ b/src/reports/sfdc/sfdc-reports.dto.ts @@ -284,6 +284,18 @@ export class PaymentsReportQueryDto { @IsNotEmpty({ each: true }) @Transform(transformArray) challengeStatus?: string[]; + + @ApiProperty({ + required: false, + description: + "List of payment statuses to filter payments (for example ON_HOLD, PROCESSING, CANCELLED). If omitted, all statuses are included.", + example: ["ON_HOLD", "PROCESSING"], + }) + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(transformArray) + status?: string[]; } export class PaymentsReportResponse { diff --git a/src/reports/sfdc/sfdc-reports.service.spec.ts b/src/reports/sfdc/sfdc-reports.service.spec.ts index c2c93e0..c40f856 100644 --- a/src/reports/sfdc/sfdc-reports.service.spec.ts +++ b/src/reports/sfdc/sfdc-reports.service.spec.ts @@ -316,6 +316,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); expect(result).toEqual(normalizedPaymentData); }); @@ -336,6 +337,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); @@ -353,6 +355,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { 100, 500, ["COMPLETED"], + ["PROCESSING"], ]); }); @@ -370,6 +373,25 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, ["COMPLETED"], + undefined, + ]); + }); + + it("handles payment status filter", async () => { + await service.getPaymentsReport(mockPaymentQueryDto.paymentStatus); + + expect(mockDbService.query).toHaveBeenCalledWith(mockSqlQuery, [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ["ON_HOLD"], ]); }); @@ -387,6 +409,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, [null as unknown as string], + undefined, ]); }); @@ -426,6 +449,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); @@ -443,6 +467,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); }); diff --git a/src/reports/sfdc/sfdc-reports.service.ts b/src/reports/sfdc/sfdc-reports.service.ts index f5ab52c..59cd80b 100644 --- a/src/reports/sfdc/sfdc-reports.service.ts +++ b/src/reports/sfdc/sfdc-reports.service.ts @@ -122,6 +122,7 @@ export class SfdcReportsService { filters.minPaymentAmount, filters.maxPaymentAmount, filters.challengeStatus, + filters.status, ]); this.logger.debug("Mapped payments to the final report format"); diff --git a/src/reports/sfdc/test-helpers/mock-data.ts b/src/reports/sfdc/test-helpers/mock-data.ts index cd2d638..1875c7b 100644 --- a/src/reports/sfdc/test-helpers/mock-data.ts +++ b/src/reports/sfdc/test-helpers/mock-data.ts @@ -142,6 +142,9 @@ export const mockPaymentQueryDto: Record = { nullChallengeStatus: { challengeStatus: [null as unknown as string], }, + paymentStatus: { + status: ["ON_HOLD"], + }, full: { billingAccountIds: ["80001012", "!90000000"], challengeIds: ["e74c3e37-73c9-474e-a838-a38dd4738906"], @@ -152,6 +155,7 @@ export const mockPaymentQueryDto: Record = { minPaymentAmount: 100, maxPaymentAmount: 500, challengeStatus: ["COMPLETED"], + status: ["PROCESSING"], }, };