Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions src/api/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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).
*
Expand All @@ -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],
Expand Down
70 changes: 70 additions & 0 deletions src/api/salesforce/dto/salesforce-opportunity-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions src/api/salesforce/salesforce-opportunity.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(ROLES_KEY, handler);

expect(roles).toEqual(
expect.arrayContaining([
...MANAGER_ROLES,
UserRole.TALENT_MANAGER,
UserRole.TOPCODER_TALENT_MANAGER,
]),
);
expect(roles).not.toContain(UserRole.TOPCODER_USER);
});
});
85 changes: 85 additions & 0 deletions src/api/salesforce/salesforce-opportunity.controller.ts
Original file line number Diff line number Diff line change
@@ -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<SalesforceOpportunityResponseDto> {
return this.opportunities.getOpportunity(opportunityId);
}
}
114 changes: 114 additions & 0 deletions src/api/salesforce/salesforce-opportunity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading