Skip to content

Commit cd76905

Browse files
committed
PM-4684 - challenge approval flow
1 parent a80a6c9 commit cd76905

4 files changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add budget approval workflow fields for challenge launch gating.
2+
CREATE TYPE "ChallengeApprovalStatusEnum" AS ENUM ('PENDING_APPROVAL', 'APPROVED', 'REJECTED');
3+
4+
ALTER TABLE "Challenge"
5+
ADD COLUMN "approvalStatus" "ChallengeApprovalStatusEnum" NOT NULL DEFAULT 'PENDING_APPROVAL',
6+
ADD COLUMN "approvalRejectionReason" TEXT,
7+
ADD COLUMN "approvalApprovedBy" TEXT;

prisma/schema.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ enum PrizeSetTypeEnum {
6161
CHECKPOINT
6262
}
6363

64+
enum ChallengeApprovalStatusEnum {
65+
PENDING_APPROVAL
66+
APPROVED
67+
REJECTED
68+
}
69+
6470
// Enum for review opportunity types on reviewers
6571
enum ReviewOpportunityTypeEnum {
6672
REGULAR_REVIEW
@@ -118,6 +124,9 @@ model Challenge {
118124
119125
// Additional fields from createChallenge schema
120126
status ChallengeStatusEnum @default(NEW) // new challenges default to status "New"
127+
approvalStatus ChallengeApprovalStatusEnum @default(PENDING_APPROVAL)
128+
approvalRejectionReason String?
129+
approvalApprovedBy String?
121130
// Normalized top‑level constraints (e.g. allowedRegistrants) for a challenge
122131
constraintRecord ChallengeConstraint?
123132

src/common/prisma-helper.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ function convertChallengeSchemaToPrisma(currentUser, challenge) {
9999
"numOfRegistrants",
100100
"numOfSubmissions",
101101
"numOfCheckpointSubmissions",
102+
"approvalStatus",
103+
"approvalRejectionReason",
104+
"approvalApprovedBy",
102105
]);
103106
// set legacy data
104107
if (!_.isNil(challenge.legacy)) {
@@ -208,6 +211,9 @@ function convertChallengeSchemaToPrisma(currentUser, challenge) {
208211
if (challenge.status) {
209212
result.status = challenge.status.toUpperCase();
210213
}
214+
if (challenge.approvalStatus) {
215+
result.approvalStatus = challenge.approvalStatus.toUpperCase();
216+
}
211217
// terms
212218
if (!_.isNil(challenge.terms)) {
213219
result.terms = {

src/services/ChallengeService.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const CHALLENGE_BILLING_LOCK_STATUSES = new Set([
5151
ChallengeStatusEnum.APPROVED,
5252
ChallengeStatusEnum.ACTIVE,
5353
]);
54+
const CHALLENGE_APPROVAL_STATUS = {
55+
PENDING_APPROVAL: "PENDING_APPROVAL",
56+
APPROVED: "APPROVED",
57+
REJECTED: "REJECTED",
58+
};
59+
const CHALLENGE_APPROVAL_ACTION_STATUSES = new Set([
60+
CHALLENGE_APPROVAL_STATUS.APPROVED,
61+
CHALLENGE_APPROVAL_STATUS.REJECTED,
62+
]);
5463

5564
// Provide aliases for friendlier sortBy query params
5665
const sortByAliases = {
@@ -97,6 +106,39 @@ function normalizeStatusSortValue(statusValue) {
97106
return normalizedStatus;
98107
}
99108

109+
function normalizeApprovalStatus(value) {
110+
if (_.isNil(value)) {
111+
return null;
112+
}
113+
114+
const normalized = _.toString(value).trim().toUpperCase();
115+
if (!normalized) {
116+
return null;
117+
}
118+
119+
if (!Object.values(CHALLENGE_APPROVAL_STATUS).includes(normalized)) {
120+
return null;
121+
}
122+
123+
return normalized;
124+
}
125+
126+
async function userCanApproveChallengeBudget(currentUser, challengeOrProjectId) {
127+
if (!currentUser) {
128+
return false;
129+
}
130+
131+
if (currentUser.isMachine || hasAdminRole(currentUser)) {
132+
return true;
133+
}
134+
135+
const projectId = _.isObject(challengeOrProjectId)
136+
? _.get(challengeOrProjectId, "projectId")
137+
: challengeOrProjectId;
138+
139+
return helper.userHasProjectManagerAccess(projectId, currentUser);
140+
}
141+
100142
function compareStatusSortValues(aStatusValue, bStatusValue) {
101143
const normalizedA = normalizeStatusSortValue(aStatusValue);
102144
const normalizedB = normalizeStatusSortValue(bStatusValue);
@@ -1991,6 +2033,39 @@ async function createChallenge(currentUser, challenge, userToken) {
19912033
_.set(challenge, "legacy.reviewType", _.toUpper(_.get(challenge, "legacy.reviewType")));
19922034
}
19932035

2036+
const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus);
2037+
const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge);
2038+
2039+
if (!requestedApprovalStatus) {
2040+
challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL;
2041+
} else {
2042+
challenge.approvalStatus = requestedApprovalStatus;
2043+
}
2044+
2045+
if (
2046+
CHALLENGE_APPROVAL_ACTION_STATUSES.has(challenge.approvalStatus) &&
2047+
!canApproveChallengeBudget
2048+
) {
2049+
throw new errors.ForbiddenError(
2050+
"Only admins or project managers with full access can approve or reject challenge budgets.",
2051+
);
2052+
}
2053+
2054+
if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) {
2055+
const rejectionReason = _.toString(challenge.approvalRejectionReason || "").trim();
2056+
if (!rejectionReason) {
2057+
throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge.");
2058+
}
2059+
challenge.approvalRejectionReason = rejectionReason;
2060+
challenge.approvalApprovedBy = null;
2061+
} else if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) {
2062+
challenge.approvalRejectionReason = null;
2063+
challenge.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null;
2064+
} else {
2065+
challenge.approvalRejectionReason = null;
2066+
challenge.approvalApprovedBy = null;
2067+
}
2068+
19942069
if (!challenge.status) {
19952070
challenge.status = ChallengeStatusEnum.NEW;
19962071
}
@@ -2370,6 +2445,11 @@ createChallenge.schema = {
23702445
})
23712446
.optional(),
23722447
startDate: Joi.date().iso(),
2448+
approvalStatus: Joi.string()
2449+
.valid(...Object.values(CHALLENGE_APPROVAL_STATUS))
2450+
.insensitive(),
2451+
approvalRejectionReason: Joi.string().allow(null, ""),
2452+
approvalApprovedBy: Joi.string().allow(null, ""),
23732453
status: Joi.string().valid(
23742454
ChallengeStatusEnum.ACTIVE,
23752455
ChallengeStatusEnum.NEW,
@@ -3015,6 +3095,49 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
30153095
const sanitizedIncludesTerms = Object.prototype.hasOwnProperty.call(data, "terms");
30163096
const shouldReplaceTerms =
30173097
sanitizedIncludesTerms || (payloadIncludesTerms && originalTermsValue === null);
3098+
const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge);
3099+
const requestedApprovalStatus = normalizeApprovalStatus(data.approvalStatus);
3100+
const prizeSetsUpdated =
3101+
Array.isArray(data.prizeSets) && isDifferentPrizeSets(data.prizeSets, challenge.prizeSets);
3102+
3103+
if (CHALLENGE_APPROVAL_ACTION_STATUSES.has(requestedApprovalStatus) && !canApproveChallengeBudget) {
3104+
throw new errors.ForbiddenError(
3105+
"Only admins or project managers with full access can approve or reject challenge budgets.",
3106+
);
3107+
}
3108+
3109+
if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) {
3110+
const rejectionReason = _.toString(data.approvalRejectionReason || "").trim();
3111+
if (!rejectionReason) {
3112+
throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge.");
3113+
}
3114+
data.approvalRejectionReason = rejectionReason;
3115+
data.approvalApprovedBy = null;
3116+
} else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) {
3117+
data.approvalRejectionReason = null;
3118+
data.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null;
3119+
}
3120+
3121+
if (
3122+
challenge.status === ChallengeStatusEnum.ACTIVE &&
3123+
prizeSetsUpdated &&
3124+
!canApproveChallengeBudget
3125+
) {
3126+
throw new errors.ForbiddenError(
3127+
"Prizes and copilot fee are locked after launch. Contact the Project Manager for updates.",
3128+
);
3129+
}
3130+
3131+
if (prizeSetsUpdated && challenge.status !== ChallengeStatusEnum.ACTIVE) {
3132+
data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL;
3133+
data.approvalRejectionReason = null;
3134+
data.approvalApprovedBy = null;
3135+
}
3136+
3137+
const resolvedApprovalStatus =
3138+
normalizeApprovalStatus(data.approvalStatus) ||
3139+
normalizeApprovalStatus(challenge.approvalStatus) ||
3140+
CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL;
30183141
logger.debug(`Sanitized Data: ${JSON.stringify(data)}`);
30193142

