diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 8b9ca26..c59f4b3 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -97,7 +97,7 @@ export class AdminController { limit: 999, }); - const handles = await this.tcMembersService.getHandlesByUserIds( + const handlesMap = await this.tcMembersService.getMembersInfoByUserId( result.data.winnings.map((d) => d.winnerId), ); @@ -108,7 +108,7 @@ export class AdminController { return { id: item.id, winnerId: item.winnerId, - handle: handles[`${item.winnerId}`] ?? item.winnerId, + handle: handlesMap[`${item.winnerId}`]?.handle ?? item.winnerId, origin: item.origin, category: item.category, title: item.title, diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 2da3f93..bf275f2 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -17,6 +17,7 @@ import { WinningsModule } from './winnings/winnings.module'; import { UserModule } from './user/user.module'; import { WalletModule } from './wallet/wallet.module'; import { WithdrawalModule } from './withdrawal/withdrawal.module'; +import { ReportsModule } from './reports/reports.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { WithdrawalModule } from './withdrawal/withdrawal.module'; UserModule, WalletModule, WithdrawalModule, + ReportsModule, ], controllers: [HealthCheckController], providers: [ diff --git a/src/api/payment-providers/trolley.service.ts b/src/api/payment-providers/trolley.service.ts index 1992c23..b2c5488 100644 --- a/src/api/payment-providers/trolley.service.ts +++ b/src/api/payment-providers/trolley.service.ts @@ -56,10 +56,15 @@ export class TrolleyService { return foundRecipient[0]; } - const userInfo = await this.tcMembersService.getMemberInfoByUserHandle( + const userInfo = await this.tcMembersService.getMemberInfoByHandle( user.handle, - { fields: BASIC_MEMBER_FIELDS }, + BASIC_MEMBER_FIELDS, ); + + if (!userInfo) { + throw new Error(`Member info for handle '${user.handle}' not found!`); + } + const address = (userInfo.addresses?.[0] ?? {}) as unknown as { [key: string]: string; }; diff --git a/src/api/reports/reports.controller.ts b/src/api/reports/reports.controller.ts new file mode 100644 index 0000000..eaa361c --- /dev/null +++ b/src/api/reports/reports.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiQuery, +} from '@nestjs/swagger'; +import { ReportsService } from './reports.service'; +import { ResponseDto } from 'src/dto/api-response.dto'; +import { + PaymentsReportQueryDto, + PaymentsReportResponse, +} from 'src/dto/reports.dto'; + +@ApiTags('Reports') +@Controller('/reports') +@ApiBearerAuth() +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('/payments') + @ApiOperation({ + summary: 'Export search winnings result in csv file format', + description: 'Roles: Payment Admin, Payment Editor, Payment Viewer', + }) + @ApiQuery({ + type: PaymentsReportQueryDto, + }) + @ApiResponse({ + status: 200, + description: 'Export winnings successfully.', + type: ResponseDto, + }) + async getPaymentsReport(@Query() query: PaymentsReportQueryDto) { + const report = await this.reportsService.getPaymentsReport(query); + return report; + } +} diff --git a/src/api/reports/reports.module.ts b/src/api/reports/reports.module.ts new file mode 100644 index 0000000..90c7419 --- /dev/null +++ b/src/api/reports/reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; +import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; + +@Module({ + imports: [TopcoderModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/src/api/reports/reports.service.ts b/src/api/reports/reports.service.ts new file mode 100644 index 0000000..35987a5 --- /dev/null +++ b/src/api/reports/reports.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, winnings_category } from '@prisma/client'; +import { isEmpty, uniq } from 'lodash'; +import { PaymentsReportQueryDto } from 'src/dto/reports.dto'; +import { Logger } from 'src/shared/global'; +import { PrismaService } from 'src/shared/global/prisma.service'; +import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; +import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service'; +import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; + +@Injectable() +export class ReportsService { + private readonly logger = new Logger(ReportsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly membersService: TopcoderMembersService, + private readonly challengeService: TopcoderChallengesService, + ) {} + + private async buildPaymentReportQueryFilters( + filters: PaymentsReportQueryDto, + ) { + const queryFilters: Prisma.paymentWhereInput = {}; + + if (filters.billingAccountIds) { + Object.assign(queryFilters, { + billing_account: { in: [...filters.billingAccountIds] }, + }); + } + + if (filters.handles) { + const userIdsMap = await this.membersService.getMembersInfoByHandle( + filters.handles, + ); + + if (!isEmpty(userIdsMap)) { + Object.assign(queryFilters, { + winnings: { + winner_id: { + in: Object.values(userIdsMap).map((u) => `${u.userId ?? ''}`), + }, + }, + }); + } + } + + if (filters.challengeName) { + const challenges = await this.challengeService.searchByName( + filters.challengeName, + ); + + Object.assign(queryFilters, { + winnings: { + ...queryFilters.winnings, + external_id: { in: challenges.map((c: { id: string }) => c.id) }, + }, + }); + } + + if (filters.startDate || filters.endDate) { + Object.assign(queryFilters, { + created_at: { + ...(filters.startDate && { gte: filters.startDate }), + ...(filters.endDate && { lte: filters.endDate }), + }, + }); + } + + if (filters.minPaymentAmount || filters.maxPaymentAmount) { + Object.assign(queryFilters, { + total_amount: { + ...(filters.minPaymentAmount && { gte: filters.minPaymentAmount }), + ...(filters.maxPaymentAmount && { lte: filters.maxPaymentAmount }), + }, + }); + } + + return queryFilters; + } + + async getPaymentsReport(filters: PaymentsReportQueryDto) { + this.logger.debug('Starting getPaymentsReport with filters:', filters); + + const queryFilters = await this.buildPaymentReportQueryFilters(filters); + + const payments = await this.prisma.payment.findMany({ + where: { + ...queryFilters, + }, + select: { + payment_id: true, + created_at: true, + billing_account: true, + payment_status: true, + challenge_fee: true, + total_amount: true, + winnings: { + select: { + external_id: true, + winner_id: true, + category: true, + }, + }, + }, + }); + + this.logger.debug(`Fetched ${payments.length} payments from the database`); + + const userIds = uniq(payments.map((p) => p.winnings.winner_id as string)); + const challengeIds = uniq( + payments.map((p) => p.winnings.external_id as string).filter(Boolean), + ); + + this.logger.debug(`Extracted ${userIds.length} unique user IDs`); + this.logger.debug(`Extracted ${challengeIds.length} unique challenge IDs`); + + const [membersMap, challengeNamesMap] = await Promise.all([ + this.membersService.getMembersInfoByUserId(userIds, BASIC_MEMBER_FIELDS), + this.challengeService.getChallengesNameByChallengeIds(challengeIds), + ]); + + this.logger.debug('Fetched member information and challenge names'); + + const result = payments.map((payment) => ({ + billingAccountId: payment.billing_account, + challengeName: + challengeNamesMap[payment.winnings.external_id as string] ?? '', + challengeId: payment.winnings.external_id as string, + paymentDate: payment.created_at, + paymentId: payment.payment_id, + paymentStatus: payment.payment_status, + winnerId: payment.winnings.winner_id, + winnerHandle: membersMap[payment.winnings.winner_id]?.handle ?? '', + winnerFirstName: membersMap[payment.winnings.winner_id]?.firstName ?? '', + winnerLastName: membersMap[payment.winnings.winner_id]?.lastName ?? '', + isTask: payment.winnings.category === winnings_category.TASK_PAYMENT, + challengeFee: payment.challenge_fee, + paymentAmount: payment.total_amount, + })); + + this.logger.debug('Mapped payments to the final report format'); + + return result; + } +} diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 5aab1b2..b252095 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -42,9 +42,10 @@ export class WinningsService { private async sendSetupEmailNotification(userId: string, amount: number) { this.logger.debug(`Fetching member info for user handle: ${userId}`); - const member = await this.tcMembersService.getMemberInfoByUserId(userId, { - fields: BASIC_MEMBER_FIELDS, - }); + const member = await this.tcMembersService.getMemberInfoByUserId( + userId, + BASIC_MEMBER_FIELDS, + ); if (!member) { this.logger.warn( diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index c3a12d9..673f436 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -201,9 +201,9 @@ export class WithdrawalService { let userInfo: { email: string }; this.logger.debug(`Getting user details for user ${userHandle}(${userId})`); try { - userInfo = (await this.tcMembersService.getMemberInfoByUserHandle( + userInfo = (await this.tcMembersService.getMemberInfoByHandle( userHandle, - { fields: [MEMBER_FIELDS.email] }, + [MEMBER_FIELDS.email], )) as { email: string }; } catch { throw new Error('Failed to fetch UserInfo for withdrawal!'); diff --git a/src/dto/reports.dto.ts b/src/dto/reports.dto.ts new file mode 100644 index 0000000..71c37d2 --- /dev/null +++ b/src/dto/reports.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsOptional, + IsString, + IsNotEmpty, + IsNumber, + IsDateString, +} from 'class-validator'; + +const transformArray = ({ value }: { value: string }) => + Array.isArray(value) ? value : [value]; + +export class PaymentsReportQueryDto { + @ApiProperty({ + description: + 'List of billing account IDs associated with the payments to retrieve', + example: ['80001012'], + }) + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(transformArray) + billingAccountIds?: string[]; + + @ApiProperty({ + description: 'Challenge name to search for', + example: ['Task Payment for member'], + }) + @IsOptional() + @IsString() + @IsNotEmpty() + challengeName?: string; + + @ApiProperty({ + description: 'List of challenge IDs', + example: ['e74c3e37-73c9-474e-a838-a38dd4738906'], + }) + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(transformArray) + challengeIds?: string[]; + + @ApiProperty({ + description: 'Start date for the report query in ISO format', + example: '2023-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + startDate?: Date; + + @ApiProperty({ + description: 'End date for the report query in ISO format', + example: '2023-01-31T23:59:59.000Z', + }) + @IsOptional() + @IsDateString() + endDate?: Date; + + @ApiProperty({ + description: 'List of user handles', + example: ['user_01', 'user_02'], + }) + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(transformArray) + handles?: string[]; + + @ApiProperty({ + description: 'Minimum payment amount for filtering the report', + example: 100, + }) + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseFloat(value)) + minPaymentAmount?: number; + + @ApiProperty({ + description: 'Maximum payment amount for filtering the report', + example: 1000, + }) + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseFloat(value)) + maxPaymentAmount?: number; +} + +export class PaymentsReportResponse { + billingAccountId: string; + challengeName: string; + challengeId: string; + paymentDate: string; + paymentId: string; + paymentStatus: string; + winnerId: string; + winnerHandle: string; + winnerFirstName: string; + winnerLastName: string; + isTask: boolean; + challengeFee: number; + paymentAmount: number; +} diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index fb087df..58ddf24 100644 --- a/src/shared/topcoder/challenges.service.ts +++ b/src/shared/topcoder/challenges.service.ts @@ -3,6 +3,7 @@ import { TopcoderM2MService } from './topcoder-m2m.service'; import { ENV_CONFIG } from 'src/config'; import { payment_status } from '@prisma/client'; import { Logger } from 'src/shared/global'; +import axios from 'axios'; const { TOPCODER_API_BASE_URL } = ENV_CONFIG; @@ -47,15 +48,7 @@ export class TopcoderChallengesService { ) { const requestData = mapStatus(payoutData); - let m2mToken: string | undefined; - try { - m2mToken = await this.m2MService.getToken(); - } catch (e) { - this.logger.error( - 'Failed to fetch m2m token for fetching member details!', - e.message ?? e, - ); - } + const m2mToken = await this.m2MService.getToken(); const requestUrl = `${TOPCODER_API_BASE_URL}/challenges/${challengeId}/legacy-payment`; this.logger.debug( @@ -91,4 +84,96 @@ export class TopcoderChallengesService { throw e; } } + + async searchByName(challengeName: string) { + const m2mToken = await this.m2MService.getToken(); + const requestUrl = `${TOPCODER_API_BASE_URL}/challenges?name=${encodeURIComponent(challengeName)}`; + + this.logger.debug( + `Fetching challenges ids by challenge name ${JSON.stringify(challengeName)}.`, + ); + + try { + const response = await fetch(requestUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${m2mToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch challenges by name. Status: ${response.status}, Data: ${await response.text()}`, + ); + } + + const jsonResponse: Record[] = await response.json(); + + this.logger.debug( + `Successfully fetched ${jsonResponse.length} challenges from challenges api.`, + ); + + return jsonResponse; + } catch (e) { + const errorMessage = e.response?.data?.message ?? e.message; + + this.logger.error( + `Failed to fetch challenges details for challenge name ${challengeName}! Error: ${errorMessage}`, + ); + throw new Error(errorMessage ?? 'Axios Error'); + } + } + + async getChallengesNameByChallengeIds(challengeIds: string[]) { + if (!challengeIds.length) { + return {}; + } + + const m2mToken = await this.m2MService.getToken(); + + const requestUrl = `${TOPCODER_API_BASE_URL}/challenges`; + + this.logger.debug( + `Fetching challenges names by challenges ids ${JSON.stringify(challengeIds)}.`, + ); + + try { + /** Using axios because we can't send "BODY" for a GET request with fetch */ + const response = await axios.get(requestUrl, { + method: 'GET', + data: { ids: challengeIds }, + headers: { + Authorization: `Bearer ${m2mToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status > 299) { + throw new Error( + `Failed to fetch challenges details. Status: ${response.status}, Data: ${JSON.stringify(response.data)}`, + ); + } + + const challengeNamesMap = Object.fromEntries( + response.data.map((challenge) => [ + challenge.id as string, + challenge.name as string, + ]), + ); + + this.logger.debug( + `Successfully fetched challenges names from challenges api: ${JSON.stringify(challengeNamesMap, null, 2)}`, + ); + + return challengeNamesMap; + } catch (e) { + const errorMessage = e.response?.data?.message ?? e.message; + + this.logger.error( + `Failed to fetch challenges details for challenges ${challengeIds.join(',')}! Error: ${errorMessage}`, + ); + throw new Error(errorMessage ?? 'Axios Error'); + } + } } diff --git a/src/shared/topcoder/members.service.ts b/src/shared/topcoder/members.service.ts index c0bc57f..2658d21 100644 --- a/src/shared/topcoder/members.service.ts +++ b/src/shared/topcoder/members.service.ts @@ -21,52 +21,18 @@ export class TopcoderMembersService { * * @throws Will log an error to the console if the API request fails. */ - async getHandlesByUserIds(userIds: string[]) { + async getMembersInfo>( + filter: 'userIds' | 'handles', + filterValue: string[], + options = {} as { fields?: MEMBER_FIELDS[] }, + ): Promise { // Remove duplicate user IDs to avoid redundant API calls - const uniqUserIds = [...new Set(userIds.filter(Boolean)).values()]; - - // Split the unique user IDs into chunks of 100 to comply with API request limits - const requests = chunk(uniqUserIds, 30).map((chunk) => { - const requestUrl = `${TOPCODER_API_BASE_URL}/members?${chunk.map((id) => `userIds[]=${id}`).join('&')}&fields=handle,userId`; - return fetch(requestUrl).then( - async (response) => - (await response.json()) as { handle: string; userId: string }, - ); - }); - - try { - // Execute all API requests in parallel and flatten the resulting data - const data = await Promise.all(requests).then((d) => d.flat()); - // Transform the API response into a mapping of user IDs to handles - return Object.fromEntries( - data.map(({ handle, userId }) => [userId, handle] as string[]), - ) as { [userId: string]: string }; - } catch (e) { - this.logger.error( - 'Failed to fetch tc members handles!', - e?.message ?? e, - e, - ); - return {}; - } - } + const { fields = ['handle', 'userId'] } = options; + const uniqFilterValues = [...new Set(filterValue.filter(Boolean)).values()]; - /** - * Retrieves member information from the Topcoder API based on the user's handle. - * - * @param handle - The handle of the user whose information is to be retrieved. - * @param options - Optional parameters for the request. - * @param options.fields - An array of specific member fields to include in the response. - * - * @returns A promise that resolves to the member information object or an empty object if the request fails. - * - * @throws Will log an error message to the console if the API request fails. - */ - async getMemberInfoByUserHandle( - handle: string, - options = {} as { fields: MEMBER_FIELDS[] }, - ) { - const { fields } = options; + this.logger.debug( + `Fetching members info with filters ${filter}=${JSON.stringify(uniqFilterValues)}, and fields ${fields.join(',')}`, + ); let m2mToken: string | undefined; try { @@ -77,61 +43,83 @@ export class TopcoderMembersService { e.message ?? e, ); } - const requestUrl = `${TOPCODER_API_BASE_URL}/members/${handle}${fields ? `?fields=${fields.join(',')}` : ''}`; - try { - const response = await fetch(requestUrl, { + // Split the unique user IDs into chunks of 30 to comply with API request limits + const requests = chunk(uniqFilterValues, 30).map((chunk) => { + const requestUrl = `${TOPCODER_API_BASE_URL}/members?${chunk.map((id) => `${filter}[]=${id}`).join('&')}&fields=${fields.join(',')}`; + return fetch(requestUrl, { headers: { Authorization: `Bearer ${m2mToken}` }, + }).then(async (response) => { + const jsonResponse = await response.json(); + return jsonResponse as T[]; }); + }); - const jsonResponse: { [key: string]: string } = await response.json(); - - if (response.status > 299) { - throw new Error(jsonResponse.message ?? JSON.stringify(jsonResponse)); - } + try { + // Execute all API requests in parallel and flatten the resulting data + const data = await Promise.all(requests).then((d) => d.flat()); - return jsonResponse; + this.logger.debug( + `Successfully fetched members info for filters ${filter}=${JSON.stringify(uniqFilterValues)}. ${data?.length ?? 0} users found!`, + ); + return data; } catch (e) { this.logger.error( - `Failed to fetch tc member info for user '${handle}'! Error: ${e?.message ?? e}`, + 'Failed to fetch tc members handles!', + e?.message ?? e, e, ); - throw e; + return []; } } - /** - * Retrieves member information from the Topcoder API based on the user's ID. - * - * @param userId - The ID of the user whose information is to be retrieved. - * @param options - Optional parameters for the request. - * @param options.fields - An array of specific member fields to include in the response. - * - * @returns A promise that resolves to the member information object or an empty object if the request fails. - * - * @throws Will log an error message to the console if the API request fails. - */ - async getMemberInfoByUserId( + async getMemberInfoByUserId>( userId: string, - options = {} as { fields: MEMBER_FIELDS[] }, - ) { - try { - // Fetch the handle for the given userId - const handlesMap = await this.getHandlesByUserIds([userId]); - const handle = handlesMap[userId]; + fields?: MEMBER_FIELDS[], + ): Promise { + const response = await this.getMembersInfo('userIds', [userId], { fields }); - if (!handle) { - throw new Error(`Handle not found for userId: ${userId}`); - } + return response?.[0] as T; + } - // Fetch member info using the handle - return await this.getMemberInfoByUserHandle(handle, options); - } catch (e) { - this.logger.error( - `Failed to fetch tc member info for userId '${userId}'! Error: ${e?.message ?? e}`, - e, - ); - throw e; - } + async getMembersInfoByUserId>( + userIds: string[], + fields?: MEMBER_FIELDS[], + ): Promise<{ [handle: string]: T }> { + const response = await this.getMembersInfo('userIds', userIds, { fields }); + + // Transform the API response into a mapping of user IDs to the requested fields + const result = Object.fromEntries( + response.map((user) => [user.userId, user]), + ) as { + [userId: string]: T; + }; + + return result; + } + + async getMemberInfoByHandle>( + handle: string, + fields?: MEMBER_FIELDS[], + ): Promise { + const response = await this.getMembersInfo('handles', [handle], { fields }); + + return response?.[0] as T; + } + + async getMembersInfoByHandle>( + handles: string[], + fields?: MEMBER_FIELDS[], + ): Promise<{ [handle: string]: T }> { + const response = await this.getMembersInfo('handles', handles, { fields }); + + // Transform the API response into a mapping of user IDs to the requested fields + const result = Object.fromEntries( + response.map((user) => [user.handle, user]), + ) as { + [handle: string]: T; + }; + + return result; } } diff --git a/src/shared/topcoder/topcoder-m2m.service.ts b/src/shared/topcoder/topcoder-m2m.service.ts index 06cc33a..9e36b20 100644 --- a/src/shared/topcoder/topcoder-m2m.service.ts +++ b/src/shared/topcoder/topcoder-m2m.service.ts @@ -33,6 +33,7 @@ export class TopcoderM2MService { client_secret: ENV_CONFIG.AUTH0_M2M_SECRET, audience: ENV_CONFIG.AUTH0_M2M_AUDIENCE, grant_type: ENV_CONFIG.AUTH0_M2M_GRANT_TYPE, + // fresh_token: true, }), }); @@ -42,7 +43,7 @@ export class TopcoderM2MService { return m2mToken; } catch (error) { this.logger.error('Failed fetching TC M2M Token!', error); - return undefined; + throw error; } } }