Skip to content

Commit 043663e

Browse files
authored
Merge pull request #9 from topcoder-platform/dev
Prod deploy - Allow Talent Managers to launch challenges
2 parents 1bb0520 + 784b703 commit 043663e

File tree

10 files changed

+260
-17
lines changed

10 files changed

+260
-17
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`.
9696
| `DELETE` | `/v6/projects/:projectId` | Admin only | Soft-delete project |
9797
| `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Salesforce) |
9898
| `GET` | `/v6/projects/:projectId/billingAccounts` | JWT / M2M | All billing accounts for project |
99-
| `GET` | `/v6/projects/:projectId/permissions` | JWT / M2M | Regular human JWT: caller work-management policy map. M2M, admins, project managers, and project copilots on the project: per-member permission matrix with project permissions and template policies |
99+
| `GET` | `/v6/projects/:projectId/permissions` | JWT / M2M | Regular human JWT: caller work-management policy map. M2M, admins, project managers, talent managers, and project copilots on the project: per-member permission matrix with project permissions and template policies |
100100

101101
Talent Manager note:
102102
- `Talent Manager` and `Topcoder Talent Manager` callers create projects as primary `manager` members.
103+
- `Talent Manager` and `Topcoder Talent Manager` users can also be assigned the `manager` (`Full Access`) project role through member add/update/invite flows.
104+
- `Talent Manager` and `Topcoder Talent Manager` callers also receive the elevated per-member response from `GET /v6/projects/:projectId/permissions`, which is used to provision challenge-related actions in Work Manager.
103105
- Updating `billingAccountId` is restricted to human administrators and project members whose role on that project is `manager` (`Full Access`).
104106

105107
### Members

docs/PERMISSIONS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ Swagger auth notes:
3131
## Talent Manager Behavior
3232

3333
- `Talent Manager` and `Topcoder Talent Manager` satisfy `CREATE_PROJECT_AS_MANAGER`, so project creation persists them as the primary `manager` project member.
34+
- The same Talent Manager roles also satisfy the `manager` project-role validation used by member add/update/invite flows, so they can be granted `Full Access` from Work Manager's Users tab.
3435
- That primary `manager` membership then unlocks the standard manager-level project-owner paths, such as edit and delete checks that rely on project-member context.
36+
- `Talent Manager` and `Topcoder Talent Manager` also qualify for the elevated `GET /v6/projects/:projectId/permissions` response, which keeps Work Manager's challenge-provisioning matrix aligned with project-manager access.
3537

3638
## Billing Account Editing
3739

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
22
import { ProjectMemberRole } from '@prisma/client';
33
import { Permission } from 'src/shared/constants/permissions';
44
import { KAFKA_TOPIC } from 'src/shared/config/kafka.config';
5+
import { UserRole } from 'src/shared/enums/userRole.enum';
56
import { MemberService } from 'src/shared/services/member.service';
67
import { PermissionService } from 'src/shared/services/permission.service';
78
import { ProjectMemberService } from './project-member.service';
@@ -177,6 +178,73 @@ describe('ProjectMemberService', () => {
177178
});
178179
});
179180