30203143
logger.debug(`updateChallenge(${challengeId}): fetching challenge resources`);
@@ -3036,6 +3159,11 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
30363159

30373160
const isStatusChangingToActive =
30383161
data.status === ChallengeStatusEnum.ACTIVE && challenge.status !== ChallengeStatusEnum.ACTIVE;
3162+
3163+
if (isStatusChangingToActive && resolvedApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED) {
3164+
throw new errors.BadRequestError("Challenge launch is blocked until budget approval is Approved.");
3165+
}
3166+
30393167
let sendActivationEmail = false;
30403168
let sendSubmittedEmail = false;
30413169
let sendCompletedEmail = false;
@@ -3962,6 +4090,11 @@ updateChallenge.schema = {
39624090
status: Joi.string()
39634091
.valid(..._.values(ChallengeStatusEnum))
39644092
.insensitive(),
4093+
approvalStatus: Joi.string()
4094+
.valid(...Object.values(CHALLENGE_APPROVAL_STATUS))
4095+
.insensitive(),
4096+
approvalRejectionReason: Joi.string().allow(null, ""),
4097+
approvalApprovedBy: Joi.string().allow(null, ""),
39654098
attachments: Joi.array().items(
39664099
Joi.object().keys({
39674100
id: Joi.id(),
@@ -4324,6 +4457,9 @@ function sanitizeChallenge(challenge) {
43244457
"legacyId",
43254458
"startDate",
43264459
"status",
4460+
"approvalStatus",
4461+
"approvalRejectionReason",
4462+
"approvalApprovedBy",
43274463
"task",
43284464
"groups",
43294465
"cancelReason",

0 commit comments

Comments
 (0)