diff --git a/.env.example b/.env.example index 9f4ebe1..7045523 100644 --- a/.env.example +++ b/.env.example @@ -11,5 +11,15 @@ AUTH0_AUDIENCE="" # e.g. https://m2m.topcoder-dev.com/ AUTH0_ISSUER="" # e.g. https://topcoder-dev.auth0.com/ ALLOWED_ROLES="Administrator,Topcoder User,Manager" # comma-separated +# Salesforce +SALESFORCE_CLIENT_ID="" +SALESFORCE_SUBJECT="" # e.g. integration@topcoder.com +SALESFORCE_CLIENT_KEY="" # PEM private key (use `\\n` for newlines) +SALESFORCE_AUDIENCE="https://login.salesforce.com" # or https://test.salesforce.com for sandbox + +# Optional SFDC field mapping overrides (defaults used by service) +SFDC_BILLING_ACCOUNT_NAME_FIELD="Billing_Account_name__c" +SFDC_BILLING_ACCOUNT_MARKUP_FIELD="Mark_Up__c" +SFDC_BILLING_ACCOUNT_ACTIVE_FIELD="Active__c" # Service PORT=3000 diff --git a/README.md b/README.md index e7a95c1..707b679 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - `GET /billing-accounts` - `POST /billing-accounts` - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals) + - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - `PATCH /billing-accounts/:billingAccountId/lock-amount` (0 amount = unlock) - `PATCH /billing-accounts/:billingAccountId/consume-amount` (deletes locks for challenge, then upserts consumed) @@ -40,6 +41,18 @@ pnpm run dev pnpm run build && pnpm start ``` +## Salesforce integration + +- This service can resolve billing accounts a user has access to via Salesforce. To enable Salesforce calls, configure the following environment variables in `.env` (see `.env.example`): + - `SALESFORCE_CLIENT_ID` — Connected App client ID + - `SALESFORCE_SUBJECT` — integration user username (subject for JWT) + - `SALESFORCE_CLIENT_KEY` — PEM private key for the connected app (escape newlines as `\\n` in single-line `.env` files) + - `SALESFORCE_AUDIENCE` — login URL (default `https://login.salesforce.com`, use `https://test.salesforce.com` for sandbox) + +- Optional overrides for field names returned by your Salesforce org are available via `SFDC_BILLING_ACCOUNT_NAME_FIELD`, `SFDC_BILLING_ACCOUNT_MARKUP_FIELD`, and `SFDC_BILLING_ACCOUNT_ACTIVE_FIELD`. + +- Once configured, you can call `GET /v6/billing-accounts/users/:userId` to obtain billing accounts the specified user has access to (the service authenticates to Salesforce using JWT Bearer flow and queries resource objects). + ## Import scripts - Legacy import (clients, billing accounts, challenge budgets): diff --git a/package.json b/package.json index 9be0158..b638581 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,21 @@ "generate:subcontractor-sql": "ts-node scripts/generate-subcontractor-sql.ts" }, "dependencies": { + "@nestjs/cli": "^11.0.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.3.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/swagger": "^7.4.2", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^10.3.0", + "@nestjs/swagger": "^7.4.2", "@prisma/client": "^6.18.0", "@types/express": "^5.0.3", + "axios": "^1.13.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.3", + "lodash": "^4.17.23", "pino": "^9.0.0", "prisma": "^6.18.0", "reflect-metadata": "^0.1.13", @@ -41,6 +44,9 @@ "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", + "@types/axios": "^0.14.4", + "@types/jsonwebtoken": "^9.0.10", + "@types/lodash": "^4.17.23", "@types/node": "^20.14.9", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93b7ff6..602b112 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@types/express': specifier: ^5.0.3 version: 5.0.6 + axios: + specifier: ^1.13.4 + version: 1.13.4 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -44,6 +47,12 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + lodash: + specifier: ^4.17.23 + version: 4.17.23 pino: specifier: ^9.0.0 version: 9.14.0 @@ -69,6 +78,15 @@ importers: '@eslint/js': specifier: ^9.18.0 version: 9.39.1 + '@types/axios': + specifier: ^0.14.4 + version: 0.14.4 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/lodash': + specifier: ^4.17.23 + version: 4.17.23 '@types/node': specifier: ^20.14.9 version: 20.19.26 @@ -558,6 +576,10 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/axios@0.14.4': + resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} + deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -594,6 +616,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -849,6 +874,9 @@ packages: axios@0.30.2: resolution: {integrity: sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==} + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + backoff@2.5.0: resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} engines: {node: '>= 0.6'} @@ -1685,6 +1713,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -3022,6 +3053,12 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/axios@0.14.4': + dependencies: + axios: 1.13.4 + transitivePeerDependencies: + - debug + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -3079,6 +3116,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 20.19.26 + '@types/lodash@4.17.23': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -3379,6 +3418,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + backoff@2.5.0: dependencies: precond: 0.2.3 @@ -4247,6 +4294,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -4361,7 +4410,7 @@ snapshots: node-emoji@1.11.0: dependencies: - lodash: 4.17.21 + lodash: 4.17.23 node-fetch-native@1.6.7: {} @@ -4551,7 +4600,7 @@ snapshots: dependencies: codependency: 2.1.0 json-stringify-safe: 5.0.1 - lodash: 4.17.21 + lodash: 4.17.23 reconnect-core: 1.3.0 randombytes@2.1.0: @@ -4809,7 +4858,7 @@ snapshots: bunyan: 1.8.15 jsonwebtoken: 9.0.3 jwks-rsa: 3.2.0 - lodash: 4.17.21 + lodash: 4.17.23 millisecond: 0.1.2 r7insight_node: 2.1.1 transitivePeerDependencies: diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 533bbbd..b247242 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -86,6 +86,29 @@ export class BillingAccountsController { return this.service.create(dto); } + @Get("users/:userId") + @UseGuards(RolesGuard, ScopesGuard) + @Roles(ADMIN_ROLE, COPILOT_ROLE) + @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) + @ApiOperation( + buildOperationDoc({ + summary: "List billing accounts accessible by user", + description: + "Retrieve billing accounts that the given user ID has access to (via Salesforce).", + jwtRoles: [ADMIN_ROLE, COPILOT_ROLE], + m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], + }), + ) + @ApiOkResponse({ description: "List of billing accounts returned" }) + @ApiParam({ + name: "userId", + description: "User ID (number)", + type: Number, + }) + async listByUserIds(@Param("userId", ParseIntPipe) userId: number) { + return this.service.listByUserId(userId); + } + @Get(":billingAccountId") @UseGuards(RolesGuard, ScopesGuard) @Roles(ADMIN_ROLE, COPILOT_ROLE) diff --git a/src/billing-accounts/billing-accounts.module.ts b/src/billing-accounts/billing-accounts.module.ts index 59c29e4..7398afd 100644 --- a/src/billing-accounts/billing-accounts.module.ts +++ b/src/billing-accounts/billing-accounts.module.ts @@ -2,10 +2,11 @@ import { Module } from "@nestjs/common"; import { BillingAccountsController } from "./billing-accounts.controller"; import { BillingAccountsService } from "./billing-accounts.service"; import { MembersLookupService } from "../common/members-lookup.service"; +import SalesforceService from "../common/salesforce.service"; @Module({ controllers: [BillingAccountsController], - providers: [BillingAccountsService, MembersLookupService], - exports: [BillingAccountsService, MembersLookupService], + providers: [BillingAccountsService, MembersLookupService, SalesforceService], + exports: [BillingAccountsService, MembersLookupService, SalesforceService], }) export class BillingAccountsModule {} diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index da0fddc..e211c28 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -7,14 +7,37 @@ import { UpdateBillingAccountDto } from "./dto/update-billing-account.dto"; import { LockAmountDto } from "./dto/lock-amount.dto"; import { ConsumeAmountDto } from "./dto/consume-amount.dto"; import { MembersLookupService } from "../common/members-lookup.service"; +import SalesforceService from "../common/salesforce.service"; @Injectable() export class BillingAccountsService { constructor( private readonly prisma: PrismaService, private readonly membersLookup: MembersLookupService, + private readonly salesforce: SalesforceService, ) {} + /** + * List billing accounts one or more user IDs have access to (via Salesforce resource object). + * Accepts a single userId (number). + */ + async listByUserId(userId: number) { + const { accessToken, instanceUrl } = await this.salesforce.authenticate(); + + // escape backslashes and single quotes and build IN clause + const escaped = `'${String(userId).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + const nameField = + process.env.SFDC_BILLING_ACCOUNT_NAME_FIELD || "Billing_Account_name__c"; + const sql = `SELECT Topcoder_Billing_Account__r.Id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.${nameField}, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c FROM Topcoder_Billing_Account_Resource__c tbar WHERE Topcoder_Billing_Account__r.Active__c=true AND UserID__c = ${escaped}`; + + const res = await this.salesforce.queryUserBillingAccounts( + sql, + accessToken, + instanceUrl, + ); + return res; + } + async list(q: QueryBillingAccountsDto) { const { clientId, diff --git a/src/common/salesforce.service.ts b/src/common/salesforce.service.ts new file mode 100644 index 0000000..71d1f2d --- /dev/null +++ b/src/common/salesforce.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from "@nestjs/common"; +import * as jwt from "jsonwebtoken"; +import axios from "axios"; +import { get } from "lodash"; + +const loginBaseUrl = + process.env.SALESFORCE_AUDIENCE || "https://login.salesforce.com"; +let privateKey = process.env.SALESFORCE_CLIENT_KEY || "privateKey"; +privateKey = privateKey.replace(/\\n/g, "\n"); + +const urlEncodeForm = (k: Record) => + Object.keys(k).reduce((a, b) => `${a}&${b}=${encodeURIComponent(k[b])}`, ""); + +@Injectable() +export class SalesforceService { + private parseIntStrictly(v: any, radix = 10, fallback: any = null) { + const n = parseInt(String(v), radix); + return Number.isNaN(n) ? fallback : n; + } + + async authenticate(): Promise<{ accessToken: string; instanceUrl: string }> { + const jwtToken = jwt.sign({}, privateKey, { + expiresIn: "1h", + issuer: process.env.SALESFORCE_CLIENT_ID, + audience: process.env.SALESFORCE_AUDIENCE, + subject: process.env.SALESFORCE_SUBJECT, + algorithm: "RS256", + }); + const res = await axios({ + method: "post", + url: `${loginBaseUrl}/services/oauth2/token`, + data: urlEncodeForm({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwtToken, + }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + return { + accessToken: res.data.access_token, + instanceUrl: res.data.instance_url, + }; + } + + async queryUserBillingAccounts( + sql: string, + accessToken: string, + instanceUrl: string, + logger?: any, + ) { + const res = await axios({ + url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`, + method: "get", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (logger) logger.debug(get(res, "data.records", [])); + const billingAccounts = get(res, "data.records", []).map((o: any) => ({ + sfBillingAccountId: get(o, "Topcoder_Billing_Account__r.Id"), + tcBillingAccountId: this.parseIntStrictly( + get(o, "Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c"), + 10, + null, + ), + name: get( + o, + `Topcoder_Billing_Account__r.${process.env.SFDC_BILLING_ACCOUNT_NAME_FIELD || "Billing_Account_name__c"}`, + ), + startDate: get(o, "Topcoder_Billing_Account__r.Start_Date__c"), + endDate: get(o, "Topcoder_Billing_Account__r.End_Date__c"), + })); + return billingAccounts; + } + + async queryBillingAccount( + sql: string, + accessToken: string, + instanceUrl: string, + logger?: any, + ) { + const res = await axios({ + url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`, + method: "get", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (logger) logger.debug(get(res, "data.records", [])); + const billingAccounts = get(res, "data.records", []).map((o: any) => ({ + tcBillingAccountId: this.parseIntStrictly( + get(o, "TopCoder_Billing_Account_Id__c"), + 10, + null, + ), + markup: get( + o, + process.env.SFDC_BILLING_ACCOUNT_MARKUP_FIELD || "Mark_Up__c", + ), + active: get( + o, + process.env.SFDC_BILLING_ACCOUNT_ACTIVE_FIELD || "Active__c", + ), + startDate: get(o, "Start_Date__c"), + endDate: get(o, "End_Date__c"), + })); + return billingAccounts.length > 0 ? billingAccounts[0] : {}; + } +} + +export default SalesforceService;