Skip to content

Commit 83bcf6d

Browse files
authored
Merge pull request #138 from topcoder-platform/PM-3575_handle-ba-admin-user
PM-3575 - handle BA Admin user
2 parents 51e60ab + 4448678 commit 83bcf6d

11 files changed

Lines changed: 1967 additions & 1189 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@nestjs/platform-express": "^11.1.7",
2727
"@nestjs/swagger": "^11.2.1",
2828
"@prisma/client": "^6.18.0",
29+
"@topcoder/billing-accounts-api-v6": "github:topcoder-platform/billing-accounts-api-v6#develop",
2930
"class-transformer": "^0.5.1",
3031
"class-validator": "^0.14.2",
3132
"cors": "^2.8.5",

pnpm-lock.yaml

Lines changed: 1759 additions & 1174 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/admin/admin.controller.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,21 @@ export class AdminController {
4444
private readonly tcMembersService: TopcoderMembersService,
4545
) {}
4646

47+
private isBaAdmin(user?: { roles?: string[] }) {
48+
return (user?.roles || []).some(
49+
(r) =>
50+
r &&
51+
r.trim().toLowerCase() === Role.PaymentBaAdmin.trim().toLowerCase(),
52+
);
53+
}
54+
4755
@Post('/winnings/search')
48-
@Roles(Role.PaymentAdmin, Role.PaymentEditor, Role.PaymentViewer)
56+
@Roles(
57+
Role.PaymentAdmin,
58+
Role.PaymentBaAdmin,
59+
Role.PaymentEditor,
60+
Role.PaymentViewer,
61+
)
4962
@ApiOperation({
5063
summary: 'Search winnings with parameters',
5164
description: 'Roles: Payment Admin, Payment Editor, Payment Viewer',
@@ -62,8 +75,16 @@ export class AdminController {
6275
@HttpCode(HttpStatus.OK)
6376
async searchWinnings(
6477
@Body() body: WinningRequestDto,
78+
@User() user: any,
6579
): Promise<ResponseDto<SearchWinningResult>> {
66-
const result = await this.winningsRepo.searchWinnings(body);
80+
const result = await this.winningsRepo.searchWinnings(
81+
await this.adminService.applyBaAdminUserFilters(
82+
user.id,
83+
this.isBaAdmin(user),
84+
body,
85+
),
86+
);
87+
6788
if (result.error) {
6889
result.status = ResponseStatusType.ERROR;
6990
}
@@ -74,7 +95,12 @@ export class AdminController {
7495
}
7596

7697
@Post('/winnings/export')
77-
@Roles(Role.PaymentAdmin, Role.PaymentEditor, Role.PaymentViewer)
98+
@Roles(
99+
Role.PaymentAdmin,
100+
Role.PaymentBaAdmin,
101+
Role.PaymentEditor,
102+
Role.PaymentViewer,
103+
)
78104
@ApiOperation({
79105
summary: 'Export search winnings result in csv file format',
80106
description: 'Roles: Payment Admin, Payment Editor, Payment Viewer',
@@ -91,11 +117,17 @@ export class AdminController {
91117
@HttpCode(HttpStatus.OK)
92118
@Header('Content-Type', 'text/csv')
93119
@Header('Content-Disposition', 'attachment; filename="winnings.csv"')
94-
async exportWinnings(@Body() body: WinningRequestDto) {
95-
const result = await this.winningsRepo.searchWinnings({
96-
...body,
97-
limit: 999,
98-
});
120+
async exportWinnings(@Body() body: WinningRequestDto, @User() user: any) {
121+
const result = await this.winningsRepo.searchWinnings(
122+
await this.adminService.applyBaAdminUserFilters(
123+
user.id,
124+
this.isBaAdmin(user),
125+
{
126+
...body,
127+
limit: 999,
128+
},
129+
),
130+
);
99131

100132
const handles = await this.tcMembersService.getHandlesByUserIds(
101133
result.data.winnings.map((d) => d.winnerId),
@@ -149,7 +181,7 @@ export class AdminController {
149181
}
150182

151183
@Patch('/winnings')
152-
@Roles(Role.PaymentAdmin, Role.PaymentEditor)
184+
@Roles(Role.PaymentAdmin, Role.PaymentBaAdmin, Role.PaymentEditor)
153185
@ApiOperation({
154186
summary: 'Update winnings with given parameter',
155187
description:
@@ -175,7 +207,11 @@ export class AdminController {
175207
);
176208
}
177209

178-
const result = await this.adminService.updateWinnings(body, user.id);
210+
const result = await this.adminService.updateWinnings(
211+
body,
212+
user.id,
213+
this.isBaAdmin(user),
214+
);
179215

180216
result.status = ResponseStatusType.SUCCESS;
181217
if (result.error) {
@@ -186,7 +222,12 @@ export class AdminController {
186222
}
187223

188224
@Get('/winnings/:winningID/audit')
189-
@Roles(Role.PaymentAdmin, Role.PaymentEditor, Role.PaymentViewer)
225+
@Roles(
226+
Role.PaymentAdmin,
227+
Role.PaymentBaAdmin,
228+
Role.PaymentEditor,
229+
Role.PaymentViewer,
230+
)
190231
@ApiOperation({
191232
summary: 'List winning audit logs with given winning id',
192233
description: 'Roles: Payment Admin, Payment Editor, Payment Viewer',
@@ -203,7 +244,12 @@ export class AdminController {
203244
})
204245
async getWinningAudit(
205246
@Param('winningID') winningId: string,
247+
@User() user: any,
206248
): Promise<ResponseDto<WinningAuditDto[]>> {
249+
if (this.isBaAdmin(user)) {
250+
await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id);
251+
}
252+
207253
const result = await this.adminService.getWinningAudit(winningId);
208254

209255
result.status = ResponseStatusType.SUCCESS;
@@ -215,7 +261,12 @@ export class AdminController {
215261
}
216262

217263
@Get('/winnings/:winningID/audit-payout')
218-
@Roles(Role.PaymentAdmin, Role.PaymentEditor, Role.PaymentViewer)
264+
@Roles(
265+
Role.PaymentAdmin,
266+
Role.PaymentBaAdmin,
267+
Role.PaymentEditor,
268+
Role.PaymentViewer,
269+
)
219270
@ApiOperation({
220271
summary: 'Fetch winnings payout audit logs with given winning id.',
221272
description:
@@ -233,7 +284,12 @@ export class AdminController {
233284
})
234285
async getWinningAuditPayout(
235286
@Param('winningID') winningId: string,
287+
@User() user: any,
236288
): Promise<ResponseDto<AuditPayoutDto[]>> {
289+
if (this.isBaAdmin(user)) {
290+
await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id);
291+
}
292+
237293
const result = await this.adminService.getWinningAuditPayout(winningId);
238294

239295
result.status = ResponseStatusType.SUCCESS;

src/api/admin/admin.service.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { PaymentStatus } from 'src/dto/payment.dto';
1414
import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto';
1515
import { WinningUpdateRequestDto } from './dto/winnings.dto';
1616
import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service';
17-
import { Logger } from 'src/shared/global';
17+
import { Logger, getBaClient } from 'src/shared/global';
18+
import { WinningRequestDto } from 'src/dto/winning.dto';
1819

1920
function formatDate(date = new Date()) {
2021
const pad = (n, z = 2) => String(n).padStart(z, '0');
@@ -43,6 +44,32 @@ export class AdminService {
4344
private readonly tcChallengesService: TopcoderChallengesService,
4445
) {}
4546

47+
async getBillingAccountsForUser(userId: string): Promise<string[]> {
48+
const baPrisma = getBaClient();
49+
const baRows = await baPrisma.billingAccountAccess.findMany({
50+
where: {
51+
userId,
52+
}
53+
});
54+
55+
return baRows.map(r => `${r.billingAccountId}`);
56+
}
57+
58+
async applyBaAdminUserFilters(
59+
userId: string,
60+
isBaAdmin?: boolean,
61+
filters: WinningRequestDto = {},
62+
) {
63+
if (!isBaAdmin) {
64+
return filters;
65+
}
66+
67+
return {
68+
...filters,
69+
billingAccounts: await this.getBillingAccountsForUser(userId),
70+
};
71+
}
72+
4673
private getWinningById(winningId: string) {
4774
return this.prisma.winnings.findFirst({ where: { winning_id: winningId } });
4875
}
@@ -65,6 +92,47 @@ export class AdminService {
6592
});
6693
}
6794

95+
/**
96+
* Verify that a BA admin user has access to the billing account(s)
97+
* associated with the given winningsId. Throws BadRequestException when
98+
* access is not allowed.
99+
*/
100+
async verifyBaAdminAccessToWinning(
101+
winningsId: string,
102+
userId: string,
103+
): Promise<void> {
104+
const payments = await this.prisma.payment.findMany({
105+
where: {
106+
winnings_id: {
107+
equals: winningsId,
108+
},
109+
},
110+
select: {
111+
billing_account: true,
112+
}
113+
});;
114+
115+
if (!payments || payments.length === 0) {
116+
// nothing to check
117+
return;
118+
}
119+
120+
const allowedBAs = await this.getBillingAccountsForUser(userId);
121+
const paymentBAs = payments
122+
.map((p) => p.billing_account)
123+
.filter((b) => b !== null && b !== undefined);
124+
125+
const unauthorized = paymentBAs.some((ba) => !allowedBAs.includes(`${ba}`));
126+
if (unauthorized) {
127+
this.logger.warn(
128+
`BA admin ${userId} attempted to access winnings ${winningsId} for unauthorized billing account(s)`,
129+
);
130+
throw new BadRequestException(
131+
'BA admin user does not have access to the billing account for this winnings',
132+
);
133+
}
134+
}
135+
68136
/**
69137
* Update winnings with parameters
70138
* @param body the request body
@@ -74,6 +142,7 @@ export class AdminService {
74142
async updateWinnings(
75143
body: WinningUpdateRequestDto,
76144
userId: string,
145+
isBaAdmin?: boolean,
77146
): Promise<ResponseDto<string>> {
78147
const result = new ResponseDto<string>();
79148

@@ -84,6 +153,10 @@ export class AdminService {
84153
);
85154
this.logger.log(`updateWinnings payload: ${JSON.stringify(body)}`);
86155

156+
if (isBaAdmin) {
157+
await this.verifyBaAdminAccessToWinning(body.winningsId, userId);
158+
}
159+
87160
try {
88161
const payments = await this.getPaymentsByWinningsId(
89162
winningsId,
@@ -100,6 +173,8 @@ export class AdminService {
100173
throw new NotFoundException('failed to get current payments');
101174
}
102175

176+
177+
103178
let releaseDate;
104179
if (body.paymentStatus) {
105180
releaseDate = await this.getPaymentReleaseDateByWinningsId(winningsId);
@@ -385,7 +460,9 @@ export class AdminService {
385460
// Run all transaction tasks in a single prisma transaction
386461
await this.prisma.$transaction(async (tx) => {
387462
for (let i = 0; i < transactions.length; i++) {
388-
this.logger.log(`Executing transaction ${i + 1}/${transactions.length}`);
463+
this.logger.log(
464+
`Executing transaction ${i + 1}/${transactions.length}`,
465+
);
389466
await transactions[i](tx);
390467
}
391468
});

src/api/repository/winnings.repo.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class WinningsRepository {
6262
winnerIds?: string[],
6363
externalIds?: string[],
6464
date?: DateFilterType,
65-
): Prisma.winningsFindManyArgs['where'] {
65+
): Prisma.winningsWhereInput {
6666
const typeFilter = type
6767
? {
6868
equals: type as winnings_type,
@@ -185,6 +185,17 @@ export class WinningsRepository {
185185
searchProps.date,
186186
);
187187

188+
if (searchProps.billingAccounts) {
189+
// override payment filter to include billing account constraint
190+
// while preserving status/installment constraints
191+
(queryWhere as any).payment.some = {
192+
...queryWhere.payment!.some,
193+
billing_account: {
194+
in: searchProps.billingAccounts,
195+
},
196+
};
197+
}
198+
188199
const orderBy = this.getOrderByWithWinnerId(
189200
searchProps.sortBy,
190201
searchProps.sortOrder,

src/config/config.env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,7 @@ export class ConfigEnv {
115115

116116
@IsInt({ each: true })
117117
TGBillingAccounts = [80000062, 80002800];
118+
119+
@IsString()
120+
BILLING_ACCOUNTS_DB_URL!: string;
118121
}

src/core/auth/auth.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum Role {
22
Administrator = 'Administrator',
33
PaymentAdmin = 'Payment Admin',
4+
PaymentBaAdmin = 'Payment BA Admin',
45
PaymentEditor = 'Payment Editor',
56
PaymentViewer = 'Payment Viewer',
67
TaskManager = 'Task Manager',

src/core/auth/guards/roles.guard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class RolesGuard implements CanActivate {
8888
id: userId,
8989
handle: userHandle,
9090
email: request.email,
91+
roles: userRoles,
9192
};
9293

9394
return true;

src/dto/winning.dto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,17 @@ export class WinningRequestDto extends SortPagination {
189189
@IsOptional()
190190
@IsEnum(DateFilterType)
191191
date?: DateFilterType;
192+
193+
@ApiProperty({
194+
description: 'The array of billing account ids',
195+
example: ['1234'],
196+
})
197+
@IsOptional()
198+
@IsArray()
199+
@ArrayNotEmpty()
200+
@IsString({ each: true })
201+
@IsNotEmpty({ each: true })
202+
billingAccounts?: string[];
192203
}
193204

194205
export class WinningCreateRequestDto {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { PrismaClientOptions } from '@prisma/client/runtime/library';
2+
import { PrismaClient as BaPrismaClient } from '@topcoder/billing-accounts-api-v6/packages/ba-prisma-client';
3+
import { ENV_CONFIG } from 'src/config';
4+
5+
const clientOptions = {
6+
transactionOptions: {
7+
timeout: 20000,
8+
},
9+
log: [
10+
{ level: 'query', emit: 'event' },
11+
{ level: 'info', emit: 'event' },
12+
{ level: 'warn', emit: 'event' },
13+
{ level: 'error', emit: 'event' },
14+
] as PrismaClientOptions['log'],
15+
};
16+
17+
let baPrismaClient: BaPrismaClient;
18+
export const getBaClient = () => {
19+
if (!baPrismaClient) {
20+
if (!ENV_CONFIG.BILLING_ACCOUNTS_DB_URL) {
21+
throw new Error(
22+
'BILLING_ACCOUNTS_DB_URL must be set for challenges Prisma client',
23+
);
24+
}
25+
baPrismaClient = new BaPrismaClient({
26+
...clientOptions,
27+
datasources: { db: { url: ENV_CONFIG.BILLING_ACCOUNTS_DB_URL } },
28+
});
29+
}
30+
return baPrismaClient;
31+
};

0 commit comments

Comments
 (0)