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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<OPPORTUNITIES_CHALLENGE_URL>/<challengeId>?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
Expand Down
7 changes: 7 additions & 0 deletions app-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -176,6 +182,7 @@ module.exports = {
prizeTypes,
ChallengeMetadataNames,
BOOLEAN_METADATA_VALUES,
DiscussionProviders,
validChallengeParams,
EVENT_ORIGINATOR,
EVENT_MIME_TYPE,
Expand Down
4 changes: 4 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "",
};
6 changes: 6 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 28 additions & 2 deletions src/common/challenge-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
Expand Down
11 changes: 9 additions & 2 deletions src/services/ChallengeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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()}`);
Expand Down
65 changes: 65 additions & 0 deletions test/unit/ChallengeService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading