Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ SENDGRID_SELFSERVICE_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID="d-73c29be82bfa4d68beea
SENDGRID_WELCOME_EMAIL_TEMPLATE_ID="d-26c8962fb48c42a3997053ebe5954516"
SENDGRID_SELFSERVICE_WELCOME_EMAIL_TEMPLATE_ID="d-26c8962fb48c42a3997053ebe5954516"

## HUBSPOT
HUBSPOT_API_KEY=""
HUBSPOT_BASE_URL="https://api.hubapi.com"
HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID=""
HUBSPOT_NEWSLETTER_LEGAL_BASIS="LEGITIMATE_INTEREST_OTHER"
HUBSPOT_NEWSLETTER_LEGAL_BASIS_EXPLANATION="New Topcoder user registration."

SSO_TOKEN_SALT=change-me

AUTH_OTP_DURATION=10
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/trivy.yaml
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
name: Trivy Scanner

permissions:
actions: read
contents: read
security-events: write

on:
push:
branches:
- main
- master
- dev
- develop
pull_request:
workflow_dispatch:

jobs:
trivy-scan:
name: Use Trivy
name: Trivy SAST and SCA
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Run Trivy scanner in repo mode
uses: aquasecurity/trivy-action@0.33.1
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: "v0.73.0"
scan-type: "fs"
scan-ref: "."
ignore-unfixed: true
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,UNKNOWN"
scanners: vuln,secret,misconfig,license
limit-severities-for-sarif: true
scanners: "vuln,secret,misconfig,license"
github-pat: ${{ secrets.GITHUB_TOKEN }}

- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: "trivy-results.sarif"
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ The following table summarizes the environment variables used by the application
| | **SendGrid Integration** | |
| `SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID` | SendGrid template ID for resend activation email. | `d-73c29be82bfa4d68beea2208b6a3c4b2` (example) |
| `SENDGRID_WELCOME_EMAIL_TEMPLATE_ID` | SendGrid template ID for welcome email. | `d-26c8962fb48c42a3997053ebe5954516` (example) |
| | **HubSpot Integration** | |
| `HUBSPOT_API_KEY` | HubSpot private app bearer token used for contact and subscription APIs. | *(empty)* |
| `HUBSPOT_BASE_URL` | HubSpot API base URL. | `https://api.hubapi.com` |
| `HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID` | HubSpot communication subscription ID for the Topcoder newsletter. | *(empty)* |
| `HUBSPOT_NEWSLETTER_LEGAL_BASIS` | HubSpot legal basis sent when subscribing a new user. | `LEGITIMATE_INTEREST_OTHER` |
| `HUBSPOT_NEWSLETTER_LEGAL_BASIS_EXPLANATION` | HubSpot legal basis explanation sent when subscribing a new user. | `New Topcoder user registration.` |
| | **Other** | |
| `ADMIN_ROLE_NAME` | Name of the role considered admin | `administrator` |
| `LOG_LEVEL` | Logging level (e.g., `debug`, `info`, `warn`, `error`) | `info` |
Expand Down
68 changes: 67 additions & 1 deletion src/api/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import * as crypto from 'crypto';
import { Cache } from 'cache-manager';
import { MemberPrismaService } from 'src/shared/member-prisma/member-prisma.service';
import { Constants } from '../../core/constant/constants';
import axios from 'axios';

