Skip to content

Commit 95d291f

Browse files
committed
PM-1374 - require otp when withdrawing
1 parent b0a52dc commit 95d291f

File tree

10 files changed

+256
-22
lines changed

10 files changed

+256
-22
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the column `transaction_id` on the `otp` table. All the data in the column will be lost.
5+
- You are about to drop the `transaction` table. If the table is not empty, all the data it contains will be lost.
6+
7+
*/
8+
-- AlterTable
9+
ALTER TABLE "otp" DROP COLUMN "transaction_id";
10+
11+
-- DropTable
12+
DROP TABLE "transaction";

prisma/schema.prisma

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ model otp {
3030
email String @db.VarChar(255)
3131
otp_hash String @db.VarChar(255)
3232
expiration_time DateTime @default(dbgenerated("(CURRENT_TIMESTAMP + '00:05:00'::interval)")) @db.Timestamp(6)
33-
transaction_id String @db.VarChar(255)
3433
action_type reference_type
3534
created_at DateTime? @default(now()) @db.Timestamp(6)
3635
updated_at DateTime? @default(now()) @db.Timestamp(6)
@@ -129,16 +128,6 @@ model reward {
129128
winnings winnings @relation(fields: [winnings_id], references: [winning_id], onDelete: NoAction, onUpdate: NoAction)
130129
}
131130

132-
model transaction {
133-
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
134-
user_id String @db.VarChar(255)
135-
reference_id String @db.Uuid
136-
reference_type reference_type
137-
status transaction_status @default(INITIATED)
138-
created_at DateTime? @default(now()) @db.Timestamp(6)
139-
updated_at DateTime? @default(now()) @db.Timestamp(6)
140-
}
141-
142131
model user_payment_methods {
143132
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
144133
user_id String @db.VarChar(80)

src/api/withdrawal/dto/withdraw.dto.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ArrayNotEmpty,
44
IsArray,
55
IsNotEmpty,
6+
IsNumberString,
67
IsOptional,
78
IsString,
89
IsUUID,
@@ -20,6 +21,15 @@ export class WithdrawRequestDtoBase {
2021
@IsUUID('4', { each: true })
2122
@IsNotEmpty({ each: true })
2223
winningsIds: string[];
24+
25+
@ApiProperty({
26+
description: 'The one-time password (OTP) code for withdrawal verification',
27+
example: '123456',
28+
})
29+
@IsNumberString()
30+
@IsOptional()
31+
@IsNotEmpty()
32+
otpCode?: string;
2333
}
2434

2535
export class WithdrawRequestDtoWithMemo extends WithdrawRequestDtoBase {

src/api/withdrawal/withdrawal.controller.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto';
2121

2222
import { WithdrawalService } from './withdrawal.service';
2323
import { WithdrawRequestDto } from './dto/withdraw.dto';
24+
import { response } from 'express';
2425

2526
@ApiTags('Withdrawal')
2627
@Controller('/withdraw')
@@ -52,13 +53,17 @@ export class WithdrawalController {
5253
const result = new ResponseDto<string>();
5354

5455
try {
55-
await this.withdrawalService.withdraw(
56+
const response = (await this.withdrawalService.withdraw(
5657
user.id,
5758
user.handle,
5859
body.winningsIds,
5960
body.memo,
60-
);
61-
result.status = ResponseStatusType.SUCCESS;
61+
body.otpCode,
62+
)) as any;
63+
result.status = response?.error
64+
? ResponseStatusType.ERROR
65+
: ResponseStatusType.SUCCESS;
66+
result.error = response?.error;
6267
return result;
6368
} catch (e) {
6469
throw new BadRequestException(e.message);

src/api/withdrawal/withdrawal.service.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import { PrismaService } from 'src/shared/global/prisma.service';
44
import { TaxFormRepository } from '../repository/taxForm.repo';
55
import { PaymentMethodRepository } from '../repository/paymentMethod.repo';
66
import { IdentityVerificationRepository } from '../repository/identity-verification.repo';
7-
import { payment_releases, payment_status, Prisma } from '@prisma/client';
7+
import {
8+
payment_releases,
9+
payment_status,
10+
Prisma,
11+
reference_type,
12+
} from '@prisma/client';
813
import { TrolleyService } from 'src/shared/global/trolley.service';
914
import { PaymentsService } from 'src/shared/payments';
1015
import {
1116
TopcoderChallengesService,
1217
WithdrawUpdateData,
1318
} from 'src/shared/topcoder/challenges.service';
1419
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';
15-
import { MEMBER_FIELDS } from 'src/shared/topcoder';
20+
import { BasicMemberInfo, BASIC_MEMBER_FIELDS, MEMBER_FIELDS } from 'src/shared/topcoder';
1621
import { Logger } from 'src/shared/global';
22+
import { OtpService } from 'src/shared/global/otp.service';
1723

1824
const TROLLEY_MINIMUM_PAYMENT_AMOUNT =
1925
ENV_CONFIG.TROLLEY_MINIMUM_PAYMENT_AMOUNT;
@@ -52,6 +58,7 @@ export class WithdrawalService {
5258
private readonly trolleyService: TrolleyService,
5359
private readonly tcChallengesService: TopcoderChallengesService,
5460
private readonly tcMembersService: TopcoderMembersService,
61+
private readonly otp: OtpService,
5562
) {}
5663

5764
getDbTrolleyRecipientByUserId(userId: string) {
@@ -179,10 +186,12 @@ export class WithdrawalService {
179186
userHandle: string,
180187
winningsIds: string[],
181188
paymentMemo?: string,
189+
otpCode?: string,
182190
) {
183191
this.logger.log(
184192
`Processing withdrawal request for user ${userHandle}(${userId}), winnings: ${winningsIds.join(', ')}`,
185193
);
194+
186195
const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId);
187196

188197
if (!hasActiveTaxForm) {
@@ -209,17 +218,27 @@ export class WithdrawalService {
209218
);
210219
}
211220

212-
let userInfo: { email: string };
221+
let userInfo: BasicMemberInfo;
213222
this.logger.debug(`Getting user details for user ${userHandle}(${userId})`);
214223
try {
215224
userInfo = (await this.tcMembersService.getMemberInfoByUserHandle(
216225
userHandle,
217-
{ fields: [MEMBER_FIELDS.email] },
218-
)) as { email: string };
226+
{ fields: BASIC_MEMBER_FIELDS },
227+
)) as unknown as BasicMemberInfo;
219228
} catch {
220229
throw new Error('Failed to fetch UserInfo for withdrawal!');
221230
}
222231

232+
const otpError = await this.otp.otpCodeGuard(
233+
userInfo,
234+
reference_type.WITHDRAW_PAYMENT,
235+
otpCode,
236+
);
237+
238+
if (otpError) {
239+
return { error: otpError };
240+
}
241+
223242
if (userInfo.email.toLowerCase().indexOf('wipro.com') > -1) {
224243
this.logger.error(
225244
`User ${userHandle}(${userId}) attempted withdrawal but is restricted due to email domain '${userInfo.email}'.`,

src/config/config.env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,11 @@ export class ConfigEnv {
103103
@IsNumber()
104104
@IsOptional()
105105
TROLLEY_PAYPAL_FEE_MAX_AMOUNT: number = 0;
106+
107+
@IsNumber()
108+
@IsOptional()
109+
OTP_CODE_VALIDITY_MINUTES: number = 5;
110+
111+
@IsString()
112+
SENDGRID_TEMPLATE_ID_OTP_CODE: string = 'd-2d0ab9f6c9cc4efba50080668a9c35c1';
106113
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Global, Module } from '@nestjs/common';
22
import { PrismaService } from './prisma.service';
33
import { TrolleyService } from './trolley.service';
4+
import { OtpService } from './otp.service';
5+
import { TopcoderModule } from '../topcoder/topcoder.module';
46

57
// Global module for providing global providers
68
// Add any provider you want to be global here
79
@Global()
810
@Module({
9-
providers: [PrismaService, TrolleyService],
10-
exports: [PrismaService, TrolleyService],
11+
imports: [TopcoderModule],
12+
providers: [PrismaService, TrolleyService, OtpService],
13+
exports: [PrismaService, TrolleyService, OtpService],
1114
})
1215
export class GlobalProvidersModule {}

src/shared/global/otp.service.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { PrismaService } from './prisma.service';
3+
import crypto from 'crypto';
4+
import { reference_type } from '@prisma/client';
5+
import { ENV_CONFIG } from 'src/config';
6+
import { TopcoderEmailService } from '../topcoder/tc-email.service';
7+
import { BasicMemberInfo } from '../topcoder';
8+
9+
const generateRandomOtp = (length: number): string => {
10+
const digits = '0123456789';
11+
let otp = '';
12+
for (let i = 0; i < length; i++) {
13+
otp += digits[Math.floor(Math.random() * digits.length)];
14+
}
15+
return otp;
16+
};
17+
18+
const hashOtp = (otp: string): string => {
19+
const hasher = crypto.createHash('sha256');
20+
hasher.update(otp);
21+
return hasher.digest('hex');
22+
};
23+
24+
@Injectable()
25+
export class OtpService {
26+
private readonly logger = new Logger(`global/OtpService`);
27+
28+
constructor(
29+
private readonly prisma: PrismaService,
30+
private readonly tcEmailService: TopcoderEmailService,
31+
) {}
32+
33+
async generateOtpCode(userInfo: BasicMemberInfo, action_type: string) {
34+
const actionType = reference_type[action_type as keyof reference_type];
35+
const email = userInfo.email;
36+
37+
const existingOtp = await this.prisma.otp.findFirst({
38+
where: {
39+
email,
40+
action_type: actionType,
41+
verified_at: null,
42+
expiration_time: {
43+
gt: new Date(),
44+
},
45+
},
46+
orderBy: {
47+
expiration_time: 'desc',
48+
},
49+
});
50+
51+
if (existingOtp) {
52+
this.logger.warn(
53+
`An OTP has already been sent for email ${email} and action ${action_type}.`,
54+
);
55+
return {
56+
code: 'otp_exists',
57+
message: 'An OTP has already been sent! Please check your email!',
58+
};
59+
}
60+
61+
// Generate a new OTP code
62+
const otpCode = generateRandomOtp(6); // Generate a 6-digit OTP
63+
const otpHash = hashOtp(otpCode);
64+
65+
const expirationTime = new Date();
66+
expirationTime.setMinutes(
67+
expirationTime.getMinutes() + ENV_CONFIG.OTP_CODE_VALIDITY_MINUTES,
68+
);
69+
70+
// Save the new OTP code in the database
71+
await this.prisma.otp.create({
72+
data: {
73+
email,
74+
action_type: actionType,
75+
otp_hash: otpHash,
76+
expiration_time: expirationTime,
77+
created_at: new Date(),
78+
},
79+
});
80+
81+
// Simulate sending an email (replace with actual email service logic)
82+
await this.tcEmailService.sendEmail(
83+
email,
84+
ENV_CONFIG.SENDGRID_TEMPLATE_ID_OTP_CODE,
85+
{
86+
data: {
87+
otp: otpCode,
88+
name: [userInfo.firstName, userInfo.lastName]
89+
.filter(Boolean)
90+
.join(' '),
91+
},
92+
},
93+
);
94+
this.logger.debug(
95+
`Generated and sent OTP code ${otpCode.replace(/./g, '*')} for email ${email} and action ${action_type}.`,
96+
);
97+
98+
return {
99+
code: 'otp_required',
100+
};
101+
}
102+
103+
async verifyOtpCode(
104+
otpCode: string,
105+
userInfo: BasicMemberInfo,
106+
action_type: string,
107+
) {
108+
const record = await this.prisma.otp.findFirst({
109+
where: {
110+
otp_hash: hashOtp(otpCode),
111+
},
112+
orderBy: {
113+
expiration_time: 'desc',
114+
},
115+
});
116+
117+
if (!record) {
118+
this.logger.warn(`No OTP record found for the provided code.`);
119+
return { code: 'otp_invalid', message: `Invalid OTP code.` };
120+
}
121+
122+
if (record.email !== userInfo.email) {
123+
this.logger.warn(`Email mismatch for OTP verification.`);
124+
return {
125+
code: 'otp_email_mismatch',
126+
message: `Email mismatch for OTP verification.`,
127+
};
128+
}
129+
130+
if (record.action_type !== action_type) {
131+
this.logger.warn(`Action type mismatch for OTP verification.`);
132+
return {
133+
code: 'otp_action_type_mismatch',
134+
message: `Action type mismatch for OTP verification.`,
135+
};
136+
}
137+
138+
if (record.expiration_time && record.expiration_time < new Date()) {
139+
this.logger.warn(`OTP code has expired.`);
140+
return { code: 'otp_expired', message: `OTP code has expired.` };
141+
}
142+
143+
if (record.verified_at !== null) {
144+
this.logger.warn(`OTP code has already been verified.`);
145+
return {
146+
code: 'otp_already_verified',
147+
message: `OTP code has already been verified.`,
148+
};
149+
}
150+
151+
this.logger.log(
152+
`OTP code ${otpCode} verified successfully for action ${action_type}`,
153+
);
154+
155+
await this.prisma.otp.update({
156+
where: {
157+
id: record.id,
158+
},
159+
data: {
160+
verified_at: new Date(),
161+
},
162+
});
163+
}
164+
165+
otpCodeGuard(
166+
userInfo: BasicMemberInfo,
167+
action_type: string,
168+
otpCode?: string,
169+
): Promise<{ message?: string; code?: string } | void> {
170+
if (!otpCode) {
171+
return this.generateOtpCode(userInfo, action_type);
172+
}
173+
174+
return this.verifyOtpCode(otpCode, userInfo, action_type);
175+
}
176+
}

src/shared/topcoder/bus.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export class TopcoderBusService {
3636
* @return {Promise<void>}
3737
*/
3838
async createEvent(topic: string, payload: any): Promise<void> {
39-
this.logger.debug(`Sending message to bus topic ${topic}`, payload);
39+
this.logger.debug(`Sending message to bus topic ${topic}`, {
40+
...payload,
41+
data: {},
42+
});
4043

4144
try {
4245
const headers = await this.getHeaders();

0 commit comments

Comments
 (0)