diff --git a/.circleci/config.yml b/.circleci/config.yml index 8a8c5bf..c0d082a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,6 +75,7 @@ workflows: only: - dev - engagements + - PM-4165_allow-editing-credited-payments - 'build-prod': context: org-global filters: diff --git a/package.json b/package.json index ec46ad4..2b079fb 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,10 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql b/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql new file mode 100644 index 0000000..91a1541 --- /dev/null +++ b/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX idx_winnings_type_created_at ON winnings USING btree (type, created_at DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 988305f..c241a38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,7 @@ model winnings { @@index([category, created_at(sort: Desc)], map: "idx_winnings_category_created_at") @@index([created_at(sort: Desc)], map: "idx_winnings_created_at") + @@index([type, created_at(sort: Desc)], map: "idx_winnings_type_created_at") @@index([winner_id, created_at(sort: Desc)], map: "idx_winnings_winner_created_at") @@index([winner_id, winning_id], map: "idx_winnings_winner_id_only") } diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 4fd9332..c4f298e 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -140,6 +140,7 @@ export class AdminController { { includeCount: false, includePayoutStatus: false, + latestPaymentOnly: true, }, ); diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 4c5de83..bae85fc 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -368,7 +368,8 @@ export class AdminService { // Update payment amount if requested if ( body.paymentAmount !== undefined && - (payment.payment_status === PaymentStatus.OWED || + (payment.payment_status === PaymentStatus.CREDITED || + payment.payment_status === PaymentStatus.OWED || payment.payment_status === PaymentStatus.ON_HOLD || payment.payment_status === PaymentStatus.ON_HOLD_ADMIN) ) { @@ -646,6 +647,7 @@ export class AdminService { version: currentVersion, payment_status: { in: [ + PaymentStatus.CREDITED, PaymentStatus.OWED, PaymentStatus.ON_HOLD, PaymentStatus.ON_HOLD_ADMIN, diff --git a/src/api/challenge-payments/challenge-payments.service.spec.ts b/src/api/challenge-payments/challenge-payments.service.spec.ts new file mode 100644 index 0000000..31d749e --- /dev/null +++ b/src/api/challenge-payments/challenge-payments.service.spec.ts @@ -0,0 +1,101 @@ +jest.mock('src/shared/global', () => ({ + Logger: class { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +jest.mock('src/config', () => ({ + ENV_CONFIG: { + TOPCODER_API_V6_BASE_URL: 'https://api.topcoder-dev.com/v6', + }, +})); + +import { ChallengePaymentsService } from './challenge-payments.service'; + +describe('ChallengePaymentsService', () => { + let findManyMock: jest.Mock; + let m2mFetchMock: jest.Mock; + let service: ChallengePaymentsService; + + beforeEach(() => { + findManyMock = jest.fn().mockResolvedValue([]); + m2mFetchMock = jest.fn(); + + service = new ChallengePaymentsService( + { + winnings: { + findMany: findManyMock, + }, + } as any, + { + m2mFetch: m2mFetchMock, + } as any, + ); + }); + + it('returns all challenge payments for managers', async () => { + m2mFetchMock + .mockResolvedValueOnce([ + { + memberHandle: 'manager-user', + memberId: '123', + roleId: 'manager-role-id', + }, + ]) + .mockResolvedValueOnce([ + { + fullWriteAccess: false, + id: 'manager-role-id', + name: 'Manager', + }, + ]); + + await service.listChallengePayments({ + auth0User: { roles: ['Topcoder User'] }, + challengeId: 'challenge-id', + isMachineToken: false, + requestUserId: '123', + winnerOnly: false, + }); + + const where = findManyMock.mock.calls[0][0].where; + expect(where.winner_id).toBeUndefined(); + }); + + it('keeps winner filtering for users without challenge-wide access', async () => { + m2mFetchMock + .mockResolvedValueOnce([ + { + memberHandle: 'reviewer-user', + memberId: '456', + roleId: 'reviewer-role-id', + }, + ]) + .mockResolvedValueOnce([ + { + fullWriteAccess: false, + id: 'reviewer-role-id', + name: 'Reviewer', + }, + ]); + + await service.listChallengePayments({ + auth0User: { roles: ['Topcoder User'] }, + challengeId: 'challenge-id', + isMachineToken: false, + requestUserId: '456', + winnerOnly: false, + }); + + const where = findManyMock.mock.calls[0][0].where; + expect(where.winner_id).toBe('456'); + }); +}); diff --git a/src/api/challenge-payments/challenge-payments.service.ts b/src/api/challenge-payments/challenge-payments.service.ts index 27bc857..2577d11 100644 --- a/src/api/challenge-payments/challenge-payments.service.ts +++ b/src/api/challenge-payments/challenge-payments.service.ts @@ -101,16 +101,17 @@ export class ChallengePaymentsService { } try { - const isCopilot = await this.isCopilotForChallenge( - challengeId, - requestUserId, - ); - if (isCopilot) { + const hasChallengeWideAccess = + await this.hasChallengeWideAccessForChallenge( + challengeId, + requestUserId, + ); + if (hasChallengeWideAccess) { allowAllForChallenge = true; } } catch (error) { this.logger.warn( - `Failed to verify copilot status for user ${requestUserId} on challenge ${challengeId}`, + `Failed to verify challenge-wide payment access for user ${requestUserId} on challenge ${challengeId}`, error instanceof Error ? error.message : error, ); } @@ -150,7 +151,11 @@ export class ChallengePaymentsService { roles.push(String(role)); } }); - } else if (value) { + } else if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { roles.push(String(value)); } }); @@ -165,7 +170,7 @@ export class ChallengePaymentsService { return roles; } - private async isCopilotForChallenge( + private async hasChallengeWideAccessForChallenge( challengeId: string, userId: string, ): Promise { @@ -181,19 +186,26 @@ export class ChallengePaymentsService { return false; } - const copilotRoleIds = new Set( + const challengeWideRoleIds = new Set( resourceRoles - ?.filter((role) => - role?.name ? role.name.toLowerCase().includes('copilot') : false, + ?.filter( + (role) => + role?.fullWriteAccess === true || + (role?.name + ? role.name.toLowerCase().includes('copilot') || + role.name.toLowerCase().includes('manager') + : false), ) .map((role) => role.id), ); - if (copilotRoleIds.size === 0) { + if (challengeWideRoleIds.size === 0) { return false; } - return resources.some((resource) => copilotRoleIds.has(resource.roleId)); + return resources.some((resource) => + challengeWideRoleIds.has(resource.roleId), + ); } private async fetchWinnings( diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts new file mode 100644 index 0000000..88429c3 --- /dev/null +++ b/src/api/challenges/challenges.service.spec.ts @@ -0,0 +1,61 @@ +import { ChallengeStatuses } from '../../dto/challenge.dto'; + +jest.mock('src/config', () => ({ + ENV_CONFIG: { + TGBillingAccounts: [], + TOPCODER_API_V6_BASE_URL: 'https://api.topcoder-dev.com/v6', + }, +})); + +jest.mock('src/shared/global', () => ({ + Logger: class { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +import { ChallengesService } from './challenges.service'; + +describe('ChallengesService', () => { + it('skips creating payments for fun challenges', async () => { + const prisma = { + challenge_lock: { + create: jest.fn(), + deleteMany: jest.fn(), + }, + }; + const service = new ChallengesService( + prisma as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const challenge = { + funChallenge: true, + id: '11111111-1111-1111-1111-111111111111', + name: 'MM 163', + status: ChallengeStatuses.Completed, + }; + const createPaymentsSpy = jest + .spyOn(service as any, 'createPayments') + .mockResolvedValue(undefined); + + jest.spyOn(service, 'getChallenge').mockResolvedValue(challenge as any); + + await service.generateChallengePayments( + '11111111-1111-1111-1111-111111111111', + 'test-user', + ); + + expect(createPaymentsSpy).not.toHaveBeenCalled(); + expect(prisma.challenge_lock.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index ca43bce..987976b 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -41,7 +41,7 @@ interface PaymentPayload { amount: number; userId: string; type: WinningsCategory; - currency: string; + currency: PrizeType; description?: string; } @@ -566,6 +566,13 @@ export class ChallengesService { throw new Error("Challenge isn't in a payable status!"); } + if (challenge.funChallenge === true) { + this.logger.log( + `Skipping payment generation for fun challenge ${challenge.id} (${challenge.name}).`, + ); + return; + } + // need to read for update (LOCK the rows) this.logger.log(`Attempting to acquire lock for challenge ${challenge.id}`); try { diff --git a/src/api/challenges/models/challenge.ts b/src/api/challenges/models/challenge.ts index 39ffb62..b91a351 100644 --- a/src/api/challenges/models/challenge.ts +++ b/src/api/challenges/models/challenge.ts @@ -9,6 +9,7 @@ export interface Challenge { timelineTemplateId: string; currentPhaseNames: string[]; wiproAllowed: boolean; + funChallenge?: boolean; tags: string[]; groups: string[]; submissionStartDate: string; @@ -147,6 +148,7 @@ export interface ChallengeResource { export interface ResourceRole { id: string; name: string; + fullWriteAccess?: boolean; } export interface ChallengeReview { diff --git a/src/api/repository/winnings.repo.spec.ts b/src/api/repository/winnings.repo.spec.ts new file mode 100644 index 0000000..128cb0d --- /dev/null +++ b/src/api/repository/winnings.repo.spec.ts @@ -0,0 +1,97 @@ +import { DateFilterType } from 'src/dto/date-filter.type'; + +jest.mock('src/shared/global', () => ({ + Logger: class { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +import { WinningsRepository } from './winnings.repo'; + +describe('WinningsRepository', () => { + const ONE_DAY = 24 * 60 * 60 * 1000; + + let findManyMock: jest.Mock; + let winningsRepo: WinningsRepository; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-10-24T03:20:35.000Z')); + + findManyMock = jest.fn().mockResolvedValue([]); + + const prismaMock = { + winnings: { + findMany: findManyMock, + }, + }; + + winningsRepo = new WinningsRepository(prismaMock as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + async function getCreatedAtFilter(date: DateFilterType): Promise { + await winningsRepo.searchWinnings( + { + date, + } as any, + { + includeCount: false, + includePayoutStatus: false, + }, + ); + + const findManyArgs = findManyMock.mock.calls[0][0]; + return findManyArgs.where.created_at; + } + + it('applies an inclusive 7-day range ending today for LAST7DAYS', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.LAST7DAYS); + + expect(createdAtFilter).toEqual({ + gte: expect.any(Date), + lte: expect.any(Date), + }); + + expect(createdAtFilter.lte.getHours()).toBe(23); + expect(createdAtFilter.lte.getMinutes()).toBe(59); + expect(createdAtFilter.lte.getSeconds()).toBe(59); + expect(createdAtFilter.lte.getMilliseconds()).toBe(999); + expect(createdAtFilter.lte.getTime() - createdAtFilter.gte.getTime()).toBe( + 7 * ONE_DAY - 1, + ); + }); + + it('applies an inclusive 30-day range ending today for LAST30DAYS', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.LAST30DAYS); + + expect(createdAtFilter).toEqual({ + gte: expect.any(Date), + lte: expect.any(Date), + }); + + expect(createdAtFilter.lte.getHours()).toBe(23); + expect(createdAtFilter.lte.getMinutes()).toBe(59); + expect(createdAtFilter.lte.getSeconds()).toBe(59); + expect(createdAtFilter.lte.getMilliseconds()).toBe(999); + expect(createdAtFilter.lte.getTime() - createdAtFilter.gte.getTime()).toBe( + 30 * ONE_DAY - 1, + ); + }); + + it('does not apply created_at bounds for ALL', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.ALL); + + expect(createdAtFilter).toBeUndefined(); + }); +}); diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index d661c04..0711dcd 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -24,6 +24,7 @@ const ONE_DAY = 24 * 60 * 60 * 1000; interface SearchWinningsOptions { includeCount?: boolean; includePayoutStatus?: boolean; + latestPaymentOnly?: boolean; } @Injectable() @@ -35,6 +36,7 @@ export class WinningsRepository { private generateFilterDate(date?: DateFilterType) { let filterDate: object | undefined; const currentDay = new Date(new Date().setHours(0, 0, 0, 0)); + const currentDayEnd = new Date(currentDay.getTime() + ONE_DAY - 1); switch (date) { case DateFilterType.LAST7DAYS: @@ -42,6 +44,7 @@ export class WinningsRepository { const last7days = new Date(currentDay.getTime() - 6 * ONE_DAY); filterDate = { gte: last7days, + lte: currentDayEnd, }; break; case DateFilterType.LAST30DAYS: @@ -49,6 +52,7 @@ export class WinningsRepository { const last30days = new Date(currentDay.getTime() - 29 * ONE_DAY); filterDate = { gte: last30days, + lte: currentDayEnd, }; break; case DateFilterType.ALL: @@ -115,7 +119,7 @@ export class WinningsRepository { private getOrderByWithWinnerId( sortBy: string | undefined, - sortOrder: 'asc' | 'desc', + sortOrder: 'asc' | 'desc' | undefined, externalIds?: boolean, ) { const orderBy: object = [ @@ -172,6 +176,7 @@ export class WinningsRepository { const result = new ResponseDto(); const includeCount = options.includeCount ?? true; const includePayoutStatus = options.includePayoutStatus ?? true; + const latestPaymentOnly = options.latestPaymentOnly ?? false; try { let winnerIds: string[] | undefined; @@ -224,6 +229,7 @@ export class WinningsRepository { created_at: 'desc', }, ], + take: latestPaymentOnly ? 1 : undefined, }, origin: true, }, diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index c161b60..5805e63 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -1,7 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isNumber, includes } from 'lodash'; import { ENV_CONFIG } from 'src/config'; import { ChallengeStatuses } from 'src/dto/challenge.dto'; +import { Logger } from 'src/shared/global'; import { TopcoderM2MService } from './topcoder-m2m.service'; const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG;