Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
11 changes: 0 additions & 11 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/api/withdrawal/dto/withdraw.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ArrayNotEmpty,
IsArray,
IsNotEmpty,
IsNumberString,
IsOptional,
IsString,
IsUUID,
Expand All @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions src/api/withdrawal/withdrawal.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -52,13 +53,17 @@ export class WithdrawalController {
const result = new ResponseDto<string>();

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);
Expand Down
29 changes: 24 additions & 5 deletions src/api/withdrawal/withdrawal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ 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 {
TopcoderChallengesService,
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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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}'.`,
Expand Down
7 changes: 7 additions & 0 deletions src/config/config.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
7 changes: 5 additions & 2 deletions src/shared/global/globalProviders.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
176 changes: 176 additions & 0 deletions src/shared/global/otp.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 4 additions & 1 deletion src/shared/topcoder/bus.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export class TopcoderBusService {
* @return {Promise<void>}
*/
async createEvent(topic: string, payload: any): Promise<void> {
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();
Expand Down
Loading