From 43ead8983a05a6855ac1a50a14081887bad52c56 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 18 Sep 2026 13:39:45 +1000 Subject: [PATCH] Define the challenge forum discussion on create for the opportunities app forums Challenge forums now live in the opportunities app, so the discussion is fully defined when a challenge is created instead of being filled in by the retired challenge-forum-processor: provider "topcoder", the challenge name and a URL to the challenge's forum tab (/?tab=forum). The challenge id is generated up front so the URL is known before the record is written, and the challenge.notification.create bus event carries the completed discussion. Adds the OPPORTUNITIES_CHALLENGE_URL config value, documents it, and covers the created discussion and bus payload in the challenge service unit tests. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + app-constants.ts | 7 ++++ config/default.js | 4 ++ docs/swagger.yaml | 6 +++ src/common/challenge-helper.ts | 30 +++++++++++++- src/services/ChallengeService.ts | 11 ++++- test/unit/ChallengeService.test.js | 65 ++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 49e5bb5..64e345a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ The following parameters can be set in config files or in env variables: - SCOPES: the configurable M2M token scopes, refer `config/default.js` for more details - M2M_AUDIT_HANDLE: the audit name used when perform create/update operation using M2M token - FORUM_TITLE_LENGTH_LIMIT: the forum title length limit +- OPPORTUNITIES_CHALLENGE_URL: base URL of a challenge in the opportunities app; the challenge + discussion URL is built from it as `/?tab=forum` - DATABASE_URL: PostgreSQL connection URL for the challenge database - REVIEW_DB_URL: optional PostgreSQL connection URL for review data; existing deployments may continue to omit it when review-database access is not used diff --git a/app-constants.ts b/app-constants.ts index 43ba3f1..a263065 100644 --- a/app-constants.ts +++ b/app-constants.ts @@ -26,6 +26,12 @@ const ChallengeMetadataNames = { const BOOLEAN_METADATA_VALUES = ["true", "false"]; +// Provider of challenge discussions. Forums are hosted by the opportunities app, +// which replaced the Vanilla forums created by the retired challenge-forum-processor. +const DiscussionProviders = { + TOPCODER: "topcoder", +}; + const validChallengeParams = { UpdatedBy: "updatedBy", Updated: "updatedAt", @@ -176,6 +182,7 @@ module.exports = { prizeTypes, ChallengeMetadataNames, BOOLEAN_METADATA_VALUES, + DiscussionProviders, validChallengeParams, EVENT_ORIGINATOR, EVENT_MIME_TYPE, diff --git a/config/default.js b/config/default.js index 19582bd..fcaad84 100644 --- a/config/default.js +++ b/config/default.js @@ -201,6 +201,10 @@ module.exports = { ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000, CHALLENGE_URL: process.env.CHALLENGE_URL || "https://www.topcoder-dev.com/challenges", + // Base URL of a challenge in the opportunities app; the challenge forum lives on its "forum" tab + OPPORTUNITIES_CHALLENGE_URL: + process.env.OPPORTUNITIES_CHALLENGE_URL || + "https://www.topcoder-dev.com/opportunities/challenge", SUPPORT_APP_URL: process.env.SUPPORT_APP_URL || "https://support.topcoder-dev.com", PHASE_CHANGE_SENDGRID_TEMPLATE_ID: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID || "", }; diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e24e29a..de3a187 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3504,6 +3504,10 @@ definitions: - id Discussion: type: object + description: | + Challenge forum discussion. On challenge creation the API defines this itself + (provider "topcoder", URL of the challenge's forum tab in the opportunities app), + ignoring any discussions in the request body. properties: id: type: string @@ -3516,10 +3520,12 @@ definitions: - challenge provider: type: string + example: topcoder url: type: string format: url description: Only M2M tokens can modify this + example: https://www.topcoder.com/opportunities/challenge/6a5da7b6-3841-43cb-ae9d-98416bea0d9d?tab=forum options: type: array description: Only M2M tokens can modify this diff --git a/src/common/challenge-helper.ts b/src/common/challenge-helper.ts index 3234404..fea23d2 100644 --- a/src/common/challenge-helper.ts +++ b/src/common/challenge-helper.ts @@ -12,8 +12,13 @@ const axios = require("axios"); const { getM2MToken } = require("./m2m-helper"); const { hasAdminRole } = require("./role-helper"); const { ensureAcessibilityToModifiedGroups } = require("./group-helper"); -const { ChallengeStatusEnum } = require("@prisma/client"); -const { ChallengeMetadataNames, BOOLEAN_METADATA_VALUES } = require("../../app-constants"); +const { v4: uuid } = require("uuid"); +const { ChallengeStatusEnum, DiscussionTypeEnum } = require("@prisma/client"); +const { + ChallengeMetadataNames, + BOOLEAN_METADATA_VALUES, + DiscussionProviders, +} = require("../../app-constants"); const SUBMISSION_PHASE_PRIORITY = ["Topgear Submission", "Topcoder Submission", "Submission"]; const CHECKPOINT_SUBMISSION_PHASE_NAME = "Checkpoint Submission"; @@ -348,6 +353,27 @@ class ChallengeHelper { } } + /** + * Build the discussion that links a challenge to its forum in the opportunities app. + * + * The forum is served by the opportunities app on the challenge page's "forum" tab, so the + * discussion is fully defined at creation time. The retired challenge-forum-processor no + * longer creates a Vanilla forum and writes the URL back. + * + * @param {String} challengeId the challenge id + * @param {String} challengeName the challenge name + * @returns {Object} the discussion payload + */ + buildChallengeForumDiscussion(challengeId, challengeName) { + return { + id: uuid(), + name: _.toString(challengeName).substring(0, config.FORUM_TITLE_LENGTH_LIMIT), + type: DiscussionTypeEnum.CHALLENGE, + provider: DiscussionProviders.TOPCODER, + url: `${config.OPPORTUNITIES_CHALLENGE_URL}/${challengeId}?tab=forum`, + }; + } + /** * If challenge reviewers are not provided, apply default reviewers for * the challenge type/track (timeline-template specific first, then generic fallback). diff --git a/src/services/ChallengeService.ts b/src/services/ChallengeService.ts index eb56c80..03dfd58 100644 --- a/src/services/ChallengeService.ts +++ b/src/services/ChallengeService.ts @@ -3076,9 +3076,16 @@ async function createChallenge(currentUser, challenge, userToken) { if (challenge.tags == null) challenge.tags = []; if (challenge.startDate != null) challenge.startDate = challenge.startDate; if (challenge.endDate != null) challenge.endDate = challenge.endDate; - if (challenge.discussions == null) challenge.discussions = []; if (challenge.skills == null) challenge.skills = []; + // The challenge forum is hosted by the opportunities app, so the discussion is defined here in + // full (provider, name and URL). Any discussions supplied by the caller are replaced; the + // challenge-forum-processor that used to fill in the URL from the created event is retired. + const challengeId = uuid(); + challenge.discussions = [ + challengeHelper.buildChallengeForumDiscussion(challengeId, challenge.name), + ]; + challenge.metadata = challenge.metadata.map((m) => ({ name: m.name, value: typeof m.value === "string" ? m.value : JSON.stringify(m.value), @@ -3113,7 +3120,7 @@ async function createChallenge(currentUser, challenge, userToken) { )} prizeSetCount=${_.get(challenge, "prizeSets.length", 0)}`, ); const ret = await prisma.challenge.create({ - data: prismaModel, + data: { id: challengeId, ...prismaModel }, include: includeReturnFields, }); logger.info(`createChallenge: challenge record created (id=${ret.id}) ${buildLogContext()}`); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 1d4f577..0e920fb 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -402,6 +402,71 @@ describe("challenge service unit tests", () => { } }); + it("defines the topcoder forum discussion on create and includes it in the bus event", async () => { + const challengeData = _.cloneDeep(testChallengeData); + // caller-supplied (legacy vanilla) discussions are replaced by the API-defined one + challengeData.discussions[0].type = "CHALLENGE"; + challengeData.prizeSets[0].type = PrizeSetTypeEnum.PLACEMENT; + challengeData.status = ChallengeStatusEnum.NEW; + const originalGetProject = projectHelper.getProject; + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation; + const originalPostBusEvent = helper.postBusEvent; + const busEvents = []; + let createdChallengeId; + + projectHelper.getProject = async () => ({ directProjectId: 33541 }); + projectHelper.getProjectBillingInformation = async () => ({ + billingAccountId: null, + markup: 0, + }); + helper.postBusEvent = async (topic, payload) => { + busEvents.push({ topic, payload: _.cloneDeep(payload) }); + }; + + try { + const result = await service.createChallenge( + { isMachine: true, sub: "sub", userId: "testuser" }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN || "test-token", + ); + createdChallengeId = result.id; + const expectedUrl = `${config.OPPORTUNITIES_CHALLENGE_URL}/${result.id}?tab=forum`; + + should.equal(result.discussions.length, 1); + const discussion = result.discussions[0]; + should.exist(discussion.id); + should.equal(discussion.name, testChallengeData.name); + should.equal(discussion.type, "CHALLENGE"); + should.equal(discussion.provider, constants.DiscussionProviders.TOPCODER); + should.equal(discussion.url, expectedUrl); + + const persisted = await prisma.challengeDiscussion.findMany({ + where: { challengeId: result.id }, + }); + should.equal(persisted.length, 1); + should.equal(persisted[0].discussionId, discussion.id); + should.equal(persisted[0].provider, constants.DiscussionProviders.TOPCODER); + should.equal(persisted[0].url, expectedUrl); + + const createdEvent = _.find(busEvents, { topic: constants.Topics.ChallengeCreated }); + should.exist(createdEvent); + should.equal(createdEvent.payload.discussions.length, 1); + should.equal(createdEvent.payload.discussions[0].id, discussion.id); + should.equal( + createdEvent.payload.discussions[0].provider, + constants.DiscussionProviders.TOPCODER, + ); + should.equal(createdEvent.payload.discussions[0].url, expectedUrl); + } finally { + projectHelper.getProject = originalGetProject; + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation; + helper.postBusEvent = originalPostBusEvent; + if (createdChallengeId) { + await prisma.challenge.deleteMany({ where: { id: createdChallengeId } }); + } + } + }); + it("locks draft challenge budget when the challenge is saved", async () => { const challengeData = _.cloneDeep(testChallengeData); challengeData.status = ChallengeStatusEnum.DRAFT;