// Null logger to suppress NestJS application logs during tests
const nullLogger = {
Expand Down Expand Up @@ -101,6 +102,9 @@ const mockPrismaOltp = {
findMany: jest.fn(),
// Add other methods if used
},
user_otp_email: {
create: jest.fn(),
},
achievement_type_lu: {
findUnique: jest.fn(), // Added for potential use
},
Expand All @@ -127,6 +131,7 @@ const mockValidationService = {
validateUser: jest.fn(),
validateHandle: jest.fn(),
validateEmail: jest.fn(),
validateEmailViaDB: jest.fn(),
validateCountry: jest.fn(),
validateCountryAndMutate: jest.fn(),
validateProfile: jest.fn(),
Expand Down Expand Up @@ -198,6 +203,15 @@ jest.mock('uuid', () => ({
v4: jest.fn(),
}));

jest.mock('axios', () => ({
__esModule: true,
default: {
patch: jest.fn(),
post: jest.fn(),
},
}));
const mockedAxios = axios as jest.Mocked<typeof axios>;

// Mock crypto (Node.js built-ino
let createCipherivError = false;
const mockUpdate = jest.fn().mockReturnThis();
Expand Down Expand Up @@ -1288,10 +1302,13 @@ describe('UserService', () => {
validationService.validateUser.mockImplementation(() => undefined);
validationService.validateHandle.mockResolvedValue({ valid: true });
validationService.validateEmail.mockResolvedValue({ valid: true });
validationService.validateEmailViaDB.mockResolvedValue(undefined);
validationService.validateCountryAndMutate.mockResolvedValue(null);
validationService.validateProfile.mockResolvedValue();
validationService.validateReferral.mockResolvedValue(null);
validationService.isHandleLocked.mockResolvedValue(false);
mockedAxios.patch.mockResolvedValue({ data: { id: 'hubspot-contact' } });
mockedAxios.post.mockResolvedValue({ data: {} });

prismaOltp.$queryRaw.mockImplementation(async (query) => {
const sqlString = Array.isArray(query)
Expand All @@ -1310,6 +1327,7 @@ describe('UserService', () => {
);
prismaOltp.email.findFirst.mockResolvedValue(null); // Assume email doesn't exist for new user
prismaOltp.email.create.mockResolvedValue(mockCreatedEmail);
prismaOltp.user_otp_email.create.mockResolvedValue({});
roleService.assignRoleByName.mockResolvedValue(undefined);
cacheManager.set.mockResolvedValue(undefined);
eventService.postEnvelopedNotification.mockResolvedValue(undefined);
Expand All @@ -1326,7 +1344,7 @@ describe('UserService', () => {
expect(mockValidationService.validateHandle).toHaveBeenCalledWith(
'newuser',
);
expect(mockValidationService.validateEmail).toHaveBeenCalledWith(
expect(mockValidationService.validateEmailViaDB).toHaveBeenCalledWith(
'newuser@example.com',
);
expect(
Expand Down Expand Up @@ -1380,6 +1398,54 @@ describe('UserService', () => {
expect(result).toEqual(mockCreatedUser);
});

it('should subscribe registered users to the configured HubSpot newsletter', async () => {
const defaultConfigGet = mockConfigService.get.getMockImplementation();
mockConfigService.get.mockImplementation((key, defaultValue) => {
if (key === 'HUBSPOT_API_KEY') return 'hubspot-token';
if (key === 'HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID')
return '12345';
if (key === 'HUBSPOT_NEWSLETTER_LEGAL_BASIS')
return 'LEGITIMATE_INTEREST_OTHER';
if (key === 'HUBSPOT_NEWSLETTER_LEGAL_BASIS_EXPLANATION')
return 'New Topcoder user registration.';
return defaultConfigGet(key, defaultValue);
});

await service.registerUser(createUserDto);

const headers = {
Authorization: 'Bearer hubspot-token',
'Content-Type': 'application/json',
};
expect(mockedAxios.patch).toHaveBeenCalledWith(
'https://api.hubapi.com/crm/v3/objects/contacts/newuser%40example.com',
{
properties: {
email: 'newuser@example.com',
firstname: 'New',
lastname: 'User',
},
},
{
headers,
params: { idProperty: 'email' },
},
);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://api.hubapi.com/communication-preferences/v4/statuses/newuser%40example.com',
{
subscriptionId: 12345,
statusState: 'SUBSCRIBED',
legalBasis: 'LEGITIMATE_INTEREST_OTHER',
legalBasisExplanation: 'New Topcoder user registration.',
channel: 'EMAIL',
},
{ headers },
);

mockConfigService.get.mockImplementation(defaultConfigGet);
});

it('should store isoAlpha3Code for home and competition country codes', async () => {
const dto: CreateUserBodyDto = {
param: {
Expand Down
146 changes: 145 additions & 1 deletion src/api/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { MemberStatus as MemberDbStatus } from '../../../prisma/member/generated
import { CommonUtils } from '../../shared/util/common.utils';
import { getProviderDetails } from '../../core/constant/provider-type.enum';
import { addMinutes } from 'date-fns';
import axios, { AxiosError } from 'axios';
type GroupIdRow = { id: string };
type UserIdRow = { user_id: bigint | Decimal | number | string };
type UserSearchFilters = {
Expand Down Expand Up @@ -85,6 +86,12 @@ const OTP_ACTIVATION_MODE = 1;
const ACTIVATION_OTP_EXPIRY_MINUTES = 24 * 60;
const WIPRO_SSO_PROVIDER = 'wipro-adfs';
const WIPRO_ALL_GROUP_NAME = 'Wipro - All';
const HUBSPOT_DEFAULT_BASE_URL = 'https://api.hubapi.com';
const HUBSPOT_EMAIL_CHANNEL = 'EMAIL';
const HUBSPOT_SUBSCRIBED_STATUS = 'SUBSCRIBED';
const HUBSPOT_DEFAULT_NEWSLETTER_LEGAL_BASIS = 'LEGITIMATE_INTEREST_OTHER';
const HUBSPOT_DEFAULT_NEWSLETTER_LEGAL_BASIS_EXPLANATION =
'New Topcoder user registration.';

@Injectable()
export class UserService {
Expand Down Expand Up @@ -1397,6 +1404,7 @@ export class UserService {
// publish user created event
// ==========================
await this.publishUserCreatedEvent(newUser);
await this.subscribeRegisteredUserToHubSpotNewsletter(userParams);

this.logger.log(
`Successfully registered user ${newUser.handle} (ID: ${newUser.user_id.toNumber()}). Status: U. Activation OTP sent for eventing.`,
Expand Down Expand Up @@ -1577,6 +1585,142 @@ export class UserService {
}
}

/**
* Subscribes a newly registered user to the configured HubSpot newsletter.
* @param userParams The registration parameters that contain the user's email and profile data.
* @returns A promise that resolves after HubSpot contact upsert and subscription requests complete or are skipped.
* @throws This method does not intentionally throw; HubSpot errors are logged so registration can still succeed.
*/
private async subscribeRegisteredUserToHubSpotNewsletter(
userParams: UserParamBaseDto,
): Promise<void> {
const email = userParams.email?.trim();
if (!CommonUtils.validateString(email)) {
this.logger.warn(
'HubSpot newsletter signup skipped because registration email is missing.',
);
return;
}

const accessToken = this.configService.get<string>('HUBSPOT_API_KEY');
const subscriptionIdConfig = this.configService.get<string>(
'HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID',
);
if (
!CommonUtils.validateString(accessToken) ||
!CommonUtils.validateString(subscriptionIdConfig)
) {
this.logger.warn(
'HubSpot newsletter signup skipped because HUBSPOT_API_KEY or HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID is not configured.',
);
return;
}

const subscriptionId = Number(subscriptionIdConfig);
if (!Number.isFinite(subscriptionId)) {
this.logger.warn(
`HubSpot newsletter signup skipped because HUBSPOT_TOPCODER_NEWSLETTER_SUBSCRIPTION_ID is not numeric: ${subscriptionIdConfig}.`,
);
return;
}

const baseUrl = (
this.configService.get<string>(
'HUBSPOT_BASE_URL',
HUBSPOT_DEFAULT_BASE_URL,
) || HUBSPOT_DEFAULT_BASE_URL
).replace(/\/+$/, '');
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
const configuredLegalBasis = this.configService.get<string>(
'HUBSPOT_NEWSLETTER_LEGAL_BASIS',
);
const configuredLegalBasisExplanation = this.configService.get<string>(
'HUBSPOT_NEWSLETTER_LEGAL_BASIS_EXPLANATION',
);
const legalBasis = CommonUtils.validateString(configuredLegalBasis)
? configuredLegalBasis
: HUBSPOT_DEFAULT_NEWSLETTER_LEGAL_BASIS;
const legalBasisExplanation = CommonUtils.validateString(
configuredLegalBasisExplanation,
)
? configuredLegalBasisExplanation
: HUBSPOT_DEFAULT_NEWSLETTER_LEGAL_BASIS_EXPLANATION;

try {
await this.upsertHubSpotContact(baseUrl, headers, email, userParams);
await axios.post(
`${baseUrl}/communication-preferences/v4/statuses/${encodeURIComponent(email)}`,
{
subscriptionId,
statusState: HUBSPOT_SUBSCRIBED_STATUS,
legalBasis,
legalBasisExplanation,
channel: HUBSPOT_EMAIL_CHANNEL,
},
{ headers },
);
this.logger.log(
`HubSpot newsletter signup completed for registered user ${userParams.handle}.`,
);
} catch (error) {
const hubspotError = error as AxiosError;
this.logger.error(
`HubSpot newsletter signup failed for registered user ${userParams.handle}: ${hubspotError.message}`,
hubspotError.stack,
);
}
}

/**
* Creates or updates a HubSpot contact by email before applying subscription preferences.
* @param baseUrl HubSpot API base URL without a trailing slash.
* @param headers HTTP headers containing the HubSpot bearer token.
* @param email The contact email address used as the unique HubSpot identifier.
* @param userParams Registration parameters used to populate standard contact properties.
* @returns A promise that resolves when the contact exists in HubSpot.
* @throws Rethrows non-404 HubSpot errors so the caller can log the signup failure.
*/
private async upsertHubSpotContact(
baseUrl: string,
headers: Record<string, string>,
email: string,
userParams: UserParamBaseDto,
): Promise<void> {
const contactProperties = {
email,
...(CommonUtils.validateString(userParams.firstName) && {
firstname: userParams.firstName,
}),
...(CommonUtils.validateString(userParams.lastName) && {
lastname: userParams.lastName,
}),
};

try {
await axios.patch(
`${baseUrl}/crm/v3/objects/contacts/${encodeURIComponent(email)}`,
{ properties: contactProperties },
{
headers,
params: { idProperty: 'email' },
},
);
} catch (error) {
if ((error as AxiosError).response?.status !== 404) {
throw error;
}

await axios.post(
`${baseUrl}/crm/v3/objects/contacts`,
{ properties: contactProperties },
{ headers },
);
}
}

private async createSsoSocialLoginDuringRegistration(
prisma: any,
userParams: UserParamBaseDto,
Expand Down Expand Up @@ -2722,7 +2866,7 @@ export class UserService {
from: { email: fromEmail },
version: 'v3',
sendgrid_template_id: welcomeTemplateId,
recipients: [emailAddress],
recipients: [emailAddress],
};
await this.eventService.postDirectBusMessage(
'external.action.email',
Expand Down