diff --git a/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql b/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql new file mode 100644 index 0000000..5a54934 --- /dev/null +++ b/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE "challenge_lock" ( + "id" SERIAL PRIMARY KEY, + "external_id" VARCHAR(255) NOT NULL UNIQUE, + "lock_time" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "error" TEXT +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c556c48..4ae854a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -188,6 +188,13 @@ model trolley_recipient_payment_method { trolley_recipient trolley_recipient @relation(fields: [trolley_recipient_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_trolley_recipient_trolley_recipient_payment_method") } +model challenge_lock { + id Int @id @default(autoincrement()) + external_id String @unique @db.VarChar(255) + lock_time DateTime @default(now()) @db.Timestamp(6) + error String? @db.Text +} + enum verification_status { ACTIVE INACTIVE diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index b4aaf2a..d6bf917 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -1,11 +1,11 @@ import { includes, isEmpty, find, camelCase, groupBy, orderBy } from 'lodash'; -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; import { ENV_CONFIG } from 'src/config'; import { Logger } from 'src/shared/global'; import { Challenge, ChallengeResource, - ChallengeSubmission, + ChallengeReview, ResourceRole, } from './models'; import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service'; @@ -18,6 +18,15 @@ import { WinningsType, } from 'src/dto/winning.dto'; import { WinningsRepository } from '../repository/winnings.repo'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +interface PaymentPayload { + handle: string; + amount: number; + userId: string; + type: WinningsCategory; + description?: string; +} const placeToOrdinal = (place: number) => { if (place === 1) return '1st'; @@ -34,6 +43,7 @@ export class ChallengesService { private readonly logger = new Logger(ChallengesService.name); constructor( + private readonly prisma: PrismaService, private readonly m2MService: TopcoderM2MService, private readonly baService: BillingAccountsService, private readonly winningsService: WinningsService, @@ -54,20 +64,19 @@ export class ChallengesService { } } - async getChallengeSubmissionsCount(challengeId: string) { - const requestUrl = `${TC_API_BASE}/submissions?challengeId=${challengeId}&perPage=9999`; + async getChallengeReviews(challengeId: string) { + const requestUrl = `${TC_API_BASE}/reviews?challengeId=${challengeId}&status=COMPLETED&thin=true&perPage=9999`; try { - const submissions = - await this.m2MService.m2mFetch(requestUrl); - const uniqueSubmissions = Object.fromEntries( - submissions.map((s) => [s.memberId, s]), - ); - return Object.keys(uniqueSubmissions).length; + const resposne = await this.m2MService.m2mFetch<{ + data: ChallengeReview[]; + }>(requestUrl); + return resposne.data; } catch (e) { this.logger.error( - `Challenge submissions couldn't be fetched for challenge ${challengeId}!`, - e, + `Challenge reviews couldn't be fetched for challenge ${challengeId}!`, + e.message, + e.status, ); } } @@ -100,101 +109,142 @@ export class ChallengesService { } } - async getChallengePayments(challenge: Challenge) { - this.logger.log( - `Generating payments for challenge ${challenge.name} (${challenge.id}).`, + generateWinnersPayments(challenge: Challenge): PaymentPayload[] { + const { prizeSets, winners } = challenge; + + const isCancelledFailedReview = + challenge.status.toLowerCase() === + ChallengeStatuses.CancelledFailedReview.toLowerCase(); + + if (isCancelledFailedReview) { + return []; + } + + // generate placement payments + const placementPrizes = orderBy( + find(prizeSets, { type: 'PLACEMENT' })?.prizes, + 'value', + 'desc', ); - const challengeResources = await this.getChallengeResources(challenge.id); - if (!challengeResources || isEmpty(challengeResources)) { - throw new Error('Missing challenge resources!'); + if (placementPrizes.length < winners.length) { + throw new Error( + 'Task has incorrect number of placement prizes! There are more winners than prizes!', + ); } - const payments = [] as { - handle: string; - amount: number; - userId: string; - type: WinningsCategory; - description?: string; - }[]; + return winners.map((winner) => ({ + handle: winner.handle, + amount: placementPrizes[winner.placement - 1].value, + userId: winner.userId.toString(), + type: challenge.task.isTask + ? WinningsCategory.TASK_PAYMENT + : WinningsCategory.CONTEST_PAYMENT, + description: + challenge.type === 'Task' + ? challenge.name + : `${challenge.name} - ${placeToOrdinal(winner.placement)} Place`, + })); + } - const { prizeSets, winners, reviewers } = challenge; + generateCopilotPayment( + challenge: Challenge, + copilots: ChallengeResource[], + ): PaymentPayload[] { const isCancelledFailedReview = challenge.status.toLowerCase() === ChallengeStatuses.CancelledFailedReview.toLowerCase(); + const copilotPrizes = + find(challenge.prizeSets, { type: 'COPILOT' })?.prizes ?? []; + + if (!copilotPrizes.length || isCancelledFailedReview) { + return []; + } + + if (!copilots?.length) { + throw new Error('Task has a copilot prize but no copilot assigned!'); + } + + return copilots.map((copilot) => ({ + handle: copilot.memberHandle, + amount: copilotPrizes[0].value, + userId: copilot.memberId.toString(), + type: WinningsCategory.COPILOT_PAYMENT, + })); + } + + async generateReviewersPayments( + challenge: Challenge, + reviewers: ChallengeResource[], + ): Promise { // generate placement payments const placementPrizes = orderBy( - find(prizeSets, { type: 'PLACEMENT' })?.prizes, + find(challenge.prizeSets, { type: 'PLACEMENT' })?.prizes, 'value', 'desc', ); - if (!isCancelledFailedReview) { - if (placementPrizes.length < winners.length) { - throw new Error( - 'Task has incorrect number of placement prizes! There are more winners than prizes!', - ); - } + // generate reviewer payments + const firstPlacePrize = placementPrizes?.[0]?.value ?? 0; + const challengeReviewer = find(challenge.reviewers, { + isMemberReview: true, + }); - winners.forEach((winner) => { - payments.push({ - handle: winner.handle, - amount: placementPrizes[winner.placement - 1].value, - userId: winner.userId.toString(), - type: challenge.task.isTask - ? WinningsCategory.TASK_PAYMENT - : WinningsCategory.CONTEST_PAYMENT, - description: - challenge.type === 'Task' - ? challenge.name - : `${challenge.name} - ${placeToOrdinal(winner.placement)} Place`, - }); - }); + const challengeReviews = await this.getChallengeReviews(challenge.id); + + if (!challengeReviewer || !reviewers?.length || !challengeReviews?.length) { + return []; } - // generate copilot payments - const copilotPrizes = find(prizeSets, { type: 'COPILOT' })?.prizes ?? []; - if (copilotPrizes.length && !isCancelledFailedReview) { - const copilots = challengeResources.copilot; + return reviewers.map((reviewer) => { + const numOfSubmissions = challengeReviews.filter( + (r) => + r.reviewerHandle.toLowerCase() === + reviewer.memberHandle.toLowerCase(), + ).length; + return { + handle: reviewer.memberHandle, + userId: reviewer.memberId.toString(), + amount: Math.round( + (challengeReviewer.fixedAmount ?? 0) + + (challengeReviewer.baseCoefficient ?? 0) * firstPlacePrize + + (challengeReviewer.incrementalCoefficient ?? 0) * + firstPlacePrize * + numOfSubmissions, + ), + type: WinningsCategory.REVIEW_BOARD_PAYMENT, + }; + }); + } - if (!copilots?.length) { - throw new Error('Task has a copilot prize but no copilot assigned!'); - } + async getChallengePayments(challenge: Challenge) { + this.logger.log( + `Generating payments for challenge ${challenge.name} (${challenge.id}).`, + ); - copilots.forEach((copilot) => { - payments.push({ - handle: copilot.memberHandle, - amount: copilotPrizes[0].value, - userId: copilot.memberId.toString(), - type: WinningsCategory.COPILOT_PAYMENT, - }); - }); - } + const challengeResources = await this.getChallengeResources(challenge.id); - // generate reviewer payments - const firstPlacePrize = placementPrizes?.[0]?.value ?? 0; - const challengeReviewer = find(reviewers, { isMemberReview: true }); - const numOfSubmissions = - (await this.getChallengeSubmissionsCount(challenge.id)) ?? 1; - - if (challengeReviewer && challengeResources.reviewer) { - challengeResources.reviewer?.forEach((reviewer) => { - payments.push({ - handle: reviewer.memberHandle, - userId: reviewer.memberId.toString(), - amount: Math.round( - (challengeReviewer.fixedAmount ?? 0) + - (challengeReviewer.baseCoefficient ?? 0) * firstPlacePrize + - (challengeReviewer.incrementalCoefficient ?? 0) * - firstPlacePrize * - numOfSubmissions, - ), - type: WinningsCategory.REVIEW_BOARD_PAYMENT, - }); - }); + if (!challengeResources || isEmpty(challengeResources)) { + throw new Error('Missing challenge resources!'); } + const winnersPayments = this.generateWinnersPayments(challenge); + const copilotPayments = this.generateCopilotPayment( + challenge, + challengeResources.copilot, + ); + const reviewersPayments = await this.generateReviewersPayments( + challenge, + challengeResources.reviewer, + ); + + const payments: PaymentPayload[] = [ + ...winnersPayments, + ...copilotPayments, + ...reviewersPayments, + ]; + const totalAmount = payments.reduce( (sum, payment) => sum + payment.amount, 0, @@ -227,33 +277,19 @@ export class ChallengesService { })); } - async generateChallengePayments(challengeId: string, userId: string) { - const challenge = await this.getChallenge(challengeId); - - if (!challenge) { - throw new Error('Challenge not found!'); - } - - const allowedStatuses = [ - ChallengeStatuses.Completed.toLowerCase(), - ChallengeStatuses.CancelledFailedReview.toLowerCase(), - ]; - - if (!allowedStatuses.includes(challenge.status.toLowerCase())) { - throw new Error("Challenge isn't in a payable status!"); - } - + private async createPayments(challenge: Challenge, userId: string) { const existingPayments = ( await this.winningsRepo.searchWinnings({ - externalIds: [challengeId], + externalIds: [challenge.id], } as WinningRequestDto) )?.data?.winnings; + if (existingPayments?.length > 0) { this.logger.log( - `Payments already exist for challenge ${challengeId}, skipping payment generation`, + `Payments already exist for challenge ${challenge.id}, skipping payment generation`, ); throw new Error( - `Payments already exist for challenge ${challengeId}, skipping payment generation`, + `Payments already exist for challenge ${challenge.id}, skipping payment generation`, ); } @@ -291,4 +327,57 @@ export class ChallengesService { this.logger.log('Task Completed. locking consumed budget', baValidation); await this.baService.lockConsumeAmount(baValidation); } + + async generateChallengePayments(challengeId: string, userId: string) { + const challenge = await this.getChallenge(challengeId); + + if (!challenge) { + throw new Error('Challenge not found!'); + } + + const allowedStatuses = [ + ChallengeStatuses.Completed.toLowerCase(), + ChallengeStatuses.CancelledFailedReview.toLowerCase(), + ]; + + if (!allowedStatuses.includes(challenge.status.toLowerCase())) { + throw new Error("Challenge isn't in a payable status!"); + } + + // need to read for update (LOCK the rows) + try { + await this.prisma.challenge_lock.create({ + data: { external_id: challenge.id }, + }); + } catch (err: any) { + if (err.code === 'P2002') { + this.logger.log(`Challenge Lock already acquired for ${challenge.id}`); + // P2002 = unique constraint failed → lock already exists + throw new ConflictException( + `Challenge Lock already acquired for ${challenge.id}`, + ); + } + throw err; + } + + try { + await this.createPayments(challenge, userId); + } catch (error) { + if (error.message.includes('Lock already acquired')) { + throw new ConflictException( + 'Another payment operation is in progress.', + ); + } else { + throw error; + } + } finally { + await this.prisma.challenge_lock + .deleteMany({ + where: { external_id: challenge.id }, + }) + .catch(() => { + // swallow errors if lock was already released + }); + } + } } diff --git a/src/api/challenges/models/challenge.ts b/src/api/challenges/models/challenge.ts index 28d9fa7..d843aa6 100644 --- a/src/api/challenges/models/challenge.ts +++ b/src/api/challenges/models/challenge.ts @@ -143,8 +143,19 @@ export interface ResourceRole { name: string; } -export interface ChallengeSubmission { +export interface ChallengeReview { id: string; - memberId: string; - type: 'CONTEST_SUBMISSION'; + resourceId: string; + phaseId: string; + submissionId: string; + scorecardId: string; + committed: boolean; + finalScore: number; + initialScore: number; + typeId: string; + status: string; + reviewDate: string; + reviewerHandle: string; + phaseName: string; + submitterHandle: string; } diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index 25fbd24..b0ca3bd 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -4,11 +4,7 @@ import { ENV_CONFIG } from 'src/config'; import { ChallengeStatuses } from 'src/dto/challenge.dto'; import { TopcoderM2MService } from './topcoder-m2m.service'; -const { - TOPCODER_API_V6_BASE_URL, - TGBillingAccounts, -} = ENV_CONFIG; - +const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG; interface LockAmountDTO { challengeId: string; @@ -37,7 +33,7 @@ export class BillingAccountsService { constructor(private readonly m2MService: TopcoderM2MService) {} async lockAmount(billingAccountId: number, dto: LockAmountDTO) { - this.logger.log("BA validation lock amount:", billingAccountId, dto); + this.logger.log('BA validation lock amount:', billingAccountId, dto); try { return await this.m2MService.m2mFetch( @@ -48,16 +44,19 @@ export class BillingAccountsService { }, ); } catch (err: any) { - this.logger.error(err.response?.data?.result?.content ?? "Failed to lock challenge amount"); + this.logger.error( + err.response?.data?.result?.content ?? + 'Failed to lock challenge amount', + ); throw new Error( `Budget Error: Requested amount $${dto.lockAmount} exceeds available budget for Billing Account #${billingAccountId}. - Please contact the Topcoder Project Manager for further assistance.` + Please contact the Topcoder Project Manager for further assistance.`, ); } } async consumeAmount(billingAccountId: number, dto: ConsumeAmountDTO) { - this.logger.log("BA validation consume amount:", billingAccountId, dto); + this.logger.log('BA validation consume amount:', billingAccountId, dto); try { return await this.m2MService.m2mFetch( @@ -68,23 +67,38 @@ export class BillingAccountsService { }, ); } catch (err: any) { - this.logger.error(err.response?.data?.result?.content ?? "Failed to consume challenge amount", err); - throw new Error("Failed to consume challenge amount"); + this.logger.error( + err.response?.data?.result?.content ?? + 'Failed to consume challenge amount', + err, + ); + throw new Error('Failed to consume challenge amount'); } } - async lockConsumeAmount(baValidation: BAValidation, rollback: boolean = false): Promise { - const billingAccountId = baValidation.billingAccountId ? +baValidation.billingAccountId : undefined; + async lockConsumeAmount( + baValidation: BAValidation, + rollback: boolean = false, + ): Promise { + const billingAccountId = baValidation.billingAccountId + ? +baValidation.billingAccountId + : undefined; if (!isNumber(billingAccountId)) { - this.logger.warn("Challenge doesn't have billing account id:", baValidation); + this.logger.warn( + "Challenge doesn't have billing account id:", + baValidation, + ); return; } if (includes(TGBillingAccounts, billingAccountId)) { - this.logger.info("Ignore BA validation for Topgear account:", billingAccountId); + this.logger.info( + 'Ignore BA validation for Topgear account:', + billingAccountId, + ); return; } - this.logger.log("BA validation:", baValidation); + this.logger.log('BA validation:', baValidation); const status = baValidation.status?.toLowerCase(); if ( @@ -95,41 +109,51 @@ export class BillingAccountsService { const currAmount = baValidation.totalPrizesInCents / 100; const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; - await this.lockAmount(billingAccountId!, { + await this.lockAmount(billingAccountId, { challengeId: baValidation.challengeId!, - lockAmount: (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), + lockAmount: + (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), }); } else if (status === ChallengeStatuses.Completed.toLowerCase()) { // Note an already completed challenge could still be updated with prizes const currAmount = baValidation.totalPrizesInCents / 100; - const prevAmount = baValidation.prevStatus === ChallengeStatuses.Completed ? (baValidation.prevTotalPrizesInCents ?? 0) / 100 : 0; + const prevAmount = + baValidation.prevStatus === ChallengeStatuses.Completed + ? (baValidation.prevTotalPrizesInCents ?? 0) / 100 + : 0; if (currAmount !== prevAmount) { - await this.consumeAmount(billingAccountId!, { + await this.consumeAmount(billingAccountId, { challengeId: baValidation.challengeId!, - consumeAmount: (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), + consumeAmount: + (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), markup: baValidation.markup, }); } - } else if ([ - ChallengeStatuses.Deleted, - ChallengeStatuses.Canceled, - ChallengeStatuses.CancelledFailedReview, - ChallengeStatuses.CancelledFailedScreening, - ChallengeStatuses.CancelledZeroSubmissions, - ChallengeStatuses.CancelledWinnerUnresponsive, - ChallengeStatuses.CancelledClientRequest, - ChallengeStatuses.CancelledRequirementsInfeasible, - ChallengeStatuses.CancelledZeroRegistrations, - ChallengeStatuses.CancelledPaymentFailed - ].some(t => t.toLowerCase() === status)) { - if(baValidation.prevStatus?.toLowerCase() === ChallengeStatuses.Active.toLowerCase()) { + } else if ( + [ + ChallengeStatuses.Deleted, + ChallengeStatuses.Canceled, + ChallengeStatuses.CancelledFailedReview, + ChallengeStatuses.CancelledFailedScreening, + ChallengeStatuses.CancelledZeroSubmissions, + ChallengeStatuses.CancelledWinnerUnresponsive, + ChallengeStatuses.CancelledClientRequest, + ChallengeStatuses.CancelledRequirementsInfeasible, + ChallengeStatuses.CancelledZeroRegistrations, + ChallengeStatuses.CancelledPaymentFailed, + ].some((t) => t.toLowerCase() === status) + ) { + if ( + baValidation.prevStatus?.toLowerCase() === + ChallengeStatuses.Active.toLowerCase() + ) { // Challenge canceled, unlock previous locked amount const currAmount = 0; const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; if (currAmount !== prevAmount) { - await this.lockAmount(billingAccountId!, { + await this.lockAmount(billingAccountId, { challengeId: baValidation.challengeId!, lockAmount: rollback ? prevAmount : 0, });