Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2e5bd08
Better challenge access checks for RS-518
jmgasper Feb 5, 2026
41b4c68
Cors issue when using local platform-ui
jmgasper Feb 13, 2026
d1161be
Additional fields for timeline template service.
jmgasper Feb 19, 2026
6d39268
PM-3351 Send notification on manual phase change [initial commit]
himaniraghav3 Feb 20, 2026
f7ee6df
deploy PM-3351
himaniraghav3 Feb 20, 2026
23c3a60
Add sendgrid appvar to config
himaniraghav3 Feb 20, 2026
c17d651
Fix typo
himaniraghav3 Feb 20, 2026
95fd2e6
Fix typo
himaniraghav3 Feb 20, 2026
57c5063
Add logging
himaniraghav3 Feb 20, 2026
4ecb6bf
Fix recipient emails and add logging
himaniraghav3 Feb 20, 2026
e814a6d
fix typo
himaniraghav3 Feb 20, 2026
b39f104
Enable challenge phase update topic
himaniraghav3 Feb 20, 2026
b1812d7
always return updated phase
himaniraghav3 Feb 20, 2026
1bd719a
AI feedback
himaniraghav3 Feb 20, 2026
b235f71
Change topic
himaniraghav3 Feb 20, 2026
eaca715
Add valid topic and test
himaniraghav3 Feb 20, 2026
aa80953
Wrong method updated
himaniraghav3 Feb 20, 2026
629b0b2
Keep the invalid topic disabled
himaniraghav3 Feb 20, 2026
979eb82
Match autopilot bus payload
himaniraghav3 Feb 20, 2026
867eec4
cleanup logs
himaniraghav3 Feb 20, 2026
1c0edd7
Fix JSdocs
himaniraghav3 Feb 20, 2026
0fc71dd
Fixes for new work app in platform-ui
jmgasper Feb 21, 2026
48aac90
PM-1839: add funChallenge flag to challenge API
jmgasper Feb 22, 2026
c35a6d1
PM-2205: normalize legacy challenge track aliases
jmgasper Feb 22, 2026
001601c
Better handling of old directProjectId (PM-3999)
jmgasper Feb 23, 2026
cc56bd8
Merge pull request #70 from topcoder-platform/PM-3351
kkartunov Feb 23, 2026
4d2ae69
Better challenge status sorting for new work app
jmgasper Feb 25, 2026
1487ca8
Merge branch 'develop' of github.com:topcoder-platform/challenge-api-…
jmgasper Feb 25, 2026
52f4318
Add index on challenge term
vas3a Feb 27, 2026
aa5e5a2
Merge pull request #73 from topcoder-platform/add-index-on-challenge-…
kkartunov Feb 27, 2026
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: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ workflows:
only:
- develop
- security
- PM-3327
- PM-3351

- "build-qa":
context: org-global
Expand Down
9 changes: 9 additions & 0 deletions app-constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ const PhaseFact = {
UNRECOGNIZED: -1
}

const PhaseChangeNotificationSettings = {
PHASE_CHANGE: {
sendgridTemplateId: config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
Ensure that config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID is always defined and valid. If this value can be undefined or incorrect, it may lead to runtime errors when sending notifications.

cc: [],
},
};


const auditFields = [
'createdAt', 'createdBy', 'updatedAt', 'updatedBy'
]
Expand All @@ -168,4 +176,5 @@ module.exports = {
SelfServiceNotificationSettings,
PhaseFact,
auditFields,
PhaseChangeNotificationSettings,
};
10 changes: 1 addition & 9 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,7 @@

app.use(
cors({
origin: (origin, callback) => {
if (!origin) {
console.log("No origin - probably curl or server to server request");
// disable cors if service to service request
callback(null, false);
} else {
callback(null, '*')
}
},
origin: "*",

Check warning

Code scanning / CodeQL

Permissive CORS configuration Medium

CORS Origin allows broad access due to
permissive or user controlled value
.

Copilot Autofix

AI 3 months ago

In general, the problem should be fixed by replacing the permissive origin: "*" configuration with either (a) a restrictive, explicit whitelist of allowed origins, or (b) origin: false where CORS is not needed. For APIs that genuinely need to be called from browsers, the typical approach is to maintain a list of trusted front‑end origins (e.g., via configuration) and only return CORS headers when the request’s Origin header matches that list.

For this specific code, the best fix without changing existing functionality too much is to move from origin: "*" to a whitelist‑based policy, driven by configuration values that can be adjusted per environment. We can use the cors package’s ability to accept a function for origin, which lets us check the incoming Origin header against an allowed list and only enable CORS for those origins. A natural place to get that list is config, which is already imported. We’ll treat config.CORS_ALLOWED_ORIGINS (if present) as an array of allowed origins, and otherwise fall back to a safe default (e.g., no origins allowed) instead of "*". This preserves flexibility while eliminating the unconditional * response. Concretely:

  • Add a small helper just before the app.use(cors(...)) call to compute CORS_ALLOWED_ORIGINS from config (e.g., an array or a comma‑separated string).
  • Replace origin: "*" with an origin callback that:
    • Allows requests with no Origin (non‑browser or same‑origin requests) by invoking the callback with null.
    • If the origin is in the allowed list, invokes the callback with null (allow).
    • Otherwise, invokes the callback with an error or false (deny CORS for that origin).

All changes will be confined to app.js around the CORS configuration, without touching other middleware or routes.


Suggested changeset 1
app.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app.js b/app.js
--- a/app.js
+++ b/app.js
@@ -68,9 +68,34 @@
   swaggerUi.setup(challengeAPIWithAuthDoc, { explorer: true })
 );
 
+// Configure CORS with an explicit whitelist of allowed origins from config
+const allowedCorsOrigins = (() => {
+  const fromConfig = config.CORS_ALLOWED_ORIGINS;
+  if (Array.isArray(fromConfig)) {
+    return fromConfig;
+  }
+  if (typeof fromConfig === "string" && fromConfig.trim().length > 0) {
+    return fromConfig
+      .split(",")
+      .map((o) => o.trim())
+      .filter((o) => o.length > 0);
+  }
+  return [];
+})();
+
 app.use(
   cors({
-    origin: "*",
+    origin: (origin, callback) => {
+      // Allow requests with no origin (e.g., curl, server-to-server, same-origin)
+      if (!origin) {
+        return callback(null, true);
+      }
+      if (allowedCorsOrigins.includes(origin)) {
+        return callback(null, true);
+      }
+      // Origin not allowed by CORS
+      return callback(null, false);
+    },
     exposedHeaders: [
       "X-Prev-Page",
       "X-Next-Page",
EOF
@@ -68,9 +68,34 @@
swaggerUi.setup(challengeAPIWithAuthDoc, { explorer: true })
);

// Configure CORS with an explicit whitelist of allowed origins from config
const allowedCorsOrigins = (() => {
const fromConfig = config.CORS_ALLOWED_ORIGINS;
if (Array.isArray(fromConfig)) {
return fromConfig;
}
if (typeof fromConfig === "string" && fromConfig.trim().length > 0) {
return fromConfig
.split(",")
.map((o) => o.trim())
.filter((o) => o.length > 0);
}
return [];
})();

app.use(
cors({
origin: "*",
origin: (origin, callback) => {
// Allow requests with no origin (e.g., curl, server-to-server, same-origin)
if (!origin) {
return callback(null, true);
}
if (allowedCorsOrigins.includes(origin)) {
return callback(null, true);
}
// Origin not allowed by CORS
return callback(null, false);
},
exposedHeaders: [
"X-Prev-Page",
"X-Next-Page",
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
Changing the CORS origin to "*" allows any domain to access the API, which can be a security risk. Consider restricting this to specific domains or using a more dynamic approach to determine allowed origins.

exposedHeaders: [
"X-Prev-Page",
"X-Next-Page",
Expand Down
2 changes: 2 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,6 @@ module.exports = {
RESOURCES_DB_SCHEMA: process.env.RESOURCES_DB_SCHEMA || "resources",
REVIEW_DB_SCHEMA: process.env.REVIEW_DB_SCHEMA || "reviews",
CHALLENGE_SERVICE_PRISMA_TIMEOUT: process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000,
CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' ,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The default value for CHALLENGE_URL is hardcoded to https://www.topcoder-dev.com/challenges. Ensure this is the intended production URL and not a development or staging URL.

PHASE_CHANGE_SENDGRID_TEMPLATE_ID: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID || "",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The PHASE_CHANGE_SENDGRID_TEMPLATE_ID is defaulting to an empty string. If this is required for production, consider adding a validation check to ensure it is set correctly.

};
1 change: 1 addition & 0 deletions data-migration/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ model Challenge {
currentPhaseNames String[] // current phase names

wiproAllowed Boolean @default(false)
funChallenge Boolean @default(false)

// simple arrays for tags and groups (PostgreSQL native array type)
tags String[]
Expand Down
16 changes: 16 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2538,6 +2538,10 @@ definitions:
type: boolean
default: false
description: Flag to indicate whether Wipro employees can join challenge
funChallenge:
type: boolean
default: false
description: Flag to indicate this challenge contributes to leaderboard scoring without individual prize payouts
descriptionFormat:
type: string
default: markup
Expand Down Expand Up @@ -3597,6 +3601,18 @@ definitions:
type: string
description: The timeline template id.
format: UUID
typeId:
type: string
description: The challenge type id mapped to this timeline template row.
format: UUID
trackId:
type: string
description: The challenge track id mapped to this timeline template row.
format: UUID
isDefault:
type: boolean
description: Indicates this timeline template is the default for the mapped type/track.
default: false
- $ref: "#/definitions/TimelineTemplateData"
required:
- id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "Challenge"
ADD COLUMN "funChallenge" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "ChallengeTerm_challengeId_idx" ON "ChallengeTerm"("challengeId");
5 changes: 4 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ model Challenge {
currentPhaseNames String[] // current phase names

wiproAllowed Boolean @default(false)
funChallenge Boolean @default(false)

// simple arrays for tags and groups (PostgreSQL native array type)
tags String[]
Expand Down Expand Up @@ -375,6 +376,8 @@ model ChallengeTerm {
createdBy String
updatedAt DateTime @updatedAt
updatedBy String

@@index([challengeId])
}

//////////////////////////////////////////
Expand Down Expand Up @@ -732,4 +735,4 @@ model TimelineTemplatePhase {

@@index([timelineTemplateId])
@@index([timelineTemplateId, phaseId])
}
}
1 change: 0 additions & 1 deletion src/common/challenge-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ class ChallengeHelper {
promises.push(
(async () => {
const group = await helper.getGroupById(g);
console.log("group", group);
if (!group) {
throw new errors.BadRequestError("The groups provided are invalid " + g);
}
Expand Down
108 changes: 101 additions & 7 deletions src/common/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -1428,22 +1428,48 @@ function sumOfPrizes(prizes) {
}

/**
* Get group by id
* @param {String} groupId the group id
* Get group by id, with oldId fallback for backward compatibility.
* @param {String} groupId the group id or oldId
* @returns {Promise<Object>} the group
*/
async function getGroupById(groupId) {
const normalizedGroupId = _.toString(groupId || "").trim();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
Consider using String(groupId).trim() instead of _.toString(groupId || '').trim() for better performance and to avoid unnecessary dependency on lodash.

if (!normalizedGroupId) {
return;
}

const token = await m2mHelper.getM2MToken();
const requestHeaders = { Authorization: `Bearer ${token}` };
try {
const result = await axios.get(`${config.GROUPS_API_URL}/${groupId}`, {
headers: { Authorization: `Bearer ${token}` },
const result = await axios.get(`${config.GROUPS_API_URL}/${encodeURIComponent(normalizedGroupId)}`, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
Ensure that encodeURIComponent is used consistently for all dynamic URL segments to prevent potential injection attacks.

headers: requestHeaders,
});
return result.data;
} catch (err) {
if (err.response.status === HttpStatus.NOT_FOUND) {
return;
const status = _.get(err, "response.status");
if (status !== HttpStatus.NOT_FOUND) {
throw err;
}
}

try {
const result = await axios.get(config.GROUPS_API_URL, {
headers: requestHeaders,
params: {
page: 1,
perPage: 1,
oldId: normalizedGroupId,
},
});
const groups = _.get(result, "data", []);
if (groups.length > 0) {
return groups[0];
}
} catch (err) {
const status = _.get(err, "response.status");
if (status !== HttpStatus.NOT_FOUND) {
throw err;
}
throw err;
}
}

Expand Down Expand Up @@ -1638,6 +1664,72 @@ async function sendSelfServiceNotification(type, recipients, data) {
}
}

/**
* Build payload for phase change email notification
* @param {String} challenge Id
* @param {String} challenge name
* @param {String} challenge phase name
* @param {String} operation to be performed on the phase - open | close | reopen
* @param {String|Date} at - The date/time when the phase opened/closed
*/
function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, at }) {
const isOpen = operation === 'open' || operation === 'reopen';
const isClose = operation === 'close';

return {
challengeURL: `${config.CHALLENGE_URL}/${challengeId}`,
challengeName,
phaseOpen: isOpen ? phaseName : null,
phaseOpenDate: isOpen ? at : null,
phaseClose: isClose ? phaseName : null,
phaseCloseDate: isClose ? at : null,
};
}


/**
* Send phase change notification
* @param {String} type the notification type
* @param {Array} recipients the array of recipients emails
* @param {Object} data the data
*/
async function sendPhaseChangeNotification(type, recipients, data) {
try {
const settings = constants.PhaseChangeNotificationSettings?.[type];

if (!settings) {
logger.debug(`sendPhaseChangeNotification: unknown type ${type}`);
return;
}

if (!settings.sendgridTemplateId) {
logger.debug(
`sendPhaseChangeNotification: sendgridTemplateId not configured for type ${type}`
);
return;
}
const safeRecipients = Array.isArray(recipients) ? recipients.filter(Boolean) : [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Filtering recipients with recipients.filter(Boolean) is a good practice to avoid sending emails to invalid addresses. Ensure that this logic is applied consistently across all notification functions.


if (!safeRecipients.length) {
logger.debug(`sendPhaseChangeNotification: no recipients for type ${type}`);
return;
}

await postBusEvent('external.action.email',
{
from: config.EMAIL_FROM,
replyTo: config.EMAIL_FROM,
recipients: safeRecipients,
data: data,
sendgrid_template_id: settings.sendgridTemplateId,
version: 'v3',
},
);
} catch (e) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ reliability]
Consider adding a retry mechanism for postBusEvent to handle transient network errors more gracefully.

logger.debug(`Failed to post notification ${type}: ${e.message}`);
}
}

/**
* Submit a request to zendesk
* @param {Object} request the request
Expand Down Expand Up @@ -1756,6 +1848,8 @@ module.exports = {
setToInternalCache,
flushInternalCache,
removeNullProperties,
buildPhaseChangeEmailData,
sendPhaseChangeNotification
};

logger.buildService(module.exports);
1 change: 1 addition & 0 deletions src/common/prisma-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function convertChallengeSchemaToPrisma(currentUser, challenge) {
"groups",
"legacyId",
"wiproAllowed",
"funChallenge",
"numOfRegistrants",
"numOfSubmissions",
"numOfCheckpointSubmissions",
Expand Down
65 changes: 65 additions & 0 deletions src/services/ChallengePhaseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -813,9 +813,74 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data)
_.assignIn({ id: result.id }, data)
);
await postChallengeUpdatedNotification(challengeId);

// send notification logic
try {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The try-catch block is catching all exceptions and only logging them at the debug level. Consider logging at a higher level (e.g., error) or rethrowing the exception after logging to ensure that critical failures are not silently ignored.

const shouldNotifyClose = Boolean(isClosingPhase);
const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen

if (shouldNotifyClose || shouldNotifyOpen) {
// Single template - single type
const notificationType = "PHASE_CHANGE";

const operation = shouldNotifyClose
? "close"
: (isReopeningPhase ? "reopen" : "open");

const at = shouldNotifyClose
? (result.actualEndDate || new Date().toISOString())
: (result.actualStartDate || new Date().toISOString());

// fetch challenge name
const challenge = await prisma.challenge.findUnique({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The prisma.challenge.findUnique call does not handle the case where the challenge is not found. Consider adding error handling for this scenario to avoid potential null reference errors when accessing challenge.name.

where: { id: challengeId },
select: { name: true },
});

const challengeName = challenge?.name;

// build recipients
const resources = await helper.getChallengeResources(challengeId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The helper.getChallengeResources function call assumes that it will return a non-null value. Consider adding a null check to handle cases where the function might return null or undefined to prevent runtime errors.


const recipients = Array.from(
new Set(
(resources || [])
.map(r => r?.email || r?.memberEmail)
.filter(Boolean)
.map(e => String(e).trim().toLowerCase())
)
);

if (!recipients.length) {
logger.debug(
`phase change notification skipped: no recipients for challenge ${challengeId}`
);
return _.omit(result, constants.auditFields);
}

// build payload that matches the SendGrid HTML template
const phaseName = result.name || data.name || challengePhase.name;

const payload = helper.buildPhaseChangeEmailData({
challengeId,
challengeName,
phaseName,
operation,
at,
});

await helper.sendPhaseChangeNotification(notificationType, recipients, payload);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The helper.sendPhaseChangeNotification function is called without handling potential exceptions that might occur during the notification sending process. Consider wrapping this call in a try-catch block to handle any errors gracefully.

}
} catch (e) {
logger.debug(
`phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}`
);
}

return _.omit(result, constants.auditFields);
}


partiallyUpdateChallengePhase.schema = {
currentUser: Joi.any(),
challengeId: Joi.id(),
Expand Down
Loading
Loading