Skip to content

Commit b6124cd

Browse files
authored
Merge pull request #108 from topcoder-platform/develop
Prod deploy for new talent report tab
2 parents 778ede6 + 58e265d commit b6124cd

9 files changed

Lines changed: 1023 additions & 13 deletions

src/reports/member/dto/member-search.dto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ export class MemberSearchBodyDto {
8383
@IsBoolean()
8484
profileComplete?: boolean;
8585

86+
@ApiPropertyOptional({
87+
description:
88+
"Filter by multiple preferred role values from the member's open-to-work personalization trait.",
89+
type: [String],
90+
example: ["AI_ML_ENGINEER", "FULL_STACK_DEVELOPER"],
91+
})
92+
@IsOptional()
93+
@IsArray()
94+
@IsString({ each: true })
95+
preferredRoles?: string[];
96+
8697
@ApiPropertyOptional({
8798
description:
8899
"Filter by multiple country names or country codes (case-insensitive).",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2+
import { Type } from "class-transformer";
3+
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
4+
5+
export type OpenToWorkAvailability = "FULL_TIME" | "PART_TIME";
6+
7+
/**
8+
* Query filters for the open-to-work Talent report.
9+
*
10+
* The reports UI uses these filters to page through deployment-ready members
11+
* and to request role-specific CSV exports.
12+
*/
13+
export class OpenToWorkTalentQueryDto {
14+
@ApiPropertyOptional({
15+
description:
16+
"Preferred role value from the openToWork personalization trait.",
17+
example: "FULL_STACK_DEVELOPER",
18+
})
19+
@IsOptional()
20+
@IsString()
21+
role?: string;
22+
23+
@ApiPropertyOptional({
24+
description:
25+
"Availability value from the openToWork personalization trait.",
26+
enum: ["FULL_TIME", "PART_TIME"],
27+
})
28+
@IsOptional()
29+
@IsIn(["FULL_TIME", "PART_TIME"])
30+
availability?: OpenToWorkAvailability;
31+
32+
@ApiPropertyOptional({
33+
description: "Page number (1-based). Defaults to 1.",
34+
minimum: 1,
35+
default: 1,
36+
})
37+
@IsOptional()
38+
@IsInt()
39+
@Min(1)
40+
@Type(() => Number)
41+
page?: number;
42+
43+
@ApiPropertyOptional({
44+
description: "Number of results per page. Defaults to 10, maximum 100.",
45+
minimum: 1,
46+
maximum: 100,
47+
default: 10,
48+
})
49+
@IsOptional()
50+
@IsInt()
51+
@Min(1)
52+
@Max(100)
53+
@Type(() => Number)
54+
perPage?: number;
55+
}
56+
57+
/**
58+
* Preferred-role aggregate for the open-to-work Talent report.
59+
*/
60+
export class OpenToWorkTalentRoleCountDto {
61+
@ApiProperty({ description: "Preferred role value." })
62+
role!: string;
63+
64+
@ApiProperty({
65+
description: "Number of open-to-work members with this role.",
66+
})
67+
count!: number;
68+
}
69+
70+
/**
71+
* Member row returned to the reports UI Talent tab.
72+
*/
73+
export class OpenToWorkTalentMemberDto {
74+
@ApiProperty({ description: "Member user ID." })
75+
userId!: string;
76+
77+
@ApiProperty({ description: "Topcoder handle." })
78+
handle!: string;
79+
80+
@ApiPropertyOptional({ description: "First name.", nullable: true })
81+
firstName!: string | null;
82+
83+
@ApiPropertyOptional({ description: "Last name.", nullable: true })
84+
lastName!: string | null;
85+
86+
@ApiPropertyOptional({
87+
description: "Country or country code.",
88+
nullable: true,
89+
})
90+
country!: string | null;
91+
92+
@ApiPropertyOptional({
93+
description: "Open-to-work availability value.",
94+
nullable: true,
95+
})
96+
availability!: string | null;
97+
98+
@ApiProperty({ description: "Preferred role values.", type: [String] })
99+
preferredRoles!: string[];
100+
101+
@ApiPropertyOptional({ description: "Member signup date.", nullable: true })
102+
memberSince!: string | null;
103+
104+
@ApiPropertyOptional({
105+
description: "Highest Topcoder rating.",
106+
nullable: true,
107+
})
108+
maxRating!: number | null;
109+
110+
@ApiProperty({ description: "First-place challenge wins." })
111+
challengeWins!: number;
112+
113+
@ApiProperty({ description: "First-place task wins." })
114+
taskWins!: number;
115+
116+
@ApiProperty({ description: "Combined first-place challenge and task wins." })
117+
totalWins!: number;
118+
}
119+
120+
/**
121+
* Dashboard response for the reports UI Talent tab.
122+
*/
123+
export class OpenToWorkTalentResponseDto {
124+
@ApiProperty({ description: "Distinct open-to-work member count." })
125+
totalMembers!: number;
126+
127+
@ApiProperty({ description: "Members matching the selected filters." })
128+
total!: number;
129+
130+
@ApiProperty({ description: "Current page number." })
131+
page!: number;
132+
133+
@ApiProperty({ description: "Results per page." })
134+
perPage!: number;
135+
136+
@ApiProperty({ type: [OpenToWorkTalentRoleCountDto] })
137+
roleCounts!: OpenToWorkTalentRoleCountDto[];
138+
139+
@ApiProperty({ type: [OpenToWorkTalentMemberDto] })
140+
data!: OpenToWorkTalentMemberDto[];
141+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
ExecutionContext,
3+
ForbiddenException,
4+
UnauthorizedException,
5+
} from "@nestjs/common";
6+
import { Scopes, UserRoles } from "src/app-constants";
7+
import { MemberTalentReportGuard } from "./member-talent-report.guard";
8+
9+
type AuthUserFixture = {
10+
isMachine?: boolean;
11+
roles?: string[];
12+
role?: string | string[];
13+
scopes?: string[];
14+
};
15+
16+
/**
17+
* Builds a minimal Nest execution context for guard unit tests.
18+
* @param authUser Optional authenticated-user fixture.
19+
* @returns Execution context with the supplied auth user on the request.
20+
*/
21+
function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext {
22+
return {
23+
switchToHttp: () => ({
24+
getRequest: () => ({
25+
authUser,
26+
}),
27+
}),
28+
} as unknown as ExecutionContext;
29+
}
30+
31+
describe("MemberTalentReportGuard", () => {
32+
const guard = new MemberTalentReportGuard();
33+
34+
it("throws when no auth user is present", () => {
35+
expect(() => guard.canActivate(createExecutionContext())).toThrow(
36+
UnauthorizedException,
37+
);
38+
});
39+
40+
it("allows administrator role access", () => {
41+
expect(
42+
guard.canActivate(
43+
createExecutionContext({
44+
roles: ["Administrator"],
45+
}),
46+
),
47+
).toBe(true);
48+
});
49+
50+
it("allows role claim with topcoder administrator prefix", () => {
51+
expect(
52+
guard.canActivate(
53+
createExecutionContext({
54+
role: "Topcoder Administrator",
55+
}),
56+
),
57+
).toBe(true);
58+
});
59+
60+
it("allows machine clients with all reports scope", () => {
61+
expect(
62+
guard.canActivate(
63+
createExecutionContext({
64+
isMachine: true,
65+
scopes: [Scopes.AllReports],
66+
}),
67+
),
68+
).toBe(true);
69+
});
70+
71+
it("allows talent manager users", () => {
72+
expect(
73+
guard.canActivate(
74+
createExecutionContext({
75+
roles: [UserRoles.TalentManager],
76+
}),
77+
),
78+
).toBe(true);
79+
});
80+
81+
it("allows role claim with topcoder talent manager prefix", () => {
82+
expect(
83+
guard.canActivate(
84+
createExecutionContext({
85+
role: "Topcoder Talent Manager",
86+
}),
87+
),
88+
).toBe(true);
89+
});
90+
91+
it("denies machine clients without all reports scope", () => {
92+
expect(() =>
93+
guard.canActivate(
94+
createExecutionContext({
95+
isMachine: true,
96+
scopes: [Scopes.Member.MemberSearch],
97+
}),
98+
),
99+
).toThrow(ForbiddenException);
100+
});
101+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
UnauthorizedException,
7+
} from "@nestjs/common";
8+
import { Scopes, UserRoles } from "src/app-constants";
9+
import {
10+
AuthUserLike,
11+
getNormalizedRoles,
12+
hasAccessToScopes,
13+
hasAdminRole,
14+
} from "../../../auth/permissions.util";
15+
16+
const allowedHumanRoles = new Set<string>([
17+
UserRoles.TalentManager.toLowerCase(),
18+
]);
19+
20+
/**
21+
* Allows administrator and Talent Manager users, or machine clients with
22+
* all-reports scope, to access the open-to-work Talent report and contact export.
23+
*/
24+
@Injectable()
25+
export class MemberTalentReportGuard implements CanActivate {
26+
canActivate(context: ExecutionContext): boolean {
27+
const authUser: AuthUserLike | undefined = context
28+
.switchToHttp()
29+
.getRequest().authUser;
30+
31+
if (!authUser) {
32+
throw new UnauthorizedException("You are not authenticated.");
33+
}
34+
35+
if (authUser.isMachine) {
36+
if (hasAccessToScopes(authUser, [Scopes.AllReports])) {
37+
return true;
38+
}
39+
40+
throw new ForbiddenException(
41+
"You do not have the required permissions to access this resource.",
42+
);
43+
}
44+
45+
const roles = getNormalizedRoles(authUser);
46+
47+
if (
48+
hasAdminRole(roles) ||
49+
roles.some((role) => allowedHumanRoles.has(role))
50+
) {
51+
return true;
52+
}
53+
54+
throw new ForbiddenException(
55+
"You do not have the required permissions to access this resource.",
56+
);
57+
}
58+
}

0 commit comments

Comments
 (0)