From 95d291f409bb63f26c1679424f9ac68186be9a14 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 19 Jun 2025 10:07:10 +0300 Subject: [PATCH 1/2] PM-1374 - require otp when withdrawing --- .../migration.sql | 12 ++ prisma/schema.prisma | 11 -- src/api/withdrawal/dto/withdraw.dto.ts | 10 + src/api/withdrawal/withdrawal.controller.ts | 11 +- src/api/withdrawal/withdrawal.service.ts | 29 ++- src/config/config.env.ts | 7 + src/shared/global/globalProviders.module.ts | 7 +- src/shared/global/otp.service.ts | 176 ++++++++++++++++++ src/shared/topcoder/bus.service.ts | 5 +- src/shared/topcoder/member.types.ts | 10 + 10 files changed, 256 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20250618100641_drop_otp_transaction/migration.sql create mode 100644 src/shared/global/otp.service.ts diff --git a/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql b/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql new file mode 100644 index 0000000..b01266a --- /dev/null +++ b/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `transaction_id` on the `otp` table. All the data in the column will be lost. + - You are about to drop the `transaction` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- AlterTable +ALTER TABLE "otp" DROP COLUMN "transaction_id"; + +-- DropTable +DROP TABLE "transaction"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 457b423..d003137 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,7 +30,6 @@ model otp { email String @db.VarChar(255) otp_hash String @db.VarChar(255) expiration_time DateTime @default(dbgenerated("(CURRENT_TIMESTAMP + '00:05:00'::interval)")) @db.Timestamp(6) - transaction_id String @db.VarChar(255) action_type reference_type created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) @@ -129,16 +128,6 @@ model reward { winnings winnings @relation(fields: [winnings_id], references: [winning_id], onDelete: NoAction, onUpdate: NoAction) } -model transaction { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - user_id String @db.VarChar(255) - reference_id String @db.Uuid - reference_type reference_type - status transaction_status @default(INITIATED) - created_at DateTime? @default(now()) @db.Timestamp(6) - updated_at DateTime? @default(now()) @db.Timestamp(6) -} - model user_payment_methods { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid user_id String @db.VarChar(80) diff --git a/src/api/withdrawal/dto/withdraw.dto.ts b/src/api/withdrawal/dto/withdraw.dto.ts index 4099c85..b35a675 100644 --- a/src/api/withdrawal/dto/withdraw.dto.ts +++ b/src/api/withdrawal/dto/withdraw.dto.ts @@ -3,6 +3,7 @@ import { ArrayNotEmpty, IsArray, IsNotEmpty, + IsNumberString, IsOptional, IsString, IsUUID, @@ -20,6 +21,15 @@ export class WithdrawRequestDtoBase { @IsUUID('4', { each: true }) @IsNotEmpty({ each: true }) winningsIds: string[]; + + @ApiProperty({ + description: 'The one-time password (OTP) code for withdrawal verification', + example: '123456', + }) + @IsNumberString() + @IsOptional() + @IsNotEmpty() + otpCode?: string; } export class WithdrawRequestDtoWithMemo extends WithdrawRequestDtoBase { diff --git a/src/api/withdrawal/withdrawal.controller.ts b/src/api/withdrawal/withdrawal.controller.ts index b6759be..a0c323a 100644 --- a/src/api/withdrawal/withdrawal.controller.ts +++ b/src/api/withdrawal/withdrawal.controller.ts @@ -21,6 +21,7 @@ import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto'; import { WithdrawalService } from './withdrawal.service'; import { WithdrawRequestDto } from './dto/withdraw.dto'; +import { response } from 'express'; @ApiTags('Withdrawal') @Controller('/withdraw') @@ -52,13 +53,17 @@ export class WithdrawalController { const result = new ResponseDto(); try { - await this.withdrawalService.withdraw( + const response = (await this.withdrawalService.withdraw( user.id, user.handle, body.winningsIds, body.memo, - ); - result.status = ResponseStatusType.SUCCESS; + body.otpCode, + )) as any; + result.status = response?.error + ? ResponseStatusType.ERROR + : ResponseStatusType.SUCCESS; + result.error = response?.error; return result; } catch (e) { throw new BadRequestException(e.message); diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index fc3fd71..ae1a912 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -4,7 +4,12 @@ import { PrismaService } from 'src/shared/global/prisma.service'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; -import { payment_releases, payment_status, Prisma } from '@prisma/client'; +import { + payment_releases, + payment_status, + Prisma, + reference_type, +} from '@prisma/client'; import { TrolleyService } from 'src/shared/global/trolley.service'; import { PaymentsService } from 'src/shared/payments'; import { @@ -12,8 +17,9 @@ import { WithdrawUpdateData, } from 'src/shared/topcoder/challenges.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; -import { MEMBER_FIELDS } from 'src/shared/topcoder'; +import { BasicMemberInfo, BASIC_MEMBER_FIELDS, MEMBER_FIELDS } from 'src/shared/topcoder'; import { Logger } from 'src/shared/global'; +import { OtpService } from 'src/shared/global/otp.service'; const TROLLEY_MINIMUM_PAYMENT_AMOUNT = ENV_CONFIG.TROLLEY_MINIMUM_PAYMENT_AMOUNT; @@ -52,6 +58,7 @@ export class WithdrawalService { private readonly trolleyService: TrolleyService, private readonly tcChallengesService: TopcoderChallengesService, private readonly tcMembersService: TopcoderMembersService, + private readonly otp: OtpService, ) {} getDbTrolleyRecipientByUserId(userId: string) { @@ -179,10 +186,12 @@ export class WithdrawalService { userHandle: string, winningsIds: string[], paymentMemo?: string, + otpCode?: string, ) { this.logger.log( `Processing withdrawal request for user ${userHandle}(${userId}), winnings: ${winningsIds.join(', ')}`, ); + const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId); if (!hasActiveTaxForm) { @@ -209,17 +218,27 @@ export class WithdrawalService { ); } - let userInfo: { email: string }; + let userInfo: BasicMemberInfo; this.logger.debug(`Getting user details for user ${userHandle}(${userId})`); try { userInfo = (await this.tcMembersService.getMemberInfoByUserHandle( userHandle, - { fields: [MEMBER_FIELDS.email] }, - )) as { email: string }; + { fields: BASIC_MEMBER_FIELDS }, + )) as unknown as BasicMemberInfo; } catch { throw new Error('Failed to fetch UserInfo for withdrawal!'); } + const otpError = await this.otp.otpCodeGuard( + userInfo, + reference_type.WITHDRAW_PAYMENT, + otpCode, + ); + + if (otpError) { + return { error: otpError }; + } + if (userInfo.email.toLowerCase().indexOf('wipro.com') > -1) { this.logger.error( `User ${userHandle}(${userId}) attempted withdrawal but is restricted due to email domain '${userInfo.email}'.`, diff --git a/src/config/config.env.ts b/src/config/config.env.ts index 5e0c7a5..fc47a24 100644 --- a/src/config/config.env.ts +++ b/src/config/config.env.ts @@ -103,4 +103,11 @@ export class ConfigEnv { @IsNumber() @IsOptional() TROLLEY_PAYPAL_FEE_MAX_AMOUNT: number = 0; + + @IsNumber() + @IsOptional() + OTP_CODE_VALIDITY_MINUTES: number = 5; + + @IsString() + SENDGRID_TEMPLATE_ID_OTP_CODE: string = 'd-2d0ab9f6c9cc4efba50080668a9c35c1'; } diff --git a/src/shared/global/globalProviders.module.ts b/src/shared/global/globalProviders.module.ts index 3da605f..43bc680 100644 --- a/src/shared/global/globalProviders.module.ts +++ b/src/shared/global/globalProviders.module.ts @@ -1,12 +1,15 @@ import { Global, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { TrolleyService } from './trolley.service'; +import { OtpService } from './otp.service'; +import { TopcoderModule } from '../topcoder/topcoder.module'; // Global module for providing global providers // Add any provider you want to be global here @Global() @Module({ - providers: [PrismaService, TrolleyService], - exports: [PrismaService, TrolleyService], + imports: [TopcoderModule], + providers: [PrismaService, TrolleyService, OtpService], + exports: [PrismaService, TrolleyService, OtpService], }) export class GlobalProvidersModule {} diff --git a/src/shared/global/otp.service.ts b/src/shared/global/otp.service.ts new file mode 100644 index 0000000..965e326 --- /dev/null +++ b/src/shared/global/otp.service.ts @@ -0,0 +1,176 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import crypto from 'crypto'; +import { reference_type } from '@prisma/client'; +import { ENV_CONFIG } from 'src/config'; +import { TopcoderEmailService } from '../topcoder/tc-email.service'; +import { BasicMemberInfo } from '../topcoder'; + +const generateRandomOtp = (length: number): string => { + const digits = '0123456789'; + let otp = ''; + for (let i = 0; i < length; i++) { + otp += digits[Math.floor(Math.random() * digits.length)]; + } + return otp; +}; + +const hashOtp = (otp: string): string => { + const hasher = crypto.createHash('sha256'); + hasher.update(otp); + return hasher.digest('hex'); +}; + +@Injectable() +export class OtpService { + private readonly logger = new Logger(`global/OtpService`); + + constructor( + private readonly prisma: PrismaService, + private readonly tcEmailService: TopcoderEmailService, + ) {} + + async generateOtpCode(userInfo: BasicMemberInfo, action_type: string) { + const actionType = reference_type[action_type as keyof reference_type]; + const email = userInfo.email; + + const existingOtp = await this.prisma.otp.findFirst({ + where: { + email, + action_type: actionType, + verified_at: null, + expiration_time: { + gt: new Date(), + }, + }, + orderBy: { + expiration_time: 'desc', + }, + }); + + if (existingOtp) { + this.logger.warn( + `An OTP has already been sent for email ${email} and action ${action_type}.`, + ); + return { + code: 'otp_exists', + message: 'An OTP has already been sent! Please check your email!', + }; + } + + // Generate a new OTP code + const otpCode = generateRandomOtp(6); // Generate a 6-digit OTP + const otpHash = hashOtp(otpCode); + + const expirationTime = new Date(); + expirationTime.setMinutes( + expirationTime.getMinutes() + ENV_CONFIG.OTP_CODE_VALIDITY_MINUTES, + ); + + // Save the new OTP code in the database + await this.prisma.otp.create({ + data: { + email, + action_type: actionType, + otp_hash: otpHash, + expiration_time: expirationTime, + created_at: new Date(), + }, + }); + + // Simulate sending an email (replace with actual email service logic) + await this.tcEmailService.sendEmail( + email, + ENV_CONFIG.SENDGRID_TEMPLATE_ID_OTP_CODE, + { + data: { + otp: otpCode, + name: [userInfo.firstName, userInfo.lastName] + .filter(Boolean) + .join(' '), + }, + }, + ); + this.logger.debug( + `Generated and sent OTP code ${otpCode.replace(/./g, '*')} for email ${email} and action ${action_type}.`, + ); + + return { + code: 'otp_required', + }; + } + + async verifyOtpCode( + otpCode: string, + userInfo: BasicMemberInfo, + action_type: string, + ) { + const record = await this.prisma.otp.findFirst({ + where: { + otp_hash: hashOtp(otpCode), + }, + orderBy: { + expiration_time: 'desc', + }, + }); + + if (!record) { + this.logger.warn(`No OTP record found for the provided code.`); + return { code: 'otp_invalid', message: `Invalid OTP code.` }; + } + + if (record.email !== userInfo.email) { + this.logger.warn(`Email mismatch for OTP verification.`); + return { + code: 'otp_email_mismatch', + message: `Email mismatch for OTP verification.`, + }; + } + + if (record.action_type !== action_type) { + this.logger.warn(`Action type mismatch for OTP verification.`); + return { + code: 'otp_action_type_mismatch', + message: `Action type mismatch for OTP verification.`, + }; + } + + if (record.expiration_time && record.expiration_time < new Date()) { + this.logger.warn(`OTP code has expired.`); + return { code: 'otp_expired', message: `OTP code has expired.` }; + } + + if (record.verified_at !== null) { + this.logger.warn(`OTP code has already been verified.`); + return { + code: 'otp_already_verified', + message: `OTP code has already been verified.`, + }; + } + + this.logger.log( + `OTP code ${otpCode} verified successfully for action ${action_type}`, + ); + + await this.prisma.otp.update({ + where: { + id: record.id, + }, + data: { + verified_at: new Date(), + }, + }); + } + + otpCodeGuard( + userInfo: BasicMemberInfo, + action_type: string, + otpCode?: string, + ): Promise<{ message?: string; code?: string } | void> { + if (!otpCode) { + return this.generateOtpCode(userInfo, action_type); + } + + return this.verifyOtpCode(otpCode, userInfo, action_type); + } +} diff --git a/src/shared/topcoder/bus.service.ts b/src/shared/topcoder/bus.service.ts index 58518f2..8397eec 100644 --- a/src/shared/topcoder/bus.service.ts +++ b/src/shared/topcoder/bus.service.ts @@ -36,7 +36,10 @@ export class TopcoderBusService { * @return {Promise} */ async createEvent(topic: string, payload: any): Promise { - this.logger.debug(`Sending message to bus topic ${topic}`, payload); + this.logger.debug(`Sending message to bus topic ${topic}`, { + ...payload, + data: {}, + }); try { const headers = await this.getHeaders(); diff --git a/src/shared/topcoder/member.types.ts b/src/shared/topcoder/member.types.ts index 9be6a89..eaa2a62 100644 --- a/src/shared/topcoder/member.types.ts +++ b/src/shared/topcoder/member.types.ts @@ -30,3 +30,13 @@ export const BASIC_MEMBER_FIELDS = [ MEMBER_FIELDS.addresses, MEMBER_FIELDS.homeCountryCode, ]; + +export interface BasicMemberInfo { + userId: string; + handle: string; + firstName: string; + lastName: string; + email: string; + addresses: any[]; + homeCountryCode: string; +} From 4098635b6c1e19a039003b4743edeee4460a219d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 19 Jun 2025 10:26:43 +0300 Subject: [PATCH 2/2] otp - use directly verify & generate otp --- src/api/withdrawal/dto/withdraw.dto.ts | 4 +-- src/api/withdrawal/withdrawal.controller.ts | 1 - src/api/withdrawal/withdrawal.service.ts | 27 +++++++++++++-------- src/shared/global/otp.service.ts | 25 ++++++------------- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/api/withdrawal/dto/withdraw.dto.ts b/src/api/withdrawal/dto/withdraw.dto.ts index b35a675..ca5e57c 100644 --- a/src/api/withdrawal/dto/withdraw.dto.ts +++ b/src/api/withdrawal/dto/withdraw.dto.ts @@ -3,10 +3,10 @@ import { ArrayNotEmpty, IsArray, IsNotEmpty, - IsNumberString, IsOptional, IsString, IsUUID, + Matches, MaxLength, } from 'class-validator'; import { ENV_CONFIG } from 'src/config'; @@ -26,7 +26,7 @@ export class WithdrawRequestDtoBase { description: 'The one-time password (OTP) code for withdrawal verification', example: '123456', }) - @IsNumberString() + @Matches(/^[0-9]{6}$/) @IsOptional() @IsNotEmpty() otpCode?: string; diff --git a/src/api/withdrawal/withdrawal.controller.ts b/src/api/withdrawal/withdrawal.controller.ts index a0c323a..42be030 100644 --- a/src/api/withdrawal/withdrawal.controller.ts +++ b/src/api/withdrawal/withdrawal.controller.ts @@ -21,7 +21,6 @@ import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto'; import { WithdrawalService } from './withdrawal.service'; import { WithdrawRequestDto } from './dto/withdraw.dto'; -import { response } from 'express'; @ApiTags('Withdrawal') @Controller('/withdraw') diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index ae1a912..10a9179 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -17,7 +17,7 @@ import { WithdrawUpdateData, } from 'src/shared/topcoder/challenges.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; -import { BasicMemberInfo, BASIC_MEMBER_FIELDS, MEMBER_FIELDS } from 'src/shared/topcoder'; +import { BasicMemberInfo, BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; import { Logger } from 'src/shared/global'; import { OtpService } from 'src/shared/global/otp.service'; @@ -58,7 +58,7 @@ export class WithdrawalService { private readonly trolleyService: TrolleyService, private readonly tcChallengesService: TopcoderChallengesService, private readonly tcMembersService: TopcoderMembersService, - private readonly otp: OtpService, + private readonly otpService: OtpService, ) {} getDbTrolleyRecipientByUserId(userId: string) { @@ -229,14 +229,22 @@ export class WithdrawalService { throw new Error('Failed to fetch UserInfo for withdrawal!'); } - const otpError = await this.otp.otpCodeGuard( - userInfo, - reference_type.WITHDRAW_PAYMENT, - otpCode, - ); - - if (otpError) { + if (!otpCode) { + const otpError = await this.otpService.generateOtpCode( + userInfo, + reference_type.WITHDRAW_PAYMENT, + ); return { error: otpError }; + } else { + const otpResponse = await this.otpService.verifyOtpCode( + otpCode, + userInfo, + reference_type.WITHDRAW_PAYMENT, + ); + + if (!otpResponse || otpResponse.code !== 'success') { + return { error: otpResponse }; + } } if (userInfo.email.toLowerCase().indexOf('wipro.com') > -1) { @@ -302,7 +310,6 @@ export class WithdrawalService { this.logger.log( ` Total amount won: $${totalAmount.toFixed(2)} USD, to be paid: $${paymentAmount.toFixed(2)} USD. - Fee applied: $${feeAmount.toFixed(2)} USD (${Number(ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT)}%, max ${ENV_CONFIG.TROLLEY_PAYPAL_FEE_MAX_AMOUNT}). Payout method type: ${trolleyRecipientPayoutDetails.payoutMethod}. `, ); diff --git a/src/shared/global/otp.service.ts b/src/shared/global/otp.service.ts index 965e326..e1256c0 100644 --- a/src/shared/global/otp.service.ts +++ b/src/shared/global/otp.service.ts @@ -30,8 +30,7 @@ export class OtpService { private readonly tcEmailService: TopcoderEmailService, ) {} - async generateOtpCode(userInfo: BasicMemberInfo, action_type: string) { - const actionType = reference_type[action_type as keyof reference_type]; + async generateOtpCode(userInfo: BasicMemberInfo, actionType: reference_type) { const email = userInfo.email; const existingOtp = await this.prisma.otp.findFirst({ @@ -50,7 +49,7 @@ export class OtpService { if (existingOtp) { this.logger.warn( - `An OTP has already been sent for email ${email} and action ${action_type}.`, + `An OTP has already been sent for email ${email} and action ${actionType}.`, ); return { code: 'otp_exists', @@ -92,7 +91,7 @@ export class OtpService { }, ); this.logger.debug( - `Generated and sent OTP code ${otpCode.replace(/./g, '*')} for email ${email} and action ${action_type}.`, + `Generated and sent OTP code ${otpCode.replace(/./g, '*')} for email ${email} and action ${actionType}.`, ); return { @@ -103,7 +102,7 @@ export class OtpService { async verifyOtpCode( otpCode: string, userInfo: BasicMemberInfo, - action_type: string, + actionType: reference_type, ) { const record = await this.prisma.otp.findFirst({ where: { @@ -127,7 +126,7 @@ export class OtpService { }; } - if (record.action_type !== action_type) { + if (record.action_type !== actionType) { this.logger.warn(`Action type mismatch for OTP verification.`); return { code: 'otp_action_type_mismatch', @@ -149,7 +148,7 @@ export class OtpService { } this.logger.log( - `OTP code ${otpCode} verified successfully for action ${action_type}`, + `OTP code ${otpCode} verified successfully for action ${actionType}`, ); await this.prisma.otp.update({ @@ -160,17 +159,7 @@ export class OtpService { verified_at: new Date(), }, }); - } - - otpCodeGuard( - userInfo: BasicMemberInfo, - action_type: string, - otpCode?: string, - ): Promise<{ message?: string; code?: string } | void> { - if (!otpCode) { - return this.generateOtpCode(userInfo, action_type); - } - return this.verifyOtpCode(otpCode, userInfo, action_type); + return { code: 'success' }; } }