181+
[UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach(
182+
(topcoderRole) => {
183+
it(`allows ${topcoderRole} users to be added as manager project members`, async () => {
184+
prismaMock.project.findFirst.mockResolvedValue({
185+
id: BigInt(1001),
186+
members: [],
187+
});
188+
189+
const txMock = {
190+
projectMember: {
191+
create: jest.fn().mockResolvedValue({
192+
id: BigInt(3),
193+
projectId: BigInt(1001),
194+
userId: BigInt(456),
195+
role: ProjectMemberRole.manager,
196+
isPrimary: false,
197+
createdAt: new Date(),
198+
updatedAt: new Date(),
199+
createdBy: 123,
200+
updatedBy: 123,
201+
deletedAt: null,
202+
deletedBy: null,
203+
}),
204+
},
205+
projectMemberInvite: {
206+
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
207+
},
208+
};
209+
210+
prismaMock.$transaction.mockImplementation(
211+
(callback: (tx: unknown) => Promise<unknown>) => callback(txMock),
212+
);
213+
214+
permissionServiceMock.hasNamedPermission.mockImplementation(
215+
(permission: Permission): boolean =>
216+
permission === Permission.CREATE_PROJECT_MEMBER_NOT_OWN,
217+
);
218+
memberServiceMock.getUserRoles.mockResolvedValue([topcoderRole]);
219+
memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]);
220+
221+
await service.addMember(
222+
'1001',
223+
{
224+
userId: '456',
225+
role: ProjectMemberRole.manager,
226+
},
227+
{
228+
userId: '123',
229+
roles: [UserRole.TOPCODER_ADMIN],
230+
isMachine: false,
231+
},
232+
undefined,
233+
);
234+
235+
expect(txMock.projectMember.create).toHaveBeenCalledWith({
236+
data: {
237+
projectId: BigInt(1001),
238+
userId: BigInt(456),
239+
role: ProjectMemberRole.manager,
240+
createdBy: 123,
241+
updatedBy: 123,
242+
},
243+
});
244+
});
245+
},
246+
);
247+
180248
it('rejects invalid target user ids before querying the project', async () => {
181249
await expect(
182250
service.addMember(
@@ -297,6 +365,86 @@ describe('ProjectMemberService', () => {
297365
});
298366
});
299367

368+
[UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach(
369+
(topcoderRole) => {
370+
it(`allows ${topcoderRole} users to be promoted to manager project members`, async () => {
371+
prismaMock.project.findFirst.mockResolvedValue({
372+
id: BigInt(1001),
373+
members: [
374+
{
375+
id: BigInt(2),
376+
projectId: BigInt(1001),
377+
userId: BigInt(456),
378+
role: ProjectMemberRole.customer,
379+
isPrimary: false,
380+
createdAt: new Date(),
381+
updatedAt: new Date(),
382+
createdBy: 1,
383+
updatedBy: 1,
384+
deletedAt: null,
385+
deletedBy: null,
386+
},
387+
],
388+
});
389+
390+
const txMock = {
391+
projectMember: {
392+
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
393+
update: jest.fn().mockResolvedValue({
394+
id: BigInt(2),
395+
projectId: BigInt(1001),
396+
userId: BigInt(456),
397+
role: ProjectMemberRole.manager,
398+
isPrimary: false,
399+
createdAt: new Date(),
400+
updatedAt: new Date(),
401+
createdBy: 1,
402+
updatedBy: 123,
403+
deletedAt: null,
404+
deletedBy: null,
405+
}),
406+
},
407+
};
408+
409+
prismaMock.$transaction.mockImplementation(
410+
(callback: (tx: unknown) => Promise<unknown>) => callback(txMock),
411+
);
412+
413+
permissionServiceMock.hasNamedPermission.mockImplementation(
414+
(permission: Permission): boolean =>
415+
permission === Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER,
416+
);
417+
memberServiceMock.getUserRoles.mockResolvedValue([topcoderRole]);
418+
memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]);
419+
420+
await service.updateMember(
421+
'1001',
422+
'2',
423+
{
424+
role: ProjectMemberRole.manager,
425+
},
426+
{
427+
userId: '123',
428+
roles: [UserRole.TOPCODER_ADMIN],
429+
isMachine: false,
430+
},
431+
undefined,
432+
);
433+
434+
expect(txMock.projectMember.update).toHaveBeenCalledWith({
435+
where: {
436+
id: BigInt(2),
437+
},
438+
data: {
439+
role: ProjectMemberRole.manager,
440+
isPrimary: undefined,
441+
updatedBy: 123,
442+
},
443+
});
444+
});
445+
},
446+
);
447+
300448
it('deletes a project member for machine principals inferred from token claims', async () => {
301449
prismaMock.project.findFirst.mockResolvedValue({
302450
id: BigInt(1001),

src/api/project/project.controller.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,10 @@ export class ProjectController {
316316
* @param projectId Project id path parameter.
317317
* @param user Authenticated caller context.
318318
* @returns Non-privileged human callers receive a caller policy map. M2M,
319-
* admins, global project managers, and project copilots on the requested
320-
* project receive a per-user matrix containing memberships, Topcoder roles,
321-
* named project permissions, and template work-management policies.
319+
* admins, global project managers, global talent managers, and project
320+
* copilots on the requested project receive a per-user matrix containing
321+
* memberships, Topcoder roles, named project permissions, and template
322+
* work-management policies.
322323
* @throws BadRequestException When `projectId` is not numeric.
323324
* @throws UnauthorizedException When the caller is unauthenticated.
324325
* @throws ForbiddenException When caller cannot access the project.
@@ -334,7 +335,11 @@ export class ProjectController {
334335
Scope.CONNECT_PROJECT_ADMIN,
335336
)
336337
@RequirePermission(Permission.VIEW_PROJECT, {
337-
topcoderRoles: [UserRole.PROJECT_MANAGER],
338+
topcoderRoles: [
339+
UserRole.PROJECT_MANAGER,
340+
UserRole.TALENT_MANAGER,
341+
UserRole.TOPCODER_TALENT_MANAGER,
342+
],
338343
})
339344
@ApiOperation({ summary: 'Get user permissions for project' })
340345
@ApiParam({
@@ -345,7 +350,7 @@ export class ProjectController {
345350
@ApiResponse({
346351
status: 200,
347352
description:
348-
'Caller policy map for regular human JWTs, or a per-user permission matrix for M2M/admin/project-manager/project-copilot callers',
353+
'Caller policy map for regular human JWTs, or a per-user permission matrix for M2M/admin/project-manager/talent-manager/project-copilot callers',
349354
schema: {
350355
oneOf: [
351356
{

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,32 @@ describe('ProjectService', () => {
521521
});
522522
});
523523

524+
it('returns project billing account markup for machine principals inferred from token claims', async () => {
525+
prismaMock.project.findFirst.mockResolvedValue({
526+
id: BigInt(1001),
527+
billingAccountId: BigInt(12),
528+
});
529+
billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue({
530+
tcBillingAccountId: '12',
531+
markup: 0.58,
532+
active: true,
533+
});
534+
535+
const result = await service.getProjectBillingAccount('1001', {
536+
isMachine: false,
537+
scopes: [],
538+
tokenPayload: {
539+
gty: 'client-credentials',
540+
},
541+
});
542+
543+
expect(result).toEqual({
544+
tcBillingAccountId: '12',
545+
markup: 0.58,
546+
active: true,
547+
});
548+
});
549+
524550
it('falls back to project billingAccountId when Salesforce billing lookup is empty', async () => {
525551
prismaMock.project.findFirst.mockResolvedValue({
526552
id: BigInt(1001),
@@ -832,6 +858,22 @@ describe('ProjectService', () => {
832858
isMachine: false,
833859
},
834860
],
861+
[
862+
'talent manager',
863+
{
864+
userId: '999',
865+
roles: [UserRole.TALENT_MANAGER],
866+
isMachine: false,
867+
},
868+
],
869+
[
870+
'topcoder talent manager',
871+
{
872+
userId: '999',
873+
roles: [UserRole.TOPCODER_TALENT_MANAGER],
874+
isMachine: false,
875+
},
876+
],
835877
])(
836878
'returns a per-user permission matrix for %s callers on all projects',
837879
async (

src/api/project/project.service.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -878,8 +878,8 @@ export class ProjectService {
878878
*
879879
* Human JWT callers keep the legacy v5/v6 behavior and receive the
880880
* work-management policy map allowed for the authenticated caller unless
881-
* they are an admin, a global `Project Manager`, or a `copilot` member on
882-
* the requested project.
881+
* they are an admin, a global `Project Manager`/`Talent Manager`, or a
882+
* `copilot` member on the requested project.
883883
*
884884
* M2M callers receive a per-user matrix built from active project members.
885885
* Each entry includes the user's active memberships, fetched Topcoder roles,
@@ -1067,7 +1067,7 @@ export class ProjectService {
10671067
tcBillingAccountId: projectBillingAccountId,
10681068
};
10691069

1070-
if (user.isMachine) {
1070+
if (this.isMachinePrincipal(user)) {
10711071
return billingAccount;
10721072
}
10731073

@@ -1754,7 +1754,7 @@ export class ProjectService {
17541754
* Matrix access is granted to:
17551755
* - machine principals
17561756
* - admins
1757-
* - global `Project Manager` role holders
1757+
* - global `Project Manager` and `Talent Manager` role holders
17581758
* - callers who are a `copilot` member on the current project
17591759
*
17601760
* @param user Authenticated caller context.
@@ -1774,10 +1774,12 @@ export class ProjectService {
17741774
.map((role) => String(role).trim().toLowerCase())
17751775
.filter((role) => role.length > 0),
17761776
);
1777-
const hasGlobalMatrixRole = [...ADMIN_ROLES, UserRole.PROJECT_MANAGER].some(
1778-
(role) =>
1779-
normalizedUserRoles.has(String(role).trim().toLowerCase()),
1780-
);
1777+
const hasGlobalMatrixRole = [
1778+
...ADMIN_ROLES,
1779+
UserRole.PROJECT_MANAGER,
1780+
UserRole.TALENT_MANAGER,
1781+
UserRole.TOPCODER_TALENT_MANAGER,
1782+
].some((role) => normalizedUserRoles.has(String(role).trim().toLowerCase()));
17811783

17821784
if (hasGlobalMatrixRole) {
17831785
return true;

src/shared/constants/permissions.constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,8 @@ export const PROJECT_TO_TOPCODER_ROLES_MATRIX = {
11021102
USER_ROLE.PROGRAM_MANAGER,
11031103
USER_ROLE.SOLUTION_ARCHITECT,
11041104
USER_ROLE.PROJECT_MANAGER,
1105+
USER_ROLE.TALENT_MANAGER,
1106+
USER_ROLE.TOPCODER_TALENT_MANAGER,
11051107
USER_ROLE.COPILOT_MANAGER,
11061108
],
11071109
[PROJECT_MEMBER_ROLE.COPILOT]: [USER_ROLE.COPILOT, 'copilot'],

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,25 @@ describe('PermissionService', () => {
535535
expect(allowed).toBe(false);
536536
});
537537

538+
it('allows Project Manager role to edit projects when they have full-access membership', () => {
539+
const allowed = service.hasNamedPermission(
540+
Permission.EDIT_PROJECT,
541+
{
542+
userId: '3001',
543+
roles: [UserRole.PROJECT_MANAGER],
544+
isMachine: false,
545+
},
546+
[
547+
{
548+
userId: '3001',
549+
role: ProjectMemberRole.MANAGER,
550+
},
551+
],
552+
);
553+
554+
expect(allowed).toBe(true);
555+
});
556+
538557
it('allows deleting project for machine token with project write scope', () => {
539558
const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, {
540559
scopes: [Scope.PROJECTS_ALL],
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ProjectMemberRole } from '@prisma/client';
2+
import { UserRole } from 'src/shared/enums/userRole.enum';
3+
import { validateUserHasProjectRole } from './member.utils';
4+
5+
describe('validateUserHasProjectRole', () => {
6+
[UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach(
7+
(topcoderRole) => {
8+
it(`accepts ${topcoderRole} for manager project role validation`, () => {
9+
expect(
10+
validateUserHasProjectRole(ProjectMemberRole.manager, [topcoderRole]),
11+
).toBe(true);
12+
});
13+
},
14+
);
15+
});

0 commit comments

Comments
 (0)