From 7ccad6e66770972fa3429e59afaa36f94ff3d81e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 21 May 2026 11:04:22 +1000 Subject: [PATCH 1/2] PM-5114: add HubSpot newsletter signup What was broken Newly registered identity-api-v6 users were created in Topcoder systems but were not automatically subscribed to the Topcoder newsletter in HubSpot. Root cause The v6 registration flow did not include a HubSpot contact or communication subscription side effect, and the archived identity service did not contain equivalent HubSpot newsletter logic to port. What was changed Added a non-fatal registration side effect that upserts the HubSpot contact and subscribes the registered email address to the configured Topcoder newsletter communication subscription. Documented the HubSpot configuration values required by identity-api-v6. Any added/updated tests Added a user service unit test covering the HubSpot contact upsert and newsletter subscription request payload for newly registered users. --- .env.sample | 7 ++ README.md | 6 ++ src/api/user/user.service.spec.ts | 68 +++++++++++++- src/api/user/user.service.ts | 146 +++++++++++++++++++++++++++++- 4 files changed, 225 insertions(+), 2 deletions(-) diff --git a/.env.sample b/.env.sample index c68c255..be71046 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/README.md b/README.md index 21ea764..a239edc 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/api/user/user.service.spec.ts b/src/api/user/user.service.spec.ts index 7c7c260..5ecc64c 100644 --- a/src/api/user/user.service.spec.ts +++ b/src/api/user/user.service.spec.ts @@ -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 = { @@ -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 }, @@ -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(), @@ -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; + // Mock crypto (Node.js built-ino let createCipherivError = false; const mockUpdate = jest.fn().mockReturnThis(); @@ -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) @@ -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); @@ -1326,7 +1344,7 @@ describe('UserService', () => { expect(mockValidationService.validateHandle).toHaveBeenCalledWith( 'newuser', ); - expect(mockValidationService.validateEmail).toHaveBeenCalledWith( + expect(mockValidationService.validateEmailViaDB).toHaveBeenCalledWith( 'newuser@example.com', ); expect( @@ -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: { diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts index 12e40bb..ea7c44a 100644 --- a/src/api/user/user.service.ts +++ b/src/api/user/user.service.ts @@ -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 = { @@ -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 { @@ -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.`, @@ -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 { + 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('HUBSPOT_API_KEY'); + const subscriptionIdConfig = this.configService.get( + '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( + '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( + 'HUBSPOT_NEWSLETTER_LEGAL_BASIS', + ); + const configuredLegalBasisExplanation = this.configService.get( + '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, + email: string, + userParams: UserParamBaseDto, + ): Promise { + 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, @@ -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', From 92aad11641e1c8d0e9d9f364c340c297694f4837 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 5 Aug 2026 15:47:14 +1000 Subject: [PATCH 2/2] Trivy update --- .github/workflows/trivy.yaml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa48..9706c0a 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -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"