@@ -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
5665const 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+
100142function 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