From 674518fe77002ab6b92f8b8b5971ca5d15580288 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 13 May 2026 06:55:57 +1000 Subject: [PATCH 1/4] Skip approval flow for Topgear challenges --- config/default.js | 2 + src/services/ChallengeService.js | 249 +++++++++++++----- .../unit/challenge-activation-billing.test.js | 22 ++ 3 files changed, 211 insertions(+), 62 deletions(-) diff --git a/config/default.js b/config/default.js index 687c96c..b05984f 100644 --- a/config/default.js +++ b/config/default.js @@ -81,6 +81,8 @@ module.exports = { // topgear billing accounts TOPGEAR_BILLING_ACCOUNTS_ID: process.env.TOPGEAR_BILLING_ACCOUNTS_ID ? process.env.TOPGEAR_BILLING_ACCOUNTS_ID.split(",") + .map((billingAccountId) => billingAccountId.trim()) + .filter(Boolean) : [], // billing accounts that can bypass challenge activation expiry/funds validation diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 7dc668b..0ed111c 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -126,6 +126,97 @@ function normalizeApprovalStatus(value) { return normalized; } +/** + * Normalizes a configured billing-account list for membership checks. + * + * @param {Array|string|number|null|undefined} billingAccountIds Billing-account ids from config. + * @returns {Array} Trimmed billing-account ids, with empty values removed. + */ +function normalizeConfiguredBillingAccountIds(billingAccountIds) { + if (_.isNil(billingAccountIds)) { + return []; + } + + return _.flatMap([].concat(billingAccountIds), (billingAccountId) => + _.toString(billingAccountId).split(","), + ) + .map(normalizeOptionalString) + .filter(Boolean); +} + +/** + * Resolves the billing account that should drive challenge approval decisions. + * + * @param {Object|null|undefined} challenge Existing or incoming challenge payload. + * @param {Object|null|undefined} data Incoming update payload, when applicable. + * @param {string|number|null|undefined} projectBillingAccountId Billing account returned by the project. + * @returns {string|null} The first available billing-account id, or `null` when none is present. + */ +function getApprovalFlowBillingAccountId(challenge, data, projectBillingAccountId) { + return ( + normalizeOptionalString(projectBillingAccountId) || + normalizeOptionalString(_.get(data, "billing.billingAccountId")) || + normalizeOptionalString(_.get(challenge, "billing.billingAccountId")) || + normalizeOptionalString(_.get(challenge, "billingRecord.billingAccountId")) || + null + ); +} + +/** + * Determines whether the challenge approval flow should be bypassed. + * + * Challenges billed to configured Topgear billing accounts are auto-approved + * because they should not enter the manual budget approval flow. + * + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when challenge approval should be skipped. + */ +function shouldSkipChallengeApprovalFlow(billingAccountId) { + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + + if (!normalizedBillingAccountId) { + return false; + } + + return _.includes( + normalizeConfiguredBillingAccountIds(config.TOPGEAR_BILLING_ACCOUNTS_ID), + normalizedBillingAccountId, + ); +} + +/** + * Applies the approval-flow bypass to a challenge payload. + * + * @param {Object} target Challenge create or update payload to mutate. + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when approval fields were forced to approved. + */ +function applyChallengeApprovalFlowBypass(target, billingAccountId) { + if (!shouldSkipChallengeApprovalFlow(billingAccountId)) { + return false; + } + + target.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + target.approvalRejectionReason = null; + target.approvalApprovedBy = null; + + return true; +} + +/** + * Determines whether challenge activation must wait for budget approval. + * + * @param {string|null|undefined} approvalStatus Effective approval status. + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when launch should be blocked by approval state. + */ +function shouldBlockChallengeLaunchForApproval(approvalStatus, billingAccountId) { + return ( + !shouldSkipChallengeApprovalFlow(billingAccountId) && + normalizeApprovalStatus(approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED + ); +} + async function userCanApproveChallengeBudget(currentUser, challengeOrProjectId) { if (!currentUser) { return false; @@ -2008,6 +2099,7 @@ searchChallenges.schema = { /** * Create challenge. + * Challenges billed to configured Topgear accounts skip manual budget approval and are auto-approved. * @param {Object} currentUser the user who perform operation * @param {Object} challenge the challenge to created * @param {String} userToken the user token @@ -2112,37 +2204,47 @@ async function createChallenge(currentUser, challenge, userToken) { _.set(challenge, "legacy.reviewType", _.toUpper(_.get(challenge, "legacy.reviewType"))); } - const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); - const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge); + const approvalBillingAccountId = getApprovalFlowBillingAccountId(challenge); + const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( + challenge, + approvalBillingAccountId, + ); - if (!requestedApprovalStatus) { - challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; - } else { - challenge.approvalStatus = requestedApprovalStatus; - } + if (!skipsChallengeApprovalFlow) { + const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); + const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge); - if ( - CHALLENGE_APPROVAL_ACTION_STATUSES.has(challenge.approvalStatus) && - !canApproveChallengeBudget - ) { - throw new errors.ForbiddenError( - "Only admins or project managers with full access can approve or reject challenge budgets.", - ); - } + if (!requestedApprovalStatus) { + challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; + } else { + challenge.approvalStatus = requestedApprovalStatus; + } - if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { - const rejectionReason = _.toString(challenge.approvalRejectionReason || "").trim(); - if (!rejectionReason) { - throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge."); + if ( + CHALLENGE_APPROVAL_ACTION_STATUSES.has(challenge.approvalStatus) && + !canApproveChallengeBudget + ) { + throw new errors.ForbiddenError( + "Only admins or project managers with full access can approve or reject challenge budgets.", + ); + } + + if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { + const rejectionReason = _.toString(challenge.approvalRejectionReason || "").trim(); + if (!rejectionReason) { + throw new errors.BadRequestError( + "Rejection reason is required when rejecting a challenge.", + ); + } + challenge.approvalRejectionReason = rejectionReason; + challenge.approvalApprovedBy = null; + } else if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; + } else { + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = null; } - challenge.approvalRejectionReason = rejectionReason; - challenge.approvalApprovedBy = null; - } else if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { - challenge.approvalRejectionReason = null; - challenge.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; - } else { - challenge.approvalRejectionReason = null; - challenge.approvalApprovedBy = null; } if (!challenge.status) { @@ -2706,7 +2808,9 @@ function isDifferentPrizeSets(prizeSets = [], otherPrizeSets = []) { const buildPrizeKeys = (sets) => _.sortBy( _.flatMap(sets || [], (prizeSet) => { - const normalizedType = _.toString(_.get(prizeSet, "type", "")).trim().toUpperCase(); + const normalizedType = _.toString(_.get(prizeSet, "type", "")) + .trim() + .toUpperCase(); const prizes = Array.isArray(prizeSet && prizeSet.prizes) ? prizeSet.prizes : []; return prizes.map((prize) => { @@ -2943,7 +3047,7 @@ function shouldIgnoreChallengeActivationBillingValidation(billingAccountId) { } return _.includes( - _.map(config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS, normalizeOptionalString), + normalizeConfiguredBillingAccountIds(config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS), normalizedBillingAccountId, ); } @@ -3097,6 +3201,7 @@ function prepareTaskCompletionData(challenge, challengeResources, data) { * Update challenge. * When a challenge transitions to completed task status or a cancelled status, * payment generation is requested after the database update commits. + * Challenges billed to configured Topgear accounts skip manual budget approval and remain approved. * @param {Object} currentUser the user who perform operation * @param {String} challengeId the challenge id * @param {Object} data the challenge data to be updated @@ -3192,30 +3297,53 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { const requestedApprovalStatus = normalizeApprovalStatus(data.approvalStatus); const prizeSetsUpdated = Array.isArray(data.prizeSets) && isDifferentPrizeSets(data.prizeSets, challenge.prizeSets); + const approvalBillingAccountId = getApprovalFlowBillingAccountId( + challenge, + data, + billingAccountId, + ); + const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( + data, + approvalBillingAccountId, + ); - if ( - requestedApprovalStatus != null && - requestedApprovalStatus !== challenge.approvalStatus && - !canApproveChallengeBudget - ) { - throw new errors.ForbiddenError( - "Only admins or project managers with full access can change the challenge budget approval status.", - ); - } + if (!skipsChallengeApprovalFlow) { + if ( + requestedApprovalStatus != null && + requestedApprovalStatus !== challenge.approvalStatus && + !canApproveChallengeBudget + ) { + throw new errors.ForbiddenError( + "Only admins or project managers with full access can change the challenge budget approval status.", + ); + } + + if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { + const rejectionReason = rawApprovalRejectionReason.trim(); + if (!rejectionReason) { + throw new errors.BadRequestError( + "Rejection reason is required when rejecting a challenge.", + ); + } + data.approvalRejectionReason = rejectionReason; + data.approvalApprovedBy = null; + } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { + data.approvalRejectionReason = null; + data.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; + } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL) { + data.approvalRejectionReason = null; + data.approvalApprovedBy = null; + } - if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { - const rejectionReason = rawApprovalRejectionReason.trim(); - if (!rejectionReason) { - throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge."); + if ( + prizeSetsUpdated && + challenge.status !== ChallengeStatusEnum.ACTIVE && + (requestedApprovalStatus == null || !canApproveChallengeBudget) + ) { + data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; + data.approvalRejectionReason = null; + data.approvalApprovedBy = null; } - data.approvalRejectionReason = rejectionReason; - data.approvalApprovedBy = null; - } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { - data.approvalRejectionReason = null; - data.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; - } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL) { - data.approvalRejectionReason = null; - data.approvalApprovedBy = null; } if ( @@ -3228,16 +3356,6 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { ); } - if ( - prizeSetsUpdated && - challenge.status !== ChallengeStatusEnum.ACTIVE && - (requestedApprovalStatus == null || !canApproveChallengeBudget) - ) { - data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; - data.approvalRejectionReason = null; - data.approvalApprovedBy = null; - } - const resolvedApprovalStatus = normalizeApprovalStatus(data.approvalStatus) || normalizeApprovalStatus(challenge.approvalStatus) || @@ -3267,8 +3385,13 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { data.status === ChallengeStatusEnum.COMPLETED && challenge.status !== ChallengeStatusEnum.COMPLETED; - if (isStatusChangingToActive && resolvedApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED) { - throw new errors.BadRequestError("Challenge launch is blocked until budget approval is Approved."); + if ( + isStatusChangingToActive && + shouldBlockChallengeLaunchForApproval(resolvedApprovalStatus, approvalBillingAccountId) + ) { + throw new errors.BadRequestError( + "Challenge launch is blocked until budget approval is Approved.", + ); } let sendActivationEmail = false; @@ -5140,6 +5263,8 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) { module.exports = { __testables: { + shouldBlockChallengeLaunchForApproval, + shouldSkipChallengeApprovalFlow, validateChallengeActivationBillingAccount, }, searchChallenges, diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index eadcc58..b169731 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -4,6 +4,7 @@ if (!process.env.REVIEW_DB_URL && process.env.DATABASE_URL) { require("../../app-bootstrap"); const chai = require("chai"); +const config = require("config"); const service = require("../../src/services/ChallengeService"); const projectHelper = require("../../src/common/project-helper"); const { ChallengeStatusEnum } = require("../../src/common/prisma"); @@ -13,14 +14,19 @@ const should = chai.should(); describe("challenge activation billing validation unit tests", () => { const validateChallengeActivationBillingAccount = service.__testables.validateChallengeActivationBillingAccount; + const shouldBlockChallengeLaunchForApproval = + service.__testables.shouldBlockChallengeLaunchForApproval; + const shouldSkipChallengeApprovalFlow = service.__testables.shouldSkipChallengeApprovalFlow; const projectChallenge = { status: ChallengeStatusEnum.DRAFT, timelineTemplateId: "project-required-template", }; const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails; + const originalTopgearBillingAccounts = config.TOPGEAR_BILLING_ACCOUNTS_ID; afterEach(() => { projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails; + config.TOPGEAR_BILLING_ACCOUNTS_ID = originalTopgearBillingAccounts; }); it("prevents activation when the project has no billing account", async () => { @@ -137,4 +143,20 @@ describe("challenge activation billing validation unit tests", () => { endDate: "2000-01-01T00:00:00.000Z", }); }); + + it("skips approval flow for configured Topgear billing accounts", () => { + config.TOPGEAR_BILLING_ACCOUNTS_ID = [" 80000062 ", 80000063]; + + should.equal(shouldSkipChallengeApprovalFlow("80000062"), true); + should.equal(shouldSkipChallengeApprovalFlow(80000063), true); + should.equal(shouldSkipChallengeApprovalFlow("80001061"), false); + }); + + it("does not block launch approval for configured Topgear billing accounts", () => { + config.TOPGEAR_BILLING_ACCOUNTS_ID = ["80000062"]; + + should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80000062"), false); + should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061"), true); + should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false); + }); }); From 566875aa3b943b90c9d2b5337aa6abde1c593e9c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 13 May 2026 07:18:26 +1000 Subject: [PATCH 2/4] Skip BA lock when launching TG challenges --- config/default.js | 2 +- src/services/ChallengeService.js | 19 +++++-- .../unit/challenge-activation-billing.test.js | 54 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/config/default.js b/config/default.js index b05984f..02388ba 100644 --- a/config/default.js +++ b/config/default.js @@ -85,7 +85,7 @@ module.exports = { .filter(Boolean) : [], - // billing accounts that can bypass challenge activation expiry/funds validation + // billing accounts that can bypass challenge activation expiry/funds validation and budget locks IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS: process.env .IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS ? process.env.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS.split(",") diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 0ed111c..ef995ad 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -399,6 +399,9 @@ function getChallengeMemberPaymentAmount(challenge) { * finance later consumes the finalized payment amount after payment generation. * This keeps draft challenge rows visible as locked budget in billing-account * details until finance moves the row to consumed. + * Accounts configured to ignore challenge activation billing validation also + * skip this lock because the Billing Accounts API validates available funds + * when writing the lock. * * @param {object} challenge Challenge model or response object after persistence. * @returns {Promise} Resolves after the billing-account lock is written or skipped. @@ -414,6 +417,14 @@ async function syncChallengeBillingAccountLock(challenge) { const hasBillingAccountId = !_.isNil(billingAccountId) && _.toString(billingAccountId).trim(); const memberPaymentAmount = getChallengeMemberPaymentAmount(challenge); + if (shouldIgnoreChallengeActivationBillingValidation(billingAccountId)) { + logger.info("Skipping challenge billing lock sync for ignored billing account", { + billingAccountId, + challengeId: _.get(challenge, "id"), + }); + return; + } + if (!hasBillingAccountId || _.isNil(memberPaymentAmount)) { logger.warn("Skipping challenge billing lock sync due to missing billing context", { challengeId: _.get(challenge, "id"), @@ -3029,12 +3040,13 @@ function isBillingAccountExpired(active, endDate) { } /** - * Determines whether challenge activation should skip expiry/funds validation - * for a billing account. + * Determines whether challenge billing funds checks should be skipped for a + * billing account. * * Missing, inactive, and not-found checks still apply. The bypass is intended * for specific accounts that must remain launchable despite expired dates or - * depleted remaining budget. + * depleted remaining budget, and that should not call the budget-lock endpoint + * because it enforces the same remaining-funds constraint. * * @param {string|number|null|undefined} billingAccountId Billing-account identifier. * @returns {boolean} `true` when expiry/funds validation should be skipped. @@ -5265,6 +5277,7 @@ module.exports = { __testables: { shouldBlockChallengeLaunchForApproval, shouldSkipChallengeApprovalFlow, + syncChallengeBillingAccountLock, validateChallengeActivationBillingAccount, }, searchChallenges, diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index b169731..4f6661c 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -17,15 +17,20 @@ describe("challenge activation billing validation unit tests", () => { const shouldBlockChallengeLaunchForApproval = service.__testables.shouldBlockChallengeLaunchForApproval; const shouldSkipChallengeApprovalFlow = service.__testables.shouldSkipChallengeApprovalFlow; + const syncChallengeBillingAccountLock = service.__testables.syncChallengeBillingAccountLock; const projectChallenge = { status: ChallengeStatusEnum.DRAFT, timelineTemplateId: "project-required-template", }; const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails; + const originalLockChallengeBillingAccountAmount = projectHelper.lockChallengeBillingAccountAmount; + const originalIgnoredBillingAccounts = config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS; const originalTopgearBillingAccounts = config.TOPGEAR_BILLING_ACCOUNTS_ID; afterEach(() => { projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails; + projectHelper.lockChallengeBillingAccountAmount = originalLockChallengeBillingAccountAmount; + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = originalIgnoredBillingAccounts; config.TOPGEAR_BILLING_ACCOUNTS_ID = originalTopgearBillingAccounts; }); @@ -159,4 +164,53 @@ describe("challenge activation billing validation unit tests", () => { should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061"), true); should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false); }); + + it("skips budget lock funds validation for ignored billing accounts", async () => { + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; + let lockCalled = false; + projectHelper.lockChallengeBillingAccountAmount = async () => { + lockCalled = true; + }; + + await syncChallengeBillingAccountLock({ + id: "challenge-id", + status: ChallengeStatusEnum.DRAFT, + billing: { + billingAccountId: "80000062", + markup: 0, + }, + overview: { + totalPrizes: 100, + }, + }); + + should.equal(lockCalled, false); + }); + + it("continues budget lock sync for non-ignored billing accounts", async () => { + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; + let lockRequest; + projectHelper.lockChallengeBillingAccountAmount = async (request) => { + lockRequest = request; + }; + + await syncChallengeBillingAccountLock({ + id: "challenge-id", + status: ChallengeStatusEnum.DRAFT, + billing: { + billingAccountId: "80001061", + markup: 0.1, + }, + overview: { + totalPrizes: 100, + }, + }); + + lockRequest.should.deep.equal({ + billingAccountId: "80001061", + challengeId: "challenge-id", + markup: 0.1, + memberPaymentAmount: 100, + }); + }); }); From 1b78405a0e488b0842647ab992dd6fc970ec1c77 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 13 May 2026 13:58:13 +1000 Subject: [PATCH 3/4] Force approved status when creating challenges, temporarily to get around TaaS / TG issues --- src/services/ChallengeService.js | 51 +++++++++++++++---- .../unit/challenge-activation-billing.test.js | 28 ++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index ef995ad..a4491ab 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -203,6 +203,33 @@ function applyChallengeApprovalFlowBypass(target, billingAccountId) { return true; } +/** + * Applies the temporary create-time approval hotfix to a challenge payload. + * + * Challenges created in NEW or DRAFT status are forced to approved while the + * budget approval flow is being investigated. A missing create status is + * treated as NEW because createChallenge defaults it later in the workflow. + * + * @param {Object} challenge Challenge create payload to mutate. + * @returns {boolean} `true` when approval fields were forced to approved. + */ +function applyCreateChallengeApprovalStatusHotfix(challenge) { + const challengeStatus = normalizeStatusSortValue(challenge.status || ChallengeStatusEnum.NEW); + + if ( + challengeStatus !== ChallengeStatusEnum.NEW && + challengeStatus !== ChallengeStatusEnum.DRAFT + ) { + return false; + } + + challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = null; + + return true; +} + /** * Determines whether challenge activation must wait for budget approval. * @@ -2110,6 +2137,7 @@ searchChallenges.schema = { /** * Create challenge. + * Temporary hotfix: NEW and DRAFT challenge creations are auto-approved. * Challenges billed to configured Topgear accounts skip manual budget approval and are auto-approved. * @param {Object} currentUser the user who perform operation * @param {Object} challenge the challenge to created @@ -2215,11 +2243,19 @@ async function createChallenge(currentUser, challenge, userToken) { _.set(challenge, "legacy.reviewType", _.toUpper(_.get(challenge, "legacy.reviewType"))); } - const approvalBillingAccountId = getApprovalFlowBillingAccountId(challenge); - const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( - challenge, - approvalBillingAccountId, - ); + if (!challenge.status) { + challenge.status = ChallengeStatusEnum.NEW; + } + + let skipsChallengeApprovalFlow = applyCreateChallengeApprovalStatusHotfix(challenge); + + if (!skipsChallengeApprovalFlow) { + const approvalBillingAccountId = getApprovalFlowBillingAccountId(challenge); + skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( + challenge, + approvalBillingAccountId, + ); + } if (!skipsChallengeApprovalFlow) { const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); @@ -2258,10 +2294,6 @@ async function createChallenge(currentUser, challenge, userToken) { } } - if (!challenge.status) { - challenge.status = ChallengeStatusEnum.NEW; - } - if (!challenge.startDate) { challenge.startDate = new Date().toISOString(); } else { @@ -5275,6 +5307,7 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) { module.exports = { __testables: { + applyCreateChallengeApprovalStatusHotfix, shouldBlockChallengeLaunchForApproval, shouldSkipChallengeApprovalFlow, syncChallengeBillingAccountLock, diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index 4f6661c..6508862 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -12,6 +12,8 @@ const { ChallengeStatusEnum } = require("../../src/common/prisma"); const should = chai.should(); describe("challenge activation billing validation unit tests", () => { + const applyCreateChallengeApprovalStatusHotfix = + service.__testables.applyCreateChallengeApprovalStatusHotfix; const validateChallengeActivationBillingAccount = service.__testables.validateChallengeActivationBillingAccount; const shouldBlockChallengeLaunchForApproval = @@ -165,6 +167,32 @@ describe("challenge activation billing validation unit tests", () => { should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false); }); + it("auto-approves NEW and DRAFT challenge creation payloads", () => { + const defaultNewChallenge = {}; + const draftChallenge = { + status: ChallengeStatusEnum.DRAFT, + approvalStatus: "REJECTED", + approvalRejectionReason: "too expensive", + approvalApprovedBy: "approver", + }; + const approvedChallenge = { + status: ChallengeStatusEnum.APPROVED, + }; + + should.equal(applyCreateChallengeApprovalStatusHotfix(defaultNewChallenge), true); + should.equal(defaultNewChallenge.approvalStatus, "APPROVED"); + should.equal(defaultNewChallenge.approvalRejectionReason, null); + should.equal(defaultNewChallenge.approvalApprovedBy, null); + + should.equal(applyCreateChallengeApprovalStatusHotfix(draftChallenge), true); + should.equal(draftChallenge.approvalStatus, "APPROVED"); + should.equal(draftChallenge.approvalRejectionReason, null); + should.equal(draftChallenge.approvalApprovedBy, null); + + should.equal(applyCreateChallengeApprovalStatusHotfix(approvedChallenge), false); + should.equal(approvedChallenge.approvalStatus, undefined); + }); + it("skips budget lock funds validation for ignored billing accounts", async () => { config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; let lockCalled = false; From cc25ea3f993c86a07d08df6e14fc1b931b937e65 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 13 May 2026 14:16:43 +1000 Subject: [PATCH 4/4] Tweak PATCH to support preset approval status --- src/services/ChallengeService.js | 41 ++++++++++++++++ test/unit/ChallengeService.test.js | 11 ++++- .../unit/challenge-activation-billing.test.js | 48 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index a4491ab..a94c97b 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -230,6 +230,39 @@ function applyCreateChallengeApprovalStatusHotfix(challenge) { return true; } +/** + * Applies the temporary NEW-to-DRAFT approval preservation hotfix to an update payload. + * + * Challenges auto-approved during creation must keep that approved state when + * saved from NEW to DRAFT, even if the same PATCH includes prize data. + * + * @param {Object} challenge Existing challenge response payload. + * @param {Object} data Sanitized challenge update payload to mutate. + * @param {string|null|undefined} requestedApprovalStatus Valid approval status from the update payload. + * @returns {boolean} `true` when approval fields were forced to remain approved. + */ +function applyNewDraftApprovalStatusPreservationHotfix(challenge, data, requestedApprovalStatus) { + const currentStatus = normalizeStatusSortValue(challenge.status); + const targetStatus = normalizeStatusSortValue(data.status || challenge.status); + const currentApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); + + if ( + currentStatus !== ChallengeStatusEnum.NEW || + targetStatus !== ChallengeStatusEnum.DRAFT || + currentApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED || + (requestedApprovalStatus != null && + requestedApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED) + ) { + return false; + } + + data.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + data.approvalRejectionReason = null; + delete data.approvalApprovedBy; + + return true; +} + /** * Determines whether challenge activation must wait for budget approval. * @@ -3379,9 +3412,16 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { data.approvalApprovedBy = null; } + const preservesNewDraftApprovalStatus = applyNewDraftApprovalStatusPreservationHotfix( + challenge, + data, + requestedApprovalStatus, + ); + if ( prizeSetsUpdated && challenge.status !== ChallengeStatusEnum.ACTIVE && + !preservesNewDraftApprovalStatus && (requestedApprovalStatus == null || !canApproveChallengeBudget) ) { data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; @@ -5308,6 +5348,7 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) { module.exports = { __testables: { applyCreateChallengeApprovalStatusHotfix, + applyNewDraftApprovalStatusPreservationHotfix, shouldBlockChallengeLaunchForApproval, shouldSkipChallengeApprovalFlow, syncChallengeBillingAccountLock, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 87a2a89..f7002cf 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -1759,23 +1759,30 @@ describe("challenge service unit tests", () => { config.M2M_FULL_ACCESS_TOKEN, ); createdChallengeId = created.id; + should.equal(created.approvalStatus, "APPROVED"); should.equal(billingLockRequests.length, 0); + const draftPrizeSets = _.cloneDeep(challengeData.prizeSets); + draftPrizeSets[0].prizes[0].value = 1005; + const draft = await service.updateChallenge( { isMachine: true, sub: "sub-billing-lock-update", userId: 22838965 }, created.id, { + approvalStatus: "APPROVED", + prizeSets: draftPrizeSets, status: ChallengeStatusEnum.DRAFT, }, ); + should.equal(draft.approvalStatus, "APPROVED"); should.equal(draft.billing.billingAccountId, "80001012"); should.equal(billingLockRequests.length, 1); billingLockRequests[0].should.deep.equal({ billingAccountId: "80001012", challengeId: created.id, markup: 0.1, - memberPaymentAmount: 1150, + memberPaymentAmount: 1155, }); const updatedPrizeSets = _.cloneDeep(draft.prizeSets); @@ -1800,7 +1807,7 @@ describe("challenge service unit tests", () => { billingAccountId: "80001012", challengeId: created.id, markup: 0.1, - memberPaymentAmount: 1225, + memberPaymentAmount: 1230, }); } finally { projectHelper.getProject = originalGetProject; diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index 6508862..4e9c25e 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -14,6 +14,8 @@ const should = chai.should(); describe("challenge activation billing validation unit tests", () => { const applyCreateChallengeApprovalStatusHotfix = service.__testables.applyCreateChallengeApprovalStatusHotfix; + const applyNewDraftApprovalStatusPreservationHotfix = + service.__testables.applyNewDraftApprovalStatusPreservationHotfix; const validateChallengeActivationBillingAccount = service.__testables.validateChallengeActivationBillingAccount; const shouldBlockChallengeLaunchForApproval = @@ -193,6 +195,52 @@ describe("challenge activation billing validation unit tests", () => { should.equal(approvedChallenge.approvalStatus, undefined); }); + it("keeps approved status when an approved NEW challenge is saved as DRAFT", () => { + const existingChallenge = { + status: ChallengeStatusEnum.NEW, + approvalStatus: "APPROVED", + approvalApprovedBy: "existing-approver", + }; + const updatePayload = { + status: ChallengeStatusEnum.DRAFT, + approvalApprovedBy: "incoming-approver", + }; + const pendingChallenge = { + status: ChallengeStatusEnum.NEW, + approvalStatus: "PENDING_APPROVAL", + }; + const pendingUpdatePayload = { + status: ChallengeStatusEnum.DRAFT, + }; + const rejectedUpdatePayload = { + status: ChallengeStatusEnum.DRAFT, + }; + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix(existingChallenge, updatePayload), + true, + ); + should.equal(updatePayload.approvalStatus, "APPROVED"); + should.equal(updatePayload.approvalRejectionReason, null); + should.equal(updatePayload.approvalApprovedBy, undefined); + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix(pendingChallenge, pendingUpdatePayload), + false, + ); + should.equal(pendingUpdatePayload.approvalStatus, undefined); + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix( + existingChallenge, + rejectedUpdatePayload, + "REJECTED", + ), + false, + ); + should.equal(rejectedUpdatePayload.approvalStatus, undefined); + }); + it("skips budget lock funds validation for ignored billing accounts", async () => { config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; let lockCalled = false;