From 00ebad9309602874686b1059a4bdd0ccc5da6652 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 18 Sep 2026 06:09:29 +1000 Subject: [PATCH] PM-6363: Salesforce opportunity lookup and Salesforce-aligned SMU options Adds a read-only Salesforce opportunity endpoint that the Work app uses to populate project details and the Sales app uses for its opportunity popup: - GET /v6/projects/salesforce/opportunities/:opportunityId returns the opportunity name, description, Subcontracting End Customer, Reporting SMU, Close Date, stage and a link to the record. - SalesforceClient runs SOQL over the OAuth client-credentials flow, mirroring the Reports API client: trusted-origin allow-list, bounded retries, in-memory token reuse and a single renewal on 401. Record ids are validated against a strict pattern before they reach the query. - Access requires a manager-tier role (Work) or a talent-manager role (Sales). Renames the SMU options to the Salesforce Reporting SMU codes (APME, EURP, AMR1, AMR2) so an imported opportunity maps onto a project without translation. Projects saved with the previous labels are upgraded in place on the next write, and an unrecognized Reporting SMU is surfaced as Others plus the raw value. Project.details.salesforceOpportunityId is validated alongside the other shared metadata. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 + src/api/api.module.ts | 3 + .../salesforce-opportunity-response.dto.ts | 70 +++++ .../salesforce-opportunity.controller.spec.ts | 42 +++ .../salesforce-opportunity.controller.ts | 85 ++++++ .../salesforce-opportunity.service.spec.ts | 114 +++++++ .../salesforce-opportunity.service.ts | 144 +++++++++ src/api/salesforce/salesforce.client.spec.ts | 147 +++++++++ src/api/salesforce/salesforce.client.ts | 287 ++++++++++++++++++ src/api/salesforce/salesforce.module.ts | 17 ++ src/shared/config/salesforce.config.ts | 44 +++ .../utils/showcase-metadata.utils.spec.ts | 57 +++- src/shared/utils/showcase-metadata.utils.ts | 75 ++++- 13 files changed, 1078 insertions(+), 11 deletions(-) create mode 100644 src/api/salesforce/dto/salesforce-opportunity-response.dto.ts create mode 100644 src/api/salesforce/salesforce-opportunity.controller.spec.ts create mode 100644 src/api/salesforce/salesforce-opportunity.controller.ts create mode 100644 src/api/salesforce/salesforce-opportunity.service.spec.ts create mode 100644 src/api/salesforce/salesforce-opportunity.service.ts create mode 100644 src/api/salesforce/salesforce.client.spec.ts create mode 100644 src/api/salesforce/salesforce.client.ts create mode 100644 src/api/salesforce/salesforce.module.ts create mode 100644 src/shared/config/salesforce.config.ts diff --git a/README.md b/README.md index 2400b16..fd3c354 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,10 @@ Reference source: `.env.example`. | `SALESFORCE_CLIENT_KEY` | ✅ | - | Salesforce private key | | `SALESFORCE_LOGIN_BASE_URL` | - | `https://login.salesforce.com` | Salesforce login URL | | `SALESFORCE_API_VERSION` | - | `v37.0` | Salesforce API version | +| `SALESFORCE_API_CONSUMER_KEY` | - | - | Salesforce connected-app consumer key (client-credentials flow) used by the opportunity lookup endpoint | +| `SALESFORCE_API_CONSUMER_SECRET` | - | - | Salesforce connected-app consumer secret used by the opportunity lookup endpoint | +| `SALESFORCE_LOGIN_URL` | - | `https://topcoder.my.salesforce.com` | Salesforce origin used for the client-credentials token exchange | +| `SALESFORCE_REST_API_VERSION` | - | `65.0` | Salesforce REST API version for the opportunity lookup (no leading `v`) | | `SFDC_BILLING_ACCOUNT_NAME_FIELD` | - | `Billing_Account_name__c` | SOQL field name | | `SFDC_BILLING_ACCOUNT_MARKUP_FIELD` | - | `Mark_Up__c` | SOQL field name | | `SFDC_BILLING_ACCOUNT_ACTIVE_FIELD` | - | `Active__c` | SOQL field name | diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 50e5c63..ab911bc 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -13,6 +13,7 @@ import { ProjectMemberModule } from './project-member/project-member.module'; import { ProjectPhaseModule } from './project-phase/project-phase.module'; import { ProjectSettingModule } from './project-setting/project-setting.module'; import { ProjectShowcasePostModule } from './project-showcase-post/project-showcase-post.module'; +import { SalesforceModule } from './salesforce/salesforce.module'; import { ProjectModule } from './project/project.module'; /** @@ -28,6 +29,7 @@ import { ProjectModule } from './project/project.module'; * - ProjectSettingModule - per-project settings * - CopilotModule - copilot request/opportunity/application flow * - MetadataModule - reference metadata (categories, skills, etc.) + * - SalesforceModule - read-only Salesforce opportunity lookups * * Also registers HealthCheckController directly (not via a sub-module). * @@ -48,6 +50,7 @@ import { ProjectModule } from './project/project.module'; ProjectPhaseModule, PhaseProductModule, ProjectSettingModule, + SalesforceModule, // TODO (quality): WorkStreamModule is included in the Swagger document in main.ts but is not imported here. Add WorkStreamModule to this imports array so its routes are part of the same module graph, or remove it from the Swagger include list. ], controllers: [HealthCheckController], diff --git a/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts b/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts new file mode 100644 index 0000000..a189d8c --- /dev/null +++ b/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Salesforce opportunity fields used to populate project details. + * + * Only read-only, non-sensitive opportunity attributes are exposed. The + * `smu`/`smuOther` pair is already mapped onto the SMU options accepted by + * `Project.details`, so the client can apply it without translation. + */ +export class SalesforceOpportunityResponseDto { + @ApiProperty({ + description: 'Salesforce opportunity record id (18 characters).', + example: '006UN00000XamntYAB', + }) + id: string; + + @ApiProperty({ + description: 'Opportunity name.', + example: 'EMEA - Amazon Web Services - PS BFSI', + }) + name: string; + + @ApiPropertyOptional({ + description: + 'Opportunity description, shown in the Sales opportunity popup.', + }) + description?: string; + + @ApiPropertyOptional({ + description: + 'Subcontracting End Customer account name; maps to the project Customer field.', + example: 'Novartis Pharmaceuticals', + }) + customer?: string; + + @ApiPropertyOptional({ + description: + 'Reporting SMU mapped to a supported project SMU option, or "Others" when unrecognized.', + example: 'AMR1', + }) + smu?: string; + + @ApiPropertyOptional({ + description: + 'Raw Reporting SMU value, populated only when `smu` is "Others".', + example: 'INTERNAL', + }) + smuOther?: string; + + @ApiPropertyOptional({ + description: 'Raw Salesforce Reporting SMU value.', + example: 'AMR1', + }) + reportingSmu?: string; + + @ApiPropertyOptional({ + description: 'Close Date as a YYYY-MM-DD calendar date.', + example: '2026-07-31', + }) + closeDate?: string; + + @ApiPropertyOptional({ description: 'Opportunity stage name.' }) + stageName?: string; + + @ApiProperty({ + description: 'Deep link to the opportunity record in Salesforce.', + example: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }) + url: string; +} diff --git a/src/api/salesforce/salesforce-opportunity.controller.spec.ts b/src/api/salesforce/salesforce-opportunity.controller.spec.ts new file mode 100644 index 0000000..fdc0bf7 --- /dev/null +++ b/src/api/salesforce/salesforce-opportunity.controller.spec.ts @@ -0,0 +1,42 @@ +import { Reflector } from '@nestjs/core'; +import { MANAGER_ROLES, UserRole } from 'src/shared/enums/userRole.enum'; +import { ROLES_KEY } from 'src/shared/guards/tokenRoles.guard'; +import { SalesforceOpportunityController } from './salesforce-opportunity.controller'; +import { SalesforceOpportunityService } from './salesforce-opportunity.service'; + +describe('SalesforceOpportunityController', () => { + const opportunity = { + id: '006UN00000XamntYAB', + name: 'EMEA - Amazon Web Services - PS BFSI', + url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }; + + it('returns the opportunity resolved by the service', async () => { + const getOpportunity = jest.fn().mockResolvedValue(opportunity); + const controller = new SalesforceOpportunityController({ + getOpportunity, + } as unknown as SalesforceOpportunityService); + + await expect(controller.getOpportunity('006UN00000XamntYAB')).resolves.toBe( + opportunity, + ); + expect(getOpportunity).toHaveBeenCalledWith('006UN00000XamntYAB'); + }); + + it('is restricted to manager-tier and talent-manager roles', () => { + const handler = Object.getOwnPropertyDescriptor( + SalesforceOpportunityController.prototype, + 'getOpportunity', + )?.value as () => unknown; + const roles = new Reflector().get(ROLES_KEY, handler); + + expect(roles).toEqual( + expect.arrayContaining([ + ...MANAGER_ROLES, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ]), + ); + expect(roles).not.toContain(UserRole.TOPCODER_USER); + }); +}); diff --git a/src/api/salesforce/salesforce-opportunity.controller.ts b/src/api/salesforce/salesforce-opportunity.controller.ts new file mode 100644 index 0000000..0a7661f --- /dev/null +++ b/src/api/salesforce/salesforce-opportunity.controller.ts @@ -0,0 +1,85 @@ +import { Controller, Get, Header, Param } from '@nestjs/common'; +import { + ApiBadGatewayResponse, + ApiBadRequestResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiServiceUnavailableResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { MANAGER_ROLES, UserRole } from 'src/shared/enums/userRole.enum'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { SalesforceOpportunityResponseDto } from './dto/salesforce-opportunity-response.dto'; +import { SalesforceOpportunityService } from './salesforce-opportunity.service'; + +/** + * Roles allowed to read Salesforce opportunity details. + * + * Manager-tier roles cover the Work app users who edit project details; + * talent-manager roles cover the Sales app opportunity listing. + */ +const OPPORTUNITY_ROLES = [ + ...MANAGER_ROLES, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, +]; + +/** + * Read-only Salesforce opportunity endpoints. + * + * Used by the Work app to populate project details from an opportunity, and by + * the Sales app to show an opportunity description. Responses are never cached + * by intermediaries because Salesforce remains the source of truth. + */ +@ApiTags('Salesforce') +@ApiBearerAuth() +@ApiUnauthorizedResponse({ description: 'Missing or invalid bearer token.' }) +@ApiForbiddenResponse({ + description: 'Requires a manager-tier or talent-manager role.', +}) +@Controller('/projects/salesforce/opportunities') +export class SalesforceOpportunityController { + /** + * @param opportunities Read-only Salesforce opportunity service. + */ + constructor(private readonly opportunities: SalesforceOpportunityService) {} + + /** + * Returns a single Salesforce opportunity by record id. + * + * @param opportunityId 15 or 18 character Salesforce opportunity id + * @returns the opportunity attributes used by Work and Sales + */ + @Get(':opportunityId') + @Roles(...OPPORTUNITY_ROLES) + @Header('Cache-Control', 'private, no-store') + @ApiOperation({ + summary: 'Get a Salesforce opportunity', + description: + 'Reads Subcontracting End Customer, Reporting SMU, Close Date and Description for an opportunity. Read-only; nothing is written back to Salesforce.', + }) + @ApiParam({ + name: 'opportunityId', + description: 'Salesforce opportunity record id (15 or 18 characters).', + example: '006UN00000XamntYAB', + }) + @ApiOkResponse({ type: SalesforceOpportunityResponseDto }) + @ApiBadRequestResponse({ description: 'Malformed opportunity id.' }) + @ApiNotFoundResponse({ description: 'No opportunity exists for that ID.' }) + @ApiBadGatewayResponse({ + description: 'Salesforce is unavailable or returned an invalid response.', + }) + @ApiServiceUnavailableResponse({ + description: 'Server Salesforce configuration is missing or invalid.', + }) + async getOpportunity( + @Param('opportunityId') opportunityId: string, + ): Promise { + return this.opportunities.getOpportunity(opportunityId); + } +} diff --git a/src/api/salesforce/salesforce-opportunity.service.spec.ts b/src/api/salesforce/salesforce-opportunity.service.spec.ts new file mode 100644 index 0000000..737c1dd --- /dev/null +++ b/src/api/salesforce/salesforce-opportunity.service.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { SalesforceOpportunityService } from './salesforce-opportunity.service'; +import { SalesforceClient } from './salesforce.client'; + +describe('SalesforceOpportunityService', () => { + const record = { + Id: '006UN00000XamntYAB', + Name: 'EMEA - Amazon Web Services - PS BFSI', + Description: ' Pipeline for the BFSI practice ', + CloseDate: '2026-07-31', + StageName: 'Qualification', + Reporting_SMU__c: 'AMR1', + Subcontracting_End_Customer__r: { Name: 'Novartis Pharmaceuticals' }, + }; + + function build(records: unknown[] = [record]): { + service: SalesforceOpportunityService; + query: jest.Mock; + } { + const query = jest.fn().mockResolvedValue(records); + const client = { + query, + instanceOrigin: () => 'https://topcoder.my.salesforce.com', + } as unknown as SalesforceClient; + return { service: new SalesforceOpportunityService(client), query }; + } + + it('maps the opportunity onto project detail fields', async () => { + const { service, query } = build(); + + await expect( + service.getOpportunity(' 006UN00000XamntYAB '), + ).resolves.toEqual({ + id: '006UN00000XamntYAB', + name: 'EMEA - Amazon Web Services - PS BFSI', + description: 'Pipeline for the BFSI practice', + customer: 'Novartis Pharmaceuticals', + smu: 'AMR1', + reportingSmu: 'AMR1', + closeDate: '2026-07-31', + stageName: 'Qualification', + url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("WHERE Id = '006UN00000XamntYAB'"), + ); + }); + + it('upgrades a legacy Reporting SMU to the supported option', async () => { + const { service } = build([{ ...record, Reporting_SMU__c: 'Americas2' }]); + + await expect( + service.getOpportunity('006UN00000XamntYAB'), + ).resolves.toMatchObject({ smu: 'AMR2', reportingSmu: 'Americas2' }); + }); + + it('surfaces an unrecognized Reporting SMU as a custom value', async () => { + const { service } = build([{ ...record, Reporting_SMU__c: 'INTERNAL' }]); + + await expect( + service.getOpportunity('006UN00000XamntYAB'), + ).resolves.toMatchObject({ smu: 'Others', smuOther: 'INTERNAL' }); + }); + + it('omits fields Salesforce left empty', async () => { + const { service } = build([ + { Id: '006UN00000XamntYAB', Name: 'Unnamed', Description: ' ' }, + ]); + + await expect(service.getOpportunity('006UN00000XamntYAB')).resolves.toEqual( + { + id: '006UN00000XamntYAB', + name: 'Unnamed', + description: undefined, + customer: undefined, + reportingSmu: undefined, + closeDate: undefined, + stageName: undefined, + url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }, + ); + }); + + it('accepts a 15 character id', async () => { + const { service, query } = build([{ ...record, Id: '006UN00000Xamnt' }]); + + await expect( + service.getOpportunity('006UN00000Xamnt'), + ).resolves.toMatchObject({ id: '006UN00000Xamnt' }); + expect(query).toHaveBeenCalledTimes(1); + }); + + it.each([ + '', + '006UN00000Xamn', + '001UN00000XamntYAB', + "006UN00000Xamnt' OR Id != '", + ])('rejects the malformed id %j without querying', async (id) => { + const { service, query } = build(); + + await expect(service.getOpportunity(id)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(query).not.toHaveBeenCalled(); + }); + + it('reports an unknown opportunity as not found', async () => { + const { service } = build([]); + + await expect( + service.getOpportunity('006UN00000XamntYAB'), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/api/salesforce/salesforce-opportunity.service.ts b/src/api/salesforce/salesforce-opportunity.service.ts new file mode 100644 index 0000000..1a13eab --- /dev/null +++ b/src/api/salesforce/salesforce-opportunity.service.ts @@ -0,0 +1,144 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + SALESFORCE_OPPORTUNITY_ID_PATTERN, + SMU_VALUES, + normalizeSmuValue, +} from 'src/shared/utils/showcase-metadata.utils'; +import { SalesforceOpportunityResponseDto } from './dto/salesforce-opportunity-response.dto'; +import { SalesforceClient } from './salesforce.client'; + +/** + * Salesforce opportunity record shape returned by the SOQL projection below. + */ +interface OpportunityRecord { + Id?: unknown; + Name?: unknown; + Description?: unknown; + CloseDate?: unknown; + StageName?: unknown; + Reporting_SMU__c?: unknown; + Subcontracting_End_Customer__r?: { Name?: unknown } | null; +} + +/** + * Fields read from `Opportunity` for the Work and Sales integrations. + * + * Only the attributes listed in PM-6363 are projected, so no additional + * Salesforce data reaches the API surface. + */ +const OPPORTUNITY_FIELDS = [ + 'Id', + 'Name', + 'Description', + 'CloseDate', + 'StageName', + 'Reporting_SMU__c', + 'Subcontracting_End_Customer__r.Name', +].join(','); + +/** + * Read-only Salesforce opportunity lookups. + * + * Backs the Work app's project-detail auto-population and the Sales app's + * opportunity description popup. Nothing is written back to Salesforce. + */ +@Injectable() +export class SalesforceOpportunityService { + /** + * @param client Shared read-only Salesforce REST client. + */ + constructor(private readonly client: SalesforceClient) {} + + /** + * Reads a string field, trimming it and discarding empty values. + * + * @param value raw Salesforce field value + * @returns the trimmed string, or `undefined` when absent or blank + */ + private readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed || undefined; + } + + /** + * Maps a Salesforce Reporting SMU onto the SMU options accepted by + * `Project.details`. + * + * Unrecognized codes (for example `INTERNAL`) are surfaced as `Others` with + * the raw value in `smuOther`, so nothing is silently dropped. + * + * @param reportingSmu raw `Reporting_SMU__c` value + * @returns the mapped option pair, or an empty object when unset + */ + private mapSmu(reportingSmu: string | undefined): { + smu?: string; + smuOther?: string; + } { + if (!reportingSmu) { + return {}; + } + + const normalized = normalizeSmuValue(reportingSmu); + if (SMU_VALUES.includes(normalized) && normalized !== 'Others') { + return { smu: normalized }; + } + + return { smu: 'Others', smuOther: reportingSmu.slice(0, 255) }; + } + + /** + * Retrieves a single opportunity by record id. + * + * @param opportunityId 15 or 18 character Salesforce opportunity id + * @returns the opportunity attributes used by Work and Sales + * @throws BadRequestException for a malformed id; NotFoundException when no + * opportunity is visible to the integration user; ServiceUnavailableException + * or BadGatewayException for configuration and upstream failures + */ + async getOpportunity( + opportunityId: string, + ): Promise { + const id = (opportunityId || '').trim(); + if (!SALESFORCE_OPPORTUNITY_ID_PATTERN.test(id)) { + throw new BadRequestException( + 'Enter a valid 15 or 18 character Salesforce opportunity id.', + ); + } + + // SECURITY: `id` is constrained to [A-Za-z0-9] by the pattern above, so it + // cannot terminate the quoted SOQL literal. + const records = await this.client.query( + `SELECT ${OPPORTUNITY_FIELDS} FROM Opportunity WHERE Id = '${id}' LIMIT 1`, + ); + + const record = records[0]; + if (!record) { + throw new NotFoundException( + 'No Salesforce opportunity was found for that ID.', + ); + } + + const resolvedId = this.readString(record.Id) || id; + const reportingSmu = this.readString(record.Reporting_SMU__c); + + return { + id: resolvedId, + name: this.readString(record.Name) || '', + description: this.readString(record.Description), + customer: this.readString(record.Subcontracting_End_Customer__r?.Name), + ...this.mapSmu(reportingSmu), + reportingSmu, + closeDate: this.readString(record.CloseDate), + stageName: this.readString(record.StageName), + url: `${this.client.instanceOrigin()}/${resolvedId}`, + }; + } +} diff --git a/src/api/salesforce/salesforce.client.spec.ts b/src/api/salesforce/salesforce.client.spec.ts new file mode 100644 index 0000000..bee173c --- /dev/null +++ b/src/api/salesforce/salesforce.client.spec.ts @@ -0,0 +1,147 @@ +import { + BadGatewayException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { SalesforceClient } from './salesforce.client'; + +/** + * Builds a fetch Response stub whose body can be cancelled by the client. + */ +function response( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + body: { cancel: jest.fn().mockResolvedValue(undefined) }, + json: () => + body === undefined + ? Promise.reject(new Error('invalid json')) + : Promise.resolve(body), + } as unknown as Response; +} + +const token = { + access_token: 'token', + instance_url: 'https://topcoder.my.salesforce.com', +}; + +describe('SalesforceClient', () => { + const originalEnv = process.env; + let fetchMock: jest.Mock; + + /** + * Creates a client that reads the environment set by the current test. + */ + function loadClient(): { client: SalesforceClient } { + return { client: new SalesforceClient() }; + } + + beforeEach(() => { + process.env = { + ...originalEnv, + SALESFORCE_API_CONSUMER_KEY: 'key', + SALESFORCE_API_CONSUMER_SECRET: 'secret', + SALESFORCE_LOGIN_URL: 'https://topcoder.my.salesforce.com', + SALESFORCE_REST_API_VERSION: '65.0', + }; + fetchMock = jest.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('authenticates once and reuses the session for later queries', async () => { + fetchMock + .mockResolvedValueOnce(response(200, token)) + .mockResolvedValue(response(200, { records: [{ Id: '1' }] })); + const { client } = loadClient(); + + await expect(client.query('SELECT Id FROM Opportunity')).resolves.toEqual([ + { Id: '1' }, + ]); + await expect(client.query('SELECT Id FROM Opportunity')).resolves.toEqual([ + { Id: '1' }, + ]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://topcoder.my.salesforce.com/services/oauth2/token', + ); + expect(fetchMock.mock.calls[1][0]).toContain( + '/services/data/v65.0/query?q=', + ); + }); + + it('renews the session once when Salesforce rejects the token', async () => { + fetchMock + .mockResolvedValueOnce(response(200, token)) + .mockResolvedValueOnce(response(401, {})) + .mockResolvedValueOnce(response(200, token)) + .mockResolvedValueOnce(response(200, { records: [] })); + const { client } = loadClient(); + + await expect(client.query('SELECT Id FROM Opportunity')).resolves.toEqual( + [], + ); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('does not send credentials to an untrusted origin', async () => { + process.env.SALESFORCE_LOGIN_URL = 'https://attacker.example.com'; + const { client } = loadClient(); + + await expect( + client.query('SELECT Id FROM Opportunity'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reports missing credentials as unconfigured', async () => { + delete process.env.SALESFORCE_API_CONSUMER_SECRET; + const { client } = loadClient(); + + expect(client.isConfigured()).toBe(false); + await expect( + client.query('SELECT Id FROM Opportunity'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects a malformed API version before calling Salesforce', async () => { + process.env.SALESFORCE_REST_API_VERSION = 'v65'; + const { client } = loadClient(); + + await expect( + client.query('SELECT Id FROM Opportunity'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces a sanitized error when the query is rejected', async () => { + fetchMock + .mockResolvedValueOnce(response(200, token)) + .mockResolvedValue(response(400, { message: 'MALFORMED_QUERY' })); + const { client } = loadClient(); + + await expect( + client.query('SELECT Id FROM Opportunity'), + ).rejects.toBeInstanceOf(BadGatewayException); + }); + + it('gives up after three transient failures', async () => { + fetchMock.mockResolvedValue(response(503, {})); + const { client } = loadClient(); + + await expect( + client.query('SELECT Id FROM Opportunity'), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/api/salesforce/salesforce.client.ts b/src/api/salesforce/salesforce.client.ts new file mode 100644 index 0000000..da3c668 --- /dev/null +++ b/src/api/salesforce/salesforce.client.ts @@ -0,0 +1,287 @@ +import { + BadGatewayException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { setTimeout as delay } from 'node:timers/promises'; +import { SALESFORCE_CONFIG } from 'src/shared/config/salesforce.config'; +import { LoggerService } from 'src/shared/modules/global/logger.service'; + +/** + * Salesforce OAuth session kept in server memory only. + */ +interface SalesforceSession { + accessToken: string; + instanceUrl: string; +} + +/** + * Server-only Salesforce REST client using the OAuth client-credentials flow. + * + * Mirrors the Reports API Salesforce client: it reads records through SOQL and + * never creates, updates or deletes anything. Access tokens stay in memory, + * are shared between concurrent callers and are renewed once on a 401. + */ +@Injectable() +export class SalesforceClient { + private readonly logger = LoggerService.forRoot('SalesforceClient'); + private session?: SalesforceSession; + private authenticating?: Promise; + + /** + * Reports whether the client-credentials configuration is complete. + * + * @returns `true` when both the consumer key and secret are present + */ + isConfigured(): boolean { + return Boolean( + SALESFORCE_CONFIG.consumerKey && SALESFORCE_CONFIG.consumerSecret, + ); + } + + /** + * Validates a configured or OAuth-provided Salesforce origin before + * credentials or tokens are sent to it. + * + * @param origin HTTPS Salesforce origin without a path, credentials, query or custom port + * @returns the normalized trusted origin + * @throws ServiceUnavailableException when the origin is missing or untrusted + */ + private salesforceOrigin(origin: string): string { + try { + const url = new URL(origin); + if ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.port && + url.pathname === '/' && + !url.search && + !url.hash && + (url.hostname.endsWith('.my.salesforce.com') || + ['login.salesforce.com', 'test.salesforce.com'].includes( + url.hostname, + )) + ) { + return url.origin; + } + } catch { + /* Invalid URLs report the same sanitized configuration error. */ + } + + throw new ServiceUnavailableException( + 'Salesforce integration is not configured.', + ); + } + + /** + * Returns the trusted Salesforce origin records are linked from. + * + * @returns the instance origin of the active session, or the configured login origin + */ + instanceOrigin(): string { + return ( + this.session?.instanceUrl ?? + this.salesforceOrigin(SALESFORCE_CONFIG.loginUrl) + ); + } + + /** + * Performs a bounded request, retrying network errors, throttling and 5xx. + * + * @param url trusted Salesforce API URL + * @param init HTTP request options; bodies and credentials are never logged + * @returns the first non-transient HTTP response + * @throws BadGatewayException after three failed attempts + */ + private async request(url: string, init: RequestInit): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + let retryAfter = 0; + try { + const response = await fetch(url, { + ...init, + redirect: 'error', + signal: AbortSignal.timeout(15000), + }); + if (response.status !== 429 && response.status < 500) { + return response; + } + + const header = response.headers.get('retry-after'); + retryAfter = header ? Number(header) * 1000 : 0; + await response.body?.cancel(); + this.logger.warn( + `Salesforce temporarily unavailable (HTTP ${response.status}).`, + ); + } catch { + this.logger.warn('Salesforce request timed out or failed to connect.'); + } + + if (attempt < 2) { + await delay( + Math.min( + 2000, + Math.max( + 250 * 2 ** attempt, + Number.isFinite(retryAfter) ? retryAfter : 0, + ), + ), + ); + } + } + + throw new BadGatewayException( + 'Salesforce is temporarily unavailable. Please try again.', + ); + } + + /** + * Obtains a client-credentials session, coalescing concurrent token requests. + * + * @returns a trusted instance URL and an access token kept only in memory + * @throws ServiceUnavailableException when credentials are missing; + * BadGatewayException when the OAuth exchange fails + */ + private async authenticate(): Promise { + if (this.session) { + return this.session; + } + + if (this.authenticating) { + return this.authenticating; + } + + if (!this.isConfigured()) { + throw new ServiceUnavailableException( + 'Salesforce integration is not configured.', + ); + } + + const origin = this.salesforceOrigin(SALESFORCE_CONFIG.loginUrl); + + this.authenticating = (async () => { + const response = await this.request(`${origin}/services/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: SALESFORCE_CONFIG.consumerKey, + client_secret: SALESFORCE_CONFIG.consumerSecret, + }), + }); + + if (!response.ok) { + await response.body?.cancel(); + this.logger.warn( + `Salesforce authentication rejected (HTTP ${response.status}).`, + ); + throw new BadGatewayException( + 'Salesforce authentication failed. Contact your administrator.', + ); + } + + let token: { access_token?: unknown; instance_url?: unknown }; + try { + token = (await response.json()) as typeof token; + } catch { + throw new BadGatewayException( + 'Salesforce returned an invalid authentication response.', + ); + } + + if (typeof token?.access_token !== 'string' || !token.access_token) { + throw new BadGatewayException( + 'Salesforce returned an invalid authentication response.', + ); + } + + this.session = { + accessToken: token.access_token, + instanceUrl: this.salesforceOrigin( + typeof token.instance_url === 'string' ? token.instance_url : '', + ), + }; + + return this.session; + })(); + + try { + return await this.authenticating; + } finally { + this.authenticating = undefined; + } + } + + /** + * Runs a read-only SOQL query, renewing an expired session once. + * + * Callers are responsible for building a safe query: values interpolated + * into `soql` must already be validated against a strict allow-list format. + * + * @param soql SOQL statement to execute + * @returns the `records` array returned by Salesforce + * @throws ServiceUnavailableException for invalid configuration; + * BadGatewayException for upstream or malformed responses + */ + async query>( + soql: string, + ): Promise { + const version = SALESFORCE_CONFIG.apiVersion; + if (!/^\d{2,3}\.0$/.test(version)) { + throw new ServiceUnavailableException( + 'Salesforce API version is not configured correctly.', + ); + } + + for (let attempt = 0; attempt < 2; attempt++) { + const session = await this.authenticate(); + const url = `${session.instanceUrl}/services/data/v${version}/query?q=${encodeURIComponent(soql)}`; + const response = await this.request(url, { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: 'application/json', + }, + }); + + if (response.status === 401 && attempt === 0) { + await response.body?.cancel(); + if (this.session === session) { + this.session = undefined; + } + + continue; + } + + if (!response.ok) { + await response.body?.cancel(); + this.logger.warn( + `Salesforce query rejected (HTTP ${response.status}).`, + ); + throw new BadGatewayException( + 'Salesforce data could not be loaded. Please try again.', + ); + } + + let payload: { records?: unknown }; + try { + payload = (await response.json()) as typeof payload; + } catch { + throw new BadGatewayException( + 'Salesforce returned an invalid response.', + ); + } + + if (!Array.isArray(payload?.records)) { + throw new BadGatewayException( + 'Salesforce returned an invalid response.', + ); + } + + return payload.records as TRecord[]; + } + + throw new BadGatewayException( + 'Salesforce authentication failed. Contact your administrator.', + ); + } +} diff --git a/src/api/salesforce/salesforce.module.ts b/src/api/salesforce/salesforce.module.ts new file mode 100644 index 0000000..a8ea4f6 --- /dev/null +++ b/src/api/salesforce/salesforce.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { SalesforceOpportunityController } from './salesforce-opportunity.controller'; +import { SalesforceOpportunityService } from './salesforce-opportunity.service'; +import { SalesforceClient } from './salesforce.client'; + +/** + * Wires the read-only Salesforce opportunity integration. + * + * The client keeps its OAuth session in memory only; no Salesforce data is + * persisted by this module. + */ +@Module({ + controllers: [SalesforceOpportunityController], + providers: [SalesforceClient, SalesforceOpportunityService], + exports: [SalesforceOpportunityService], +}) +export class SalesforceModule {} diff --git a/src/shared/config/salesforce.config.ts b/src/shared/config/salesforce.config.ts new file mode 100644 index 0000000..4f7c16b --- /dev/null +++ b/src/shared/config/salesforce.config.ts @@ -0,0 +1,44 @@ +/** + * Salesforce OAuth client-credentials configuration used by the opportunity + * lookup endpoints. + * + * These values are distinct from the JWT-bearer credentials consumed by + * `BillingAccountService` (`SALESFORCE_CLIENT_ID`/`SALESFORCE_CLIENT_KEY`): + * the opportunity integration uses a connected app configured for the + * client-credentials flow, matching the Reports API integration. + * + * Values are read from `process.env` on each access so the running process + * always uses the current environment rather than a snapshot taken at import. + */ +export const SALESFORCE_CONFIG = { + /** + * Connected-app consumer key. + * Env: `SALESFORCE_API_CONSUMER_KEY`. + */ + get consumerKey(): string { + return process.env.SALESFORCE_API_CONSUMER_KEY || ''; + }, + /** + * Connected-app consumer secret. + * Env: `SALESFORCE_API_CONSUMER_SECRET`. + */ + get consumerSecret(): string { + return process.env.SALESFORCE_API_CONSUMER_SECRET || ''; + }, + /** + * Salesforce origin used for the token exchange. + * Env: `SALESFORCE_LOGIN_URL`, default: `https://topcoder.my.salesforce.com`. + */ + get loginUrl(): string { + return ( + process.env.SALESFORCE_LOGIN_URL || 'https://topcoder.my.salesforce.com' + ); + }, + /** + * REST API version, without the leading `v`. + * Env: `SALESFORCE_REST_API_VERSION`, default: `65.0`. + */ + get apiVersion(): string { + return process.env.SALESFORCE_REST_API_VERSION || '65.0'; + }, +} as const; diff --git a/src/shared/utils/showcase-metadata.utils.spec.ts b/src/shared/utils/showcase-metadata.utils.spec.ts index e98b9a8..210e660 100644 --- a/src/shared/utils/showcase-metadata.utils.spec.ts +++ b/src/shared/utils/showcase-metadata.utils.spec.ts @@ -1,5 +1,8 @@ import { BadRequestException } from '@nestjs/common'; -import { normalizeShowcaseProjectMetadata } from './showcase-metadata.utils'; +import { + normalizeShowcaseProjectMetadata, + normalizeSmuValue, +} from './showcase-metadata.utils'; describe('shared showcase project metadata', () => { const metadata = { @@ -44,8 +47,58 @@ describe('shared showcase project metadata', () => { it('clears a stale custom SMU when a standard SMU is selected', () => { expect( - normalizeShowcaseProjectMetadata({ ...metadata, smu: 'Europe' }, true) + normalizeShowcaseProjectMetadata({ ...metadata, smu: 'EURP' }, true) .smuOther, ).toBe(''); }); + + it.each([ + ['APMEA', 'APME'], + ['Europe', 'EURP'], + ['Americas1', 'AMR1'], + ['Americas2', 'AMR2'], + ['AMR1', 'AMR1'], + ['Others', 'Others'], + ])('upgrades the legacy SMU label %s to %s', (stored, expected) => { + expect(normalizeSmuValue(stored)).toBe(expected); + expect( + normalizeShowcaseProjectMetadata({ ...metadata, smu: stored }).smu, + ).toBe(expected); + }); + + it('keeps a Salesforce opportunity id and trims it', () => { + expect( + normalizeShowcaseProjectMetadata({ + ...metadata, + salesforceOpportunityId: ' 006UN00000XamntYAB ', + }).salesforceOpportunityId, + ).toBe('006UN00000XamntYAB'); + }); + + it('accepts a cleared Salesforce opportunity id', () => { + expect( + normalizeShowcaseProjectMetadata({ + ...metadata, + salesforceOpportunityId: '', + }).salesforceOpportunityId, + ).toBe(''); + }); + + it.each([ + '006UN00000Xamn', + '001UN00000XamntYAB', + '006UN00000XamntYA', + "006UN00000Xamnt' OR Id != '", + 42, + ])( + 'rejects an invalid Salesforce opportunity id: %j', + (salesforceOpportunityId) => { + expect(() => + normalizeShowcaseProjectMetadata({ + ...metadata, + salesforceOpportunityId, + }), + ).toThrow(BadRequestException); + }, + ); }); diff --git a/src/shared/utils/showcase-metadata.utils.ts b/src/shared/utils/showcase-metadata.utils.ts index e1f4282..8b5dafc 100644 --- a/src/shared/utils/showcase-metadata.utils.ts +++ b/src/shared/utils/showcase-metadata.utils.ts @@ -1,12 +1,26 @@ import { BadRequestException } from '@nestjs/common'; -export const SMU_VALUES = [ - 'APMEA', - 'Europe', - 'Americas1', - 'Americas2', - 'Others', -]; +/** + * Supported SMU options. + * + * These match the Salesforce `Opportunity.Reporting_SMU__c` codes so an + * imported opportunity maps onto a project without translation. + */ +export const SMU_VALUES = ['APME', 'EURP', 'AMR1', 'AMR2', 'Others']; + +/** + * SMU labels used before the Salesforce naming alignment. + * + * Projects saved with the old labels are upgraded in place the next time their + * details are written, so historical records stay valid. + */ +export const LEGACY_SMU_VALUES: Readonly> = { + APMEA: 'APME', + Europe: 'EURP', + Americas1: 'AMR1', + Americas2: 'AMR2', +}; + export const SHOWCASE_TYPES = [ 'Open Innovation', 'Private POD Delivery', @@ -26,17 +40,44 @@ export const PROJECT_SHOWCASE_METADATA_KEYS = [ 'dealCloseDate', ] as const; +/** + * `Project.details` key holding the linked Salesforce opportunity. + */ +export const PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY = + 'salesforceOpportunityId' as const; + +/** + * Salesforce opportunity record ids: the `006` key prefix plus 12 or 15 + * case-sensitive alphanumeric characters. + */ +export const SALESFORCE_OPPORTUNITY_ID_PATTERN = + /^006[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/; + export type ProjectShowcaseMetadata = Partial< - Record<(typeof PROJECT_SHOWCASE_METADATA_KEYS)[number], string> + Record< + | (typeof PROJECT_SHOWCASE_METADATA_KEYS)[number] + | typeof PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY, + string + > >; +/** + * Maps a stored or supplied SMU label onto a currently supported option. + * @param value Raw SMU string from a request or from existing project details. + * @returns The supported option, or the original value when it is unknown. + * @throws Does not throw. + */ +export function normalizeSmuValue(value: string): string { + return LEGACY_SMU_VALUES[value] ?? value; +} + /** * Validates and normalizes the shared project fields stored in Project.details. * Used by project writes and atomic showcase saves without removing other details. * @param details Project details or the merged showcase metadata candidate. * @param required Whether Customer, SMU and Deal Close Date must be nonempty. * @returns Only supplied showcase metadata fields, with unused custom SMU cleared. - * @throws BadRequestException for invalid strings, SMU, calendar dates or required fields. + * @throws BadRequestException for invalid strings, SMU, calendar dates, Salesforce opportunity ids or required fields. */ export function normalizeShowcaseProjectMetadata( details: Record | null | undefined, @@ -53,6 +94,7 @@ export function normalizeShowcaseProjectMetadata( } result[key] = value.trim(); } + if (result.smu) result.smu = normalizeSmuValue(result.smu); if (required) { for (const key of ['customer', 'smu', 'dealCloseDate'] as const) { if (!result[key]) throw new BadRequestException(`${key} is required.`); @@ -77,5 +119,20 @@ export function normalizeShowcaseProjectMetadata( ); } } + const opportunityId = details?.[PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY]; + if (opportunityId !== undefined) { + if (typeof opportunityId !== 'string') { + throw new BadRequestException( + `${PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY} must be a string.`, + ); + } + const trimmed = opportunityId.trim(); + if (trimmed && !SALESFORCE_OPPORTUNITY_ID_PATTERN.test(trimmed)) { + throw new BadRequestException( + `${PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY} must be a 15 or 18 character Salesforce opportunity id.`, + ); + } + result[PROJECT_SALESFORCE_OPPORTUNITY_ID_KEY] = trimmed; + } return result; }