Skip to content

Commit ce73c61

Browse files
authored
Merge pull request #23 from topcoder-platform/dev
Prod deploy - May 2026
2 parents 34a8011 + 33a0a15 commit ce73c61

10 files changed

Lines changed: 181 additions & 42 deletions

.github/workflows/code_reviewer.yml

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { plainToInstance } from 'class-transformer';
2+
import { validate } from 'class-validator';
3+
import { UpdateProjectDto } from './update-project.dto';
4+
5+
describe('UpdateProjectDto', () => {
6+
it.each([null, ''])(
7+
'preserves %p billingAccountId as an explicit clear request',
8+
async (billingAccountId) => {
9+
const dto = plainToInstance(UpdateProjectDto, {
10+
billingAccountId,
11+
});
12+
13+
expect(dto.billingAccountId).toBeNull();
14+
await expect(validate(dto)).resolves.toHaveLength(0);
15+
},
16+
);
17+
18+
it('parses numeric billingAccountId updates', async () => {
19+
const dto = plainToInstance(UpdateProjectDto, {
20+
billingAccountId: '80001063',
21+
});
22+
23+
expect(dto.billingAccountId).toBe(80001063);
24+
await expect(validate(dto)).resolves.toHaveLength(0);
25+
});
26+
});

src/api/project/dto/update-project.dto.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1-
import { ApiHideProperty } from '@nestjs/swagger';
1+
import { ApiHideProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { Transform } from 'class-transformer';
3-
import { PartialType } from '@nestjs/mapped-types';
3+
import { IsNumber, IsOptional } from 'class-validator';
4+
import { OmitType, PartialType } from '@nestjs/mapped-types';
45
import { CreateProjectDto } from './create-project.dto';
56

7+
/**
8+
* Parses optional project billing-account update input.
9+
*
10+
* @param value Raw `billingAccountId` value from request payload.
11+
* @returns Parsed integer, `null` when clearing, or `undefined` when omitted.
12+
*/
13+
function parseOptionalNullableInteger(value: unknown): number | null | undefined {
14+
if (typeof value === 'undefined') {
15+
return undefined;
16+
}
17+
18+
if (value === null || value === '') {
19+
return null;
20+
}
21+
22+
const parsed = Number(value);
23+
24+
if (Number.isNaN(parsed)) {
25+
return undefined;
26+
}
27+
28+
return Math.trunc(parsed);
29+
}
30+
631
/**
732
* Resolves whether the patch payload explicitly requests clearing
833
* `billingAccountId`.
@@ -17,9 +42,21 @@ function parseClearBillingAccountFlag(value: unknown): boolean {
1742
/**
1843
* Request DTO for `PATCH /projects/:projectId`.
1944
*
20-
* Reuses `CreateProjectDto` and makes all fields optional via `PartialType`.
45+
* Reuses `CreateProjectDto`, makes all fields optional via `PartialType`, and
46+
* allows `billingAccountId` to be explicitly cleared with `null` or `''`.
2147
*/
22-
export class UpdateProjectDto extends PartialType(CreateProjectDto) {
48+
export class UpdateProjectDto extends PartialType(
49+
OmitType(CreateProjectDto, ['billingAccountId'] as const),
50+
) {
51+
@ApiPropertyOptional({
52+
description: 'Project billing account id. Send null or empty string to clear.',
53+
nullable: true,
54+
})
55+
@IsOptional()
56+
@Transform(({ value }) => parseOptionalNullableInteger(value))
57+
@IsNumber()
58+
billingAccountId?: number | null;
59+
2360
@ApiHideProperty()
2461
@Transform(({ obj }) => parseClearBillingAccountFlag(obj?.billingAccountId))
2562
clearBillingAccountId?: boolean;

src/api/project/project.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export class ProjectController {
273273
* @throws UnauthorizedException When the caller is unauthenticated.
274274
* @throws ForbiddenException When the caller lacks required permissions.
275275
* @throws NotFoundException When project or billing account is missing.
276-
* @security The service strips `markup` for non-machine callers before
276+
* @security The service strips `markup` for copilot-only callers before
277277
* returning the response payload.
278278
*/
279279
@Get(':projectId/billingAccount')

src/api/project/project.service.spec.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ describe('ProjectService', () => {
604604
).toHaveBeenCalledWith('1001', '123');
605605
});
606606

607-
it('returns project billing account and strips markup for user tokens', async () => {
607+
it('returns project billing account and strips markup for copilot-only user tokens', async () => {
608608
prismaMock.project.findFirst.mockResolvedValue({
609609
id: BigInt(1001),
610610
billingAccountId: BigInt(12),
@@ -617,6 +617,7 @@ describe('ProjectService', () => {
617617

618618
const result = await service.getProjectBillingAccount('1001', {
619619
userId: '123',
620+
roles: [UserRole.TC_COPILOT],
620621
isMachine: false,
621622
});
622623

@@ -629,6 +630,30 @@ describe('ProjectService', () => {
629630
).toHaveBeenCalledWith('12');
630631
});
631632

633+
it('returns project billing account markup for Project Manager tokens', async () => {
634+
prismaMock.project.findFirst.mockResolvedValue({
635+
id: BigInt(1001),
636+
billingAccountId: BigInt(12),
637+
});
638+
billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue({
639+
tcBillingAccountId: '12',
640+
markup: 50,
641+
active: true,
642+
});
643+
644+
const result = await service.getProjectBillingAccount('1001', {
645+
userId: '123',
646+
roles: [UserRole.PROJECT_MANAGER],
647+
isMachine: false,
648+
});
649+
650+
expect(result).toEqual({
651+
tcBillingAccountId: '12',
652+
markup: 50,
653+
active: true,
654+
});
655+
});
656+
632657
it('returns project billing account markup for m2m tokens', async () => {
633658
prismaMock.project.findFirst.mockResolvedValue({
634659
id: BigInt(1001),
@@ -1621,8 +1646,8 @@ describe('ProjectService', () => {
16211646
await service.updateProject(
16221647
'1001',
16231648
{
1624-
clearBillingAccountId: true,
1625-
} as any,
1649+
billingAccountId: null,
1650+
},
16261651
{
16271652
userId: '100',
16281653
isMachine: false,

src/api/project/project.service.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ import { ProjectWithRelationsDto } from './dto/project-response.dto';
4949
import { UpgradeProjectDto } from './dto/upgrade-project.dto';
5050
import { UpdateProjectDto } from './dto/update-project.dto';
5151

52+
const BILLING_MARKUP_COPILOT_ROLES = [
53+
UserRole.COPILOT,
54+
UserRole.TC_COPILOT,
55+
];
56+
57+
const BILLING_MARKUP_VISIBLE_HUMAN_ROLES = [
58+
...ADMIN_ROLES,
59+
UserRole.MANAGER,
60+
UserRole.TOPCODER_MANAGER,
61+
UserRole.TOPCODER_ACCOUNT_MANAGER,
62+
UserRole.COPILOT_MANAGER,
63+
UserRole.PROJECT_MANAGER,
64+
UserRole.TASK_MANAGER,
65+
UserRole.TOPCODER_TASK_MANAGER,
66+
UserRole.TALENT_MANAGER,
67+
UserRole.TOPCODER_TALENT_MANAGER,
68+
];
69+
5270
interface PaginatedProjectResponse {
5371
data: ProjectWithRelationsDto[];
5472
page: number;
@@ -648,8 +666,10 @@ export class ProjectService {
648666
throw new ForbiddenException('Insufficient permissions');
649667
}
650668

669+
const shouldClearBillingAccountId =
670+
dto.clearBillingAccountId === true || dto.billingAccountId === null;
651671
const requestedBillingAccountId =
652-
dto.clearBillingAccountId === true
672+
shouldClearBillingAccountId
653673
? null
654674
: typeof dto.billingAccountId === 'number'
655675
? String(dto.billingAccountId)
@@ -708,7 +728,7 @@ export class ProjectService {
708728
cancelReason:
709729
typeof dto.cancelReason === 'string' ? dto.cancelReason : undefined,
710730
billingAccountId:
711-
dto.clearBillingAccountId === true
731+
shouldClearBillingAccountId
712732
? null
713733
: typeof dto.billingAccountId === 'number'
714734
? BigInt(dto.billingAccountId)
@@ -1038,8 +1058,9 @@ export class ProjectService {
10381058
* @param user Authenticated caller context.
10391059
* @returns Default billing account details.
10401060
* @throws NotFoundException When project or billing account is missing.
1041-
* @security Removes `markup` for non-machine callers to avoid exposing
1042-
* markup details to interactive users.
1061+
* @security Removes `markup` for copilot-only callers while preserving the
1062+
* existing response shape for M2M, Project Manager, Talent Manager, and
1063+
* administrator callers.
10431064
*/
10441065
async getProjectBillingAccount(
10451066
projectId: string,
@@ -1083,7 +1104,7 @@ export class ProjectService {
10831104
tcBillingAccountId: projectBillingAccountId,
10841105
};
10851106

1086-
if (this.isMachinePrincipal(user)) {
1107+
if (!this.shouldHideBillingAccountMarkupForCopilot(user)) {
10871108
return billingAccount;
10881109
}
10891110

@@ -2175,6 +2196,37 @@ export class ProjectService {
21752196
return false;
21762197
}
21772198

2199+
/**
2200+
* Determines whether project billing-account markup must be hidden.
2201+
*
2202+
* Copilot-only human callers should not receive raw billing-account markup.
2203+
* Manager, Talent Manager, and administrator roles retain the existing
2204+
* response shape even when the same token also carries a copilot role.
2205+
*
2206+
* @param user Authenticated caller context.
2207+
* @returns `true` when billing-account markup should be removed.
2208+
*/
2209+
private shouldHideBillingAccountMarkupForCopilot(user: JwtUser): boolean {
2210+
if (this.isMachinePrincipal(user)) {
2211+
return false;
2212+
}
2213+
2214+
const roles = user.roles || [];
2215+
const hasCopilotRole = this.permissionService.hasIntersection(
2216+
roles,
2217+
BILLING_MARKUP_COPILOT_ROLES,
2218+
);
2219+
2220+
if (!hasCopilotRole) {
2221+
return false;
2222+
}
2223+
2224+
return !this.permissionService.hasIntersection(
2225+
roles,
2226+
BILLING_MARKUP_VISIBLE_HUMAN_ROLES,
2227+
);
2228+
}
2229+
21782230
/**
21792231
* Safely extracts template phase objects from JSON payload.
21802232
*

src/shared/constants/permissions.constants.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,7 @@ export const PERMISSION = {
265265
'There are additional limitations on editing some parts of the project.',
266266
},
267267
topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS,
268-
projectRoles: [
269-
...PROJECT_ROLES_MANAGEMENT,
270-
PROJECT_MEMBER_ROLE.COPILOT,
271-
PROJECT_MEMBER_ROLE.CUSTOMER,
272-
],
268+
projectRoles: PROJECT_ROLES_MANAGEMENT,
273269
scopes: SCOPES_PROJECTS_WRITE,
274270
},
275271

src/shared/services/permission.service.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ProjectMemberRole } from '../enums/projectMemberRole.enum';
22
import { Scope } from '../enums/scopes.enum';
33
import { UserRole } from '../enums/userRole.enum';
44
import { Permission } from '../constants/permissions';
5+
import { PERMISSION } from '../constants/permissions.constants';
56
import { PermissionService } from './permission.service';
67

78
describe('PermissionService', () => {
@@ -722,6 +723,31 @@ describe('PermissionService', () => {
722723
expect(allowed).toBe(true);
723724
});
724725

726+
it('blocks project copilots from editing project details', () => {
727+
const user = {
728+
userId: '3001',
729+
roles: [UserRole.TC_COPILOT],
730+
isMachine: false,
731+
};
732+
const projectMembers = [
733+
{
734+
userId: '3001',
735+
role: ProjectMemberRole.COPILOT,
736+
},
737+
];
738+
739+
expect(
740+
service.hasNamedPermission(
741+
Permission.EDIT_PROJECT,
742+
user,
743+
projectMembers,
744+
),
745+
).toBe(false);
746+
expect(
747+
service.matchPermissionRule(PERMISSION.UPDATE_PROJECT, user, projectMembers),
748+
).toBe(false);
749+
});
750+
725751
it('allows deleting project for machine token with project write scope', () => {
726752
const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, {
727753
scopes: [Scope.PROJECTS_ALL],

src/shared/services/permission.service.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,6 @@ export class PermissionService {
271271
return (
272272
isAdmin ||
273273
isManagementMember ||
274-
this.isCopilot(member?.role) ||
275274
this.hasProjectUpdateTopcoderRole(user) ||
276275
hasMachineProjectWriteScope
277276
);

src/shared/utils/permission-docs.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ function getNamedPermissionDocumentation(
255255
case NamedPermission.EDIT_PROJECT:
256256
return createSummary({
257257
userRoles: PROJECT_UPDATE_TOPCODER_ROLES,
258-
projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES,
258+
projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES,
259259
scopes: PROJECT_WRITE_SCOPES,
260260
});
261261

0 commit comments

Comments
 (0)