From 900a3824f7a75b92b8b36a4d3479c555785b097a Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 12:56:52 +1000 Subject: [PATCH 1/5] Route migrated Contentful spaces through Payload --- __tests__/server/contentful-endpoints.js | 31 ++++++++++ config/custom-environment-variables.js | 8 +++ config/default.js | 10 +++ docs/contentful/environment-setup.md | 35 ++++++++++- src/server/services/contentful-endpoints.js | 47 ++++++++++++++ src/server/services/contentful.js | 68 ++++++++++++++++----- 6 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 __tests__/server/contentful-endpoints.js create mode 100644 src/server/services/contentful-endpoints.js diff --git a/__tests__/server/contentful-endpoints.js b/__tests__/server/contentful-endpoints.js new file mode 100644 index 0000000000..db788d4c3e --- /dev/null +++ b/__tests__/server/contentful-endpoints.js @@ -0,0 +1,31 @@ +import { + getContentfulApiBaseUrl, + getContentfulApiHost, +} from 'server/services/contentful-endpoints'; + +describe('server/services/contentful-endpoints', () => { + test('retains Contentful Delivery and Preview hosts by default', () => { + expect(getContentfulApiHost({}, false)).toBe('cdn.contentful.com'); + expect(getContentfulApiHost({}, true)).toBe('preview.contentful.com'); + }); + + test('uses configured compatibility hosts and normalizes URL syntax', () => { + const environment = { + CDN_API_HOST: 'https://cms.topcoder-dev.com/', + PREVIEW_API_HOST: 'cms.topcoder-dev.com', + }; + + expect(getContentfulApiHost(environment, false)).toBe('cms.topcoder-dev.com'); + expect(getContentfulApiHost(environment, true)).toBe('cms.topcoder-dev.com'); + }); + + test('builds the Contentful-compatible spaces and environments path', () => { + expect(getContentfulApiBaseUrl('cms.topcoder-dev.com', 'space id', 'feature/test')) + .toBe('https://cms.topcoder-dev.com/spaces/space%20id/environments/feature%2Ftest'); + }); + + test('rejects non-string configured hosts', () => { + expect(() => getContentfulApiHost({ CDN_API_HOST: true }, false)) + .toThrow('CDN_API_HOST must be a hostname string.'); + }); +}); diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index a6dac4008d..bc892fda37 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -37,11 +37,15 @@ module.exports = { SECRET: { CONTENTFUL: { MANAGEMENT_TOKEN: 'CONTENTFUL_MANAGEMENT_TOKEN', + PAYLOAD_VOTE_API_URL: 'CONTENTFUL_PAYLOAD_VOTE_API_URL', + PAYLOAD_MANAGEMENT_API_KEY: 'CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY', default: { SPACE_ID: 'CONTENTFUL_SPACE_ID', master: { CDN_API_KEY: 'CONTENTFUL_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_PREVIEW_API_HOST', }, }, zurich: { @@ -56,6 +60,8 @@ module.exports = { master: { CDN_API_KEY: 'CONTENTFUL_TOPGEAR_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_TOPGEAR_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_TOPGEAR_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_TOPGEAR_PREVIEW_API_HOST', }, }, EDU: { @@ -63,6 +69,8 @@ module.exports = { master: { CDN_API_KEY: 'CONTENTFUL_EDU_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_EDU_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_EDU_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_EDU_PREVIEW_API_HOST', }, }, comcast: { diff --git a/config/default.js b/config/default.js index ec826e3614..5ebbe73034 100644 --- a/config/default.js +++ b/config/default.js @@ -211,11 +211,17 @@ module.exports = { DEFAULT_SPACE_NAME: 'default', DEFAULT_ENVIRONMENT: 'master', MANAGEMENT_TOKEN: '', // Personal Access Token to use the Content Management API + /* Optional Payload write-through endpoint. When unset, article votes + * continue to use the Contentful Management API. */ + PAYLOAD_VOTE_API_URL: '', + PAYLOAD_MANAGEMENT_API_KEY: '', default: { // Human-readable name of space SPACE_ID: '', master: { // Name of an environment CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, EDU: { @@ -223,6 +229,8 @@ module.exports = { master: { CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, /* Space for expert communities. */ @@ -239,6 +247,8 @@ module.exports = { master: { CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, comcast: { diff --git a/docs/contentful/environment-setup.md b/docs/contentful/environment-setup.md index 7f75a2b67c..e68b6e8156 100644 --- a/docs/contentful/environment-setup.md +++ b/docs/contentful/environment-setup.md @@ -37,5 +37,38 @@ To run Community App locally against your Contentful account: $ source ./set-contentful-env.sh $ NODE_CONFIG_ENV=development npm run dev ``` + We have prepared a demo env file you could use to start. You can find it + [here](https://gist.github.com/kkartunov/594dc65f76bac6aa800b4764cae72d2e). - We have prepared a demo env file you could use to start. You can find it [here](https://gist.github.com/kkartunov/594dc65f76bac6aa800b4764cae72d2e). +### Using the Payload CMS compatibility API + +Community App can migrate spaces independently while retaining Contentful for +spaces that have not been exported. Set the Delivery and Preview host variables +only for the migrated spaces; values are hostnames without a path. Existing API +keys remain the bearer credentials for the compatibility API. + +```bash +# Default space +export CONTENTFUL_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# EDU space +export CONTENTFUL_EDU_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_EDU_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# TopGear space +export CONTENTFUL_TOPGEAR_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_TOPGEAR_PREVIEW_API_HOST="cms.topcoder-dev.com" +``` + +Zurich and Comcast continue to use Contentful unless host variables are added +for those spaces in a later migration. To store EDU article votes in Payload, +also set the full write endpoint and its service credential: + +```bash +export CONTENTFUL_PAYLOAD_VOTE_API_URL="https://cms.topcoder-dev.com/contentful-management/votes" +export CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY="" +``` + +When the Payload vote URL is unset, Community App retains the existing +Contentful Management API update-and-publish behavior. diff --git a/src/server/services/contentful-endpoints.js b/src/server/services/contentful-endpoints.js new file mode 100644 index 0000000000..00697aea24 --- /dev/null +++ b/src/server/services/contentful-endpoints.js @@ -0,0 +1,47 @@ +/** + * Endpoint helpers shared by the server-side Contentful Delivery and Preview + * clients. They allow selected spaces to use a Contentful-compatible host + * without changing how other spaces are configured. + */ + +const CONTENTFUL_CDN_API_HOST = 'cdn.contentful.com'; +const CONTENTFUL_PREVIEW_API_HOST = 'preview.contentful.com'; + +/** + * Resolves the API hostname for one Contentful space environment. + * + * @param {Object} environmentConfig The environment's API key and optional + * host configuration. + * @param {Boolean} preview Whether the caller needs the Preview API host. + * @return {String} A hostname suitable for both the Contentful SDK and an + * HTTPS URL. This is used while constructing every server-side CMS client. + * @throws {TypeError} If a configured host is not a string. + */ +export function getContentfulApiHost(environmentConfig, preview) { + const property = preview ? 'PREVIEW_API_HOST' : 'CDN_API_HOST'; + const fallback = preview ? CONTENTFUL_PREVIEW_API_HOST : CONTENTFUL_CDN_API_HOST; + const configuredHost = environmentConfig[property]; + + if (configuredHost === undefined || configuredHost === null || configuredHost === '') { + return fallback; + } + if (typeof configuredHost !== 'string') { + throw new TypeError(`${property} must be a hostname string.`); + } + + return configuredHost.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); +} + +/** + * Builds a Contentful-compatible API base URL for direct HTTP requests. + * + * @param {String} host API hostname returned by getContentfulApiHost(). + * @param {String} spaceId Contentful space identifier. + * @param {String} environment Contentful environment name. + * @return {String} The HTTPS base URL used by ApiService.fetch(). + * @throws {URIError} If the space identifier or environment cannot be URL + * encoded. + */ +export function getContentfulApiBaseUrl(host, spaceId, environment) { + return `https://${host}/spaces/${encodeURIComponent(spaceId)}/environments/${encodeURIComponent(environment)}`; +} diff --git a/src/server/services/contentful.js b/src/server/services/contentful.js index 3ea968a50e..e5ec55d7af 100644 --- a/src/server/services/contentful.js +++ b/src/server/services/contentful.js @@ -6,18 +6,17 @@ import _ from 'lodash'; import config from 'config'; import { createClient } from 'contentful'; +import fetch from 'isomorphic-fetch'; import { logger } from 'topcoder-react-lib'; import { isomorphy } from 'topcoder-react-utils'; import { qs } from 'qs'; +import { + getContentfulApiBaseUrl, + getContentfulApiHost, +} from './contentful-endpoints'; const contentful = require('contentful-management'); -/* Holds Contentful CDN URL. */ -const CDN_URL = 'https://cdn.contentful.com/spaces'; - -/* Holds Contentful Preview URL. */ -const PREVIEW_URL = 'https://preview.contentful.com/spaces'; - export const ASSETS_DOMAIN = 'assets.ctfassets.net'; export const IMAGES_DOMAIN = 'images.ctfassets.net'; @@ -55,18 +54,19 @@ class ApiService { * @param {String} key API key. * @param {String} spaceId The space id. * @param {Boolean} preview Use the preview API? + * @param {String} host Contentful-compatible API hostname. */ - constructor(baseUrl, key, spaceId, preview) { + constructor(baseUrl, key, spaceId, preview, host) { this.private = { - baseUrl, key, spaceId, preview, + baseUrl, key, spaceId, preview, host, }; // client config const clientConf = { accessToken: key, space: spaceId, logHandler, + host, }; - if (preview) clientConf.host = 'preview.contentful.com'; // create the client to work with this.client = createClient(clientConf); } @@ -144,11 +144,42 @@ class ApiService { /** * Updates votes count in Contentful articles - * @param {Object} body - * @param {String} body.id - * @param {Object} body.votes + * @param {Object} body Vote update submitted by Community App. + * @param {String} body.id EDU article entry identifier. + * @param {Object} body.votes Updated upvote and downvote totals. + * @return {Promise} The updated Contentful entry when using Contentful, + * or the Payload endpoint's JSON response when write-through is configured. + * This is used by the authenticated article vote proxy route. + * @throws {Error} If Payload write-through is enabled without an API key, the + * Payload endpoint rejects the request, or the Contentful update fails. */ export function articleVote(body) { + const payloadUrl = config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL; + if (payloadUrl) { + const apiKey = config.SECRET.CONTENTFUL.PAYLOAD_MANAGEMENT_API_KEY; + if (!apiKey) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY is required when Payload article voting is enabled.')); + } + return fetch(payloadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + spaceId: config.SECRET.CONTENTFUL.EDU.SPACE_ID, + environment: 'master', + entryId: body.id, + votes: body.votes, + }), + }).then((response) => { + if (!response.ok) { + throw new Error(`Payload article vote update failed with status ${response.status}.`); + } + return response.json(); + }); + } + const client = contentful.createClient({ accessToken: config.SECRET.CONTENTFUL.MANAGEMENT_TOKEN, }); @@ -184,6 +215,7 @@ let services; function initServiceInstances() { const contentfulConfig = _.omit(config.SECRET.CONTENTFUL, [ 'DEFAULT_SPACE_NAME', 'DEFAULT_ENVIRONMENT', 'MANAGEMENT_TOKEN', + 'PAYLOAD_VOTE_API_URL', 'PAYLOAD_MANAGEMENT_API_KEY', ]); services = {}; _.map(contentfulConfig, (spaceConfig, spaceName) => { @@ -192,14 +224,18 @@ function initServiceInstances() { if (name !== 'SPACE_ID') { const environment = name; const spaceId = spaceConfig.SPACE_ID; - const previewBaseUrl = `${PREVIEW_URL}/${spaceId}/environments/${environment}`; - const cdnBaseUrl = `${CDN_URL}/${spaceId}/environments/${environment}`; + const previewHost = getContentfulApiHost(env, true); + const cdnHost = getContentfulApiHost(env, false); + const previewBaseUrl = getContentfulApiBaseUrl(previewHost, spaceId, environment); + const cdnBaseUrl = getContentfulApiBaseUrl(cdnHost, spaceId, environment); const svcs = {}; svcs.previewService = new ApiService( - previewBaseUrl.toString(), env.PREVIEW_API_KEY, spaceId, true, + previewBaseUrl, env.PREVIEW_API_KEY, spaceId, true, previewHost, + ); + svcs.cdnService = new ApiService( + cdnBaseUrl, env.CDN_API_KEY, spaceId, false, cdnHost, ); - svcs.cdnService = new ApiService(cdnBaseUrl.toString(), env.CDN_API_KEY, spaceId); services[spaceName][environment] = svcs; } }); From cbbd9b369a8a7d7e1693a82758faf51189e73c52 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 12 Aug 2026 11:25:47 +1000 Subject: [PATCH 2/5] Deploy branch for testing --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 40e916d303..f96d93aa50 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -180,6 +180,7 @@ workflows: only: - develop - PM-3686_group-submissions-in-challenge-details + - payload-cms - "build-prod": context: org-global From c1e76bd730f590b2e6e60b46c3189159aa8ea8b4 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 12 Aug 2026 15:04:15 +1000 Subject: [PATCH 3/5] Serve migrated Contentful assets from Payload --- config/custom-environment-variables.js | 1 + config/default.js | 1 + config/production.js | 1 + docs/contentful/environment-setup.md | 7 +++++++ src/server/index.js | 4 +++- src/shared/components/Contentful/Article/Article.jsx | 4 ++-- .../components/Contentful/SearchBar/SearchBar.jsx | 2 +- src/shared/components/Gigs/LoginModal/index.jsx | 10 +++++----- src/shared/components/MMatchLeaderboard/index.jsx | 2 +- src/shared/containers/GigsPages/index.jsx | 2 +- src/shared/utils/url.js | 2 +- 11 files changed, 24 insertions(+), 12 deletions(-) diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index bc892fda37..81141676dc 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -30,6 +30,7 @@ module.exports = { SERVER_API_KEY: 'SERVER_API_KEY', URL: { + CMS_ASSETS: 'PAYLOAD_CMS_ASSET_URL', COMMUNITY_APP: 'COMMUNITY_APP_URL', EMAIL_VERIFY_URL: 'EMAIL_VERIFY_URL', }, diff --git a/config/default.js b/config/default.js index 5ebbe73034..064d814dc8 100644 --- a/config/default.js +++ b/config/default.js @@ -111,6 +111,7 @@ module.exports = { /* This is the same value as above, but it is used by topcoder-react-lib, * as a more verbose name for the param. */ COMMUNITY_APP: 'https://community-app.topcoder-dev.com', + CMS_ASSETS: 'https://assets.topcoder-dev.com', CHALLENGES_URL: 'https://www.topcoder-dev.com/challenges', COPILOTS_URL: 'https://copilots.topcoder-dev.com', ENGAGEMENTS_APP: 'https://engagements.topcoder-dev.com', diff --git a/config/production.js b/config/production.js index f805153f72..92deada92a 100644 --- a/config/production.js +++ b/config/production.js @@ -28,6 +28,7 @@ module.exports = { /* This is the same value as above, but it is used by topcoder-react-lib, * as a more verbose name for the param. */ COMMUNITY_APP: 'https://community-app.topcoder.com', + CMS_ASSETS: 'https://assets.topcoder.com', CHALLENGES_URL: 'https://www.topcoder.com/challenges', COPILOTS_URL: 'https://copilots.topcoder.com', ENGAGEMENTS_APP: 'https://engagements.topcoder.com', diff --git a/docs/contentful/environment-setup.md b/docs/contentful/environment-setup.md index e68b6e8156..2582ca9103 100644 --- a/docs/contentful/environment-setup.md +++ b/docs/contentful/environment-setup.md @@ -59,6 +59,9 @@ export CONTENTFUL_EDU_PREVIEW_API_HOST="cms.topcoder-dev.com" # TopGear space export CONTENTFUL_TOPGEAR_CDN_API_HOST="cms.topcoder-dev.com" export CONTENTFUL_TOPGEAR_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# Public S3/CloudFront origin returned for migrated asset bytes +export PAYLOAD_CMS_ASSET_URL="https://assets.topcoder-dev.com" ``` Zurich and Comcast continue to use Contentful unless host variables are added @@ -72,3 +75,7 @@ export CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY="" When the Payload vote URL is unset, Community App retains the existing Contentful Management API update-and-publish behavior. + +`PAYLOAD_CMS_ASSET_URL` is added to the server's image and media Content +Security Policy directives. Set it to the environment-specific Payload asset +origin; do not include a path. diff --git a/src/server/index.js b/src/server/index.js index 795b51d485..e390087559 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -238,6 +238,7 @@ async function onExpressJsSetup(server) { + ' https://d1of0acg2orgco.cloudfront.net' + ' https://d24oibycet9bsb.cloudfront.net' + ' https://d2nl5eqipnb33q.cloudfront.net' + + ` ${config.URL.CMS_ASSETS}` + ' https://images.ctfassets.net' + ' https://heapanalytics.com' + ' https://q.quora.com' @@ -254,7 +255,8 @@ async function onExpressJsSetup(server) { + ' https://track.hubspot.com' + ' https://d0.awsstatic.com/logos/;' + " manifest-src 'self';" - + " media-src 'self';" + + " media-src 'self'" + + ` ${config.URL.CMS_ASSETS};` + " worker-src 'self';", ); } diff --git a/src/shared/components/Contentful/Article/Article.jsx b/src/shared/components/Contentful/Article/Article.jsx index dd4b0f883f..9ad30ab03f 100644 --- a/src/shared/components/Contentful/Article/Article.jsx +++ b/src/shared/components/Contentful/Article/Article.jsx @@ -43,7 +43,7 @@ const CONTENT_PREVIEW_LENGTH = 110; // Votes local storage key const LOCAL_STORAGE_KEY = 'VENBcnRpY2xlVm90ZXM='; // def banner image -const DEFAULT_BANNER_IMAGE = 'https://images.ctfassets.net/piwi0eufbb2g/7v2hlDsVep7FWufHw0lXpQ/2505e61a880e68fab4e80cd0e8ec1814/0C37CB5E-B253-4804-8935-78E64E67589E.png?w=1200&h=630'; +const DEFAULT_BANNER_IMAGE = `${config.URL.CMS_ASSETS}/media/contentful/images.ctfassets.net/97/970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee/0C37CB5E-B253-4804-8935-78E64E67589E-970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee.png?w=1200&h=630`; // random ads banner - left sidebar const RANDOM_BANNERS = ['6G8mjiTC1mzeSQ2YoUG1gB', '1DnDD02xX1liHfSTf5Vsn8', 'HQZ3mN0rR92CbNTkKTHJ5', '1OLoX8ZsvjAnn4TdGbZESD', '77jn01UGoQe2gqA7x0coQD']; const RANDOM_BANNER = RANDOM_BANNERS[getSecureRandomIndex(RANDOM_BANNERS.length)]; @@ -202,7 +202,7 @@ class Article extends React.Component { } - Thrive banner shape + Thrive banner shape
{ let toolName = 'Community'; From 31bd85303165c2ee558ae71624275dd55b156af1 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 12 Aug 2026 17:17:36 +1000 Subject: [PATCH 4/5] Speed up Thrive CMS rendering --- __tests__/server/contentful.js | 51 +++++ __tests__/shared/containers/EDUTrackCards.jsx | 86 +++++++++ __tests__/shared/routes/TopcoderRoutes.jsx | 64 +++++++ src/server/services/contentful.js | 12 ++ .../containers/EDU/partials/TrackCards.jsx | 20 ++ src/shared/routes/Topcoder/Routes.jsx | 181 +++++++++--------- 6 files changed, 325 insertions(+), 89 deletions(-) create mode 100644 __tests__/server/contentful.js create mode 100644 __tests__/shared/containers/EDUTrackCards.jsx create mode 100644 __tests__/shared/routes/TopcoderRoutes.jsx diff --git a/__tests__/server/contentful.js b/__tests__/server/contentful.js new file mode 100644 index 0000000000..360097ee8d --- /dev/null +++ b/__tests__/server/contentful.js @@ -0,0 +1,51 @@ +/* eslint-env jest */ + +import { createClient as createDeliveryClient } from 'contentful'; +import { + articleVote, + getService, +} from 'server/services/contentful'; + +const contentfulManagement = require('contentful-management'); + +jest.mock('contentful', () => ({ + createClient: jest.fn(() => ({})), +})); + +jest.mock('contentful-management', () => ({ + createClient: jest.fn(() => ({ + getSpace: jest.fn(() => Promise.resolve({ + getEnvironment: jest.fn(() => Promise.resolve({ + getEntry: jest.fn(() => Promise.resolve({ + fields: {}, + update: jest.fn(() => Promise.resolve({ + publish: jest.fn(() => Promise.resolve({ published: true })), + })), + })), + })), + })), + })), +})); + +describe('server/services/contentful HTTPS connections', () => { + test('shares one keep-alive agent across Delivery, Preview, and Management clients', async () => { + getService('default', 'master', false); + + const deliveryAgents = createDeliveryClient.mock.calls + .map(call => call[0].httpsAgent); + + expect(deliveryAgents.length).toBeGreaterThan(1); + deliveryAgents.forEach((agent) => { + expect(agent).toBe(deliveryAgents[0]); + expect(agent.options.keepAlive).toBe(true); + }); + + await articleVote({ + id: 'article-id', + votes: { downvotes: 1, upvotes: 2 }, + }); + + const managementConfig = contentfulManagement.createClient.mock.calls[0][0]; + expect(managementConfig.httpsAgent).toBe(deliveryAgents[0]); + }); +}); diff --git a/__tests__/shared/containers/EDUTrackCards.jsx b/__tests__/shared/containers/EDUTrackCards.jsx new file mode 100644 index 0000000000..1fdec356d7 --- /dev/null +++ b/__tests__/shared/containers/EDUTrackCards.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; +import TrackCards from 'containers/EDU/partials/TrackCards'; + +const EXPECTED_SELECT = [ + 'sys.id', + 'sys.type', + 'fields.externalArticle', + 'fields.contentUrl', + 'fields.slug', + 'fields.title', + 'fields.tags', + 'fields.readTime', + 'fields.creationDate', + 'fields.upvotes', + 'fields.commentsCount', + 'fields.featuredImage', + 'fields.contentAuthor', + 'fields.file', +].join(','); + +test('selects only fields required by EDU track cards and their assets', () => { + const renderer = new Renderer(); + renderer.render(); + + expect(renderer.getRenderOutput().props.entryQueries).toEqual({ + content_type: 'article', + 'fields.trackCategory': 'Development', + limit: 3, + order: '-sys.createdAt', + select: EXPECTED_SELECT, + }); +}); + +test('passes a projected article and resolved Asset file to Article small', () => { + const trackCardsRenderer = new Renderer(); + trackCardsRenderer.render( + , + ); + const trackCardsLoader = trackCardsRenderer.getRenderOutput(); + const article = { + fields: { + commentsCount: 2, + contentAuthor: [{ sys: { id: 'author-id', linkType: 'Entry', type: 'Link' } }], + creationDate: '2026-08-12', + featuredImage: { sys: { id: 'asset-id', linkType: 'Asset', type: 'Link' } }, + readTime: '5 min', + slug: 'projected-article', + tags: ['Payload'], + title: 'Projected article', + upvotes: 3, + }, + sys: { id: 'article-id', type: 'Entry' }, + }; + const cards = trackCardsLoader.props.render({ + entries: { items: { 'article-id': article } }, + }); + + const articleLoaderRenderer = new Renderer(); + articleLoaderRenderer.render(cards.props.children[0]); + const articleLoader = articleLoaderRenderer.getRenderOutput(); + expect(articleLoader.props.entryIds).toBe('article-id'); + + const articleAssetsRenderer = new Renderer(); + articleAssetsRenderer.render(articleLoader.props.render({ + entries: { items: { 'article-id': article } }, + })); + const assetLoader = articleAssetsRenderer.getRenderOutput(); + expect(assetLoader.props.assetIds).toBe('asset-id'); + + const articleCard = assetLoader.props.render({ + assets: { + items: { + 'asset-id': { + fields: { + file: { url: '//assets.topcoder-dev.com/media/contentful/projected.png' }, + }, + }, + }, + }, + }); + expect(articleCard.props.article.title).toBe('Projected article'); + expect(articleCard.props.featuredImage.file.url) + .toBe('//assets.topcoder-dev.com/media/contentful/projected.png'); + expect(articleCard.props.themeName).toBe('Article small'); +}); diff --git a/__tests__/shared/routes/TopcoderRoutes.jsx b/__tests__/shared/routes/TopcoderRoutes.jsx new file mode 100644 index 0000000000..64e639cd44 --- /dev/null +++ b/__tests__/shared/routes/TopcoderRoutes.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; +import { Route, Switch, matchPath } from 'react-router-dom'; +import { config } from 'topcoder-react-utils'; + +import ContentfulRoute from 'components/Contentful/Route'; +import Footer from 'components/TopcoderFooter'; +import Header from 'containers/TopcoderHeader'; +import EDUHome from 'routes/EDUHome'; +import EDUSearch from 'routes/EDUSearch'; +import EDUTracks from 'routes/EDUTracks'; +import Topcoder from 'routes/Topcoder/Routes'; + +test('matches exact Thrive routes before the generic root Contentful route', () => { + const renderer = new Renderer(); + renderer.render(); + + const output = renderer.getRenderOutput(); + const children = React.Children.toArray(output.props.children); + const routeSwitch = children[1]; + const routes = React.Children.toArray(routeSwitch.props.children); + const contentfulRouteIndex = routes.findIndex(route => route.type === ContentfulRoute); + const expectedRoutes = [ + { + component: EDUHome, + path: config.TC_EDU_BASE_PATH, + pathname: config.TC_EDU_BASE_PATH, + }, + { + component: EDUTracks, + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_TRACKS_PATH}`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_TRACKS_PATH}`, + }, + { + component: EDUSearch, + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_SEARCH_PATH}`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_SEARCH_PATH}`, + }, + { + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/routing-test`, + }, + ]; + + expect(children[0].type).toBe(Header); + expect(routeSwitch.type).toBe(Switch); + expect(children[2].type).toBe(Footer); + expect(contentfulRouteIndex).toBeGreaterThan(-1); + + expectedRoutes.forEach((expectedRoute) => { + const routeIndex = routes.findIndex(route => ( + route.type === Route && route.props.path === expectedRoute.path + )); + const route = routes[routeIndex]; + + expect(routeIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeLessThan(contentfulRouteIndex); + expect(route.props.exact).toBe(true); + expect(matchPath(expectedRoute.pathname, route.props)).not.toBeNull(); + if (expectedRoute.component) { + expect(route.props.component).toBe(expectedRoute.component); + } + }); +}); diff --git a/src/server/services/contentful.js b/src/server/services/contentful.js index e5ec55d7af..07dacd3ac8 100644 --- a/src/server/services/contentful.js +++ b/src/server/services/contentful.js @@ -6,6 +6,7 @@ import _ from 'lodash'; import config from 'config'; import { createClient } from 'contentful'; +import https from 'https'; import fetch from 'isomorphic-fetch'; import { logger } from 'topcoder-react-lib'; import { isomorphy } from 'topcoder-react-utils'; @@ -17,6 +18,15 @@ import { const contentful = require('contentful-management'); +/** + * Process-wide HTTPS connection pool shared by every server-side Contentful + * Delivery, Preview, and Management SDK client. Node 10 does not enable + * keep-alive on its default agent, so reusing this agent avoids a new TCP/TLS + * connection for each CMS request while leaving browser requests unchanged. + * @type {https.Agent} + */ +const contentfulHttpsAgent = new https.Agent({ keepAlive: true }); + export const ASSETS_DOMAIN = 'assets.ctfassets.net'; export const IMAGES_DOMAIN = 'images.ctfassets.net'; @@ -63,6 +73,7 @@ class ApiService { // client config const clientConf = { accessToken: key, + httpsAgent: contentfulHttpsAgent, space: spaceId, logHandler, host, @@ -182,6 +193,7 @@ export function articleVote(body) { const client = contentful.createClient({ accessToken: config.SECRET.CONTENTFUL.MANAGEMENT_TOKEN, + httpsAgent: contentfulHttpsAgent, }); return client.getSpace(config.SECRET.CONTENTFUL.EDU.SPACE_ID) .then(space => space.getEnvironment('master')) diff --git a/src/shared/containers/EDU/partials/TrackCards.jsx b/src/shared/containers/EDU/partials/TrackCards.jsx index 50f52334e0..e845880e99 100644 --- a/src/shared/containers/EDU/partials/TrackCards.jsx +++ b/src/shared/containers/EDU/partials/TrackCards.jsx @@ -5,6 +5,25 @@ import ContentfulLoader from 'containers/ContentfulLoader'; import LoadingIndicator from 'components/LoadingIndicator'; import { ArticleLoader } from 'components/Contentful/ArticleCard'; +// Contentful applies `select` to both entries and resolved includes. Keep only +// Article small fields, plus the linked author identity and Asset file payload. +const TRACK_CARD_SELECT = [ + 'sys.id', + 'sys.type', + 'fields.externalArticle', + 'fields.contentUrl', + 'fields.slug', + 'fields.title', + 'fields.tags', + 'fields.readTime', + 'fields.creationDate', + 'fields.upvotes', + 'fields.commentsCount', + 'fields.featuredImage', + 'fields.contentAuthor', + 'fields.file', +].join(','); + export default function TrackCards(props) { const { track, theme } = props; return ( @@ -14,6 +33,7 @@ export default function TrackCards(props) { 'fields.trackCategory': track, limit: 3, order: '-sys.createdAt', + select: TRACK_CARD_SELECT, }} spaceName="EDU" render={(data) => { diff --git a/src/shared/routes/Topcoder/Routes.jsx b/src/shared/routes/Topcoder/Routes.jsx index 18301002b6..f9d913612f 100644 --- a/src/shared/routes/Topcoder/Routes.jsx +++ b/src/shared/routes/Topcoder/Routes.jsx @@ -46,6 +46,98 @@ export default function Topcoder() { exact path="/community/hall-of-fame/:type/:eventId?" /> + + {/* Keep Thrive routes ahead of the generic root Contentful route. The + * root route otherwise loads the default CMS route before falling + * through to its error404 switch. */} + + + + { + const { articleTitle } = p.match.params; + return ( + { + if (_.isEmpty(data.entries.items)) { + // try search by title match + // this legacy support should be deprecated when all + // Thrive links switched to hypens, someday + return ( + { + if (_.isEmpty(dataTitle.entries.items)) return ; + let id = dataTitle.entries.matches[0].items[0]; + if (dataTitle.entries.matches[0].total !== 1) { + // more than 1 match. we need to try find best + const mId = _.findKey( + dataTitle.entries.items, + // eslint-disable-next-line max-len + o => o.fields.title.toLocaleLowerCase() === articleTitle.toLocaleLowerCase(), + ); + id = mId || id; + } + const { + externalArticle, + contentUrl, + } = dataTitle.entries.items[id].fields; + if (externalArticle && contentUrl && isomorphy.isClientSide()) { + window.location.href = contentUrl; + return null; + } + return ( +
+ ); + }} + renderPlaceholder={LoadingIndicator} + /> + ); + } + const id = data.entries.matches[0].items[0]; + const { externalArticle, contentUrl } = data.entries.items[id].fields; + if (externalArticle && contentUrl && isomorphy.isClientSide()) { + window.location.href = contentUrl; + return null; + } + return ( +
+ ); + }} + renderPlaceholder={LoadingIndicator} + /> + ); + }} + exact + path={`${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`} + /> } /> - {/* EDU Portal */} - - - - { - const { articleTitle } = p.match.params; - return ( - { - if (_.isEmpty(data.entries.items)) { - // try search by title match - // this legacy support should be deprecated when all - // Thrive links switched to hypens, someday - return ( - { - if (_.isEmpty(dataTitle.entries.items)) return ; - let id = dataTitle.entries.matches[0].items[0]; - if (dataTitle.entries.matches[0].total !== 1) { - // more than 1 match. we need to try find best - const mId = _.findKey( - dataTitle.entries.items, - // eslint-disable-next-line max-len - o => o.fields.title.toLocaleLowerCase() === articleTitle.toLocaleLowerCase(), - ); - id = mId || id; - } - const { - externalArticle, - contentUrl, - } = dataTitle.entries.items[id].fields; - if (externalArticle && contentUrl && isomorphy.isClientSide()) { - window.location.href = contentUrl; - return null; - } - return ( -
- ); - }} - renderPlaceholder={LoadingIndicator} - /> - ); - } - const id = data.entries.matches[0].items[0]; - const { externalArticle, contentUrl } = data.entries.items[id].fields; - if (externalArticle && contentUrl && isomorphy.isClientSide()) { - window.location.href = contentUrl; - return null; - } - return ( -
- ); - }} - renderPlaceholder={LoadingIndicator} - /> - ); - }} - exact - path={`${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`} - /> )} From 8e167e55ce3ecd50b60f7d9b4dc4f216dbfabcee Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 17 Aug 2026 13:19:22 +1000 Subject: [PATCH 5/5] Fix uni-nav 404 --- .../shared/containers/TopcoderHeader.jsx | 19 ++++++++++++++++++- .../containers/TopcoderHeader/index.jsx | 3 ++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/__tests__/shared/containers/TopcoderHeader.jsx b/__tests__/shared/containers/TopcoderHeader.jsx index d0adbfdc62..0f9daccc90 100644 --- a/__tests__/shared/containers/TopcoderHeader.jsx +++ b/__tests__/shared/containers/TopcoderHeader.jsx @@ -1,7 +1,8 @@ import _ from 'lodash'; -import Container from 'containers/TopcoderHeader'; +import Container, { TopcoderHeader } from 'containers/TopcoderHeader'; import React from 'react'; import R from 'react-test-renderer/shallow'; +import { config } from 'topcoder-react-utils'; const mockState = { auth: { @@ -27,3 +28,19 @@ test('Matches shallow snapshot', () => { )); expect(r.getRenderOutput()).toMatchSnapshot(); }); + +test('Passes the configured universal navigation URL to the navigation loader', () => { + const r = new R(); + r.render(( + + )); + + expect(r.getRenderOutput().props.children.props.uniNavUrl) + .toBe(config.UNIVERSAL_NAV_URL); +}); diff --git a/src/shared/containers/TopcoderHeader/index.jsx b/src/shared/containers/TopcoderHeader/index.jsx index ce478cb4f9..b8b859a421 100644 --- a/src/shared/containers/TopcoderHeader/index.jsx +++ b/src/shared/containers/TopcoderHeader/index.jsx @@ -8,7 +8,7 @@ import { MarketingNavigation, ToolNavigation } from 'uninav-react'; import { getSubPageConfiguration } from '../../utils/url'; import './styles.scss'; -const TopcoderHeader = ({ auth, location }) => { +export const TopcoderHeader = ({ auth, location }) => { const user = _.get(auth, 'profile') || {}; const authToken = _.get(auth, 'tokenV3'); const isAuthenticated = !!authToken; @@ -60,6 +60,7 @@ const TopcoderHeader = ({ auth, location }) => { return (