Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/reports/member/dto/member-search.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ export class MemberSearchBodyDto {
@IsBoolean()
profileComplete?: boolean;

@ApiPropertyOptional({
description:
"Filter by multiple preferred role values from the member's open-to-work personalization trait.",
type: [String],
example: ["AI_ML_ENGINEER", "FULL_STACK_DEVELOPER"],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
preferredRoles?: string[];

@ApiPropertyOptional({
description:
"Filter by multiple country names or country codes (case-insensitive).",
Expand Down
141 changes: 141 additions & 0 deletions src/reports/member/dto/open-to-work-talent.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator";

export type OpenToWorkAvailability = "FULL_TIME" | "PART_TIME";

/**
* Query filters for the open-to-work Talent report.
*
* The reports UI uses these filters to page through deployment-ready members
* and to request role-specific CSV exports.
*/
export class OpenToWorkTalentQueryDto {
@ApiPropertyOptional({
description:
"Preferred role value from the openToWork personalization trait.",
example: "FULL_STACK_DEVELOPER",
})
@IsOptional()
@IsString()
role?: string;

@ApiPropertyOptional({
description:
"Availability value from the openToWork personalization trait.",
enum: ["FULL_TIME", "PART_TIME"],
})
@IsOptional()
@IsIn(["FULL_TIME", "PART_TIME"])
availability?: OpenToWorkAvailability;

@ApiPropertyOptional({
description: "Page number (1-based). Defaults to 1.",
minimum: 1,
default: 1,
})
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;

@ApiPropertyOptional({
description: "Number of results per page. Defaults to 10, maximum 100.",
minimum: 1,
maximum: 100,
default: 10,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
perPage?: number;
}

/**
* Preferred-role aggregate for the open-to-work Talent report.
*/
export class OpenToWorkTalentRoleCountDto {
@ApiProperty({ description: "Preferred role value." })
role!: string;

@ApiProperty({
description: "Number of open-to-work members with this role.",
})
count!: number;
}

/**
* Member row returned to the reports UI Talent tab.
*/
export class OpenToWorkTalentMemberDto {
@ApiProperty({ description: "Member user ID." })
userId!: string;

@ApiProperty({ description: "Topcoder handle." })
handle!: string;

@ApiPropertyOptional({ description: "First name.", nullable: true })
firstName!: string | null;

@ApiPropertyOptional({ description: "Last name.", nullable: true })
lastName!: string | null;

@ApiPropertyOptional({
description: "Country or country code.",
nullable: true,
})
country!: string | null;

@ApiPropertyOptional({
description: "Open-to-work availability value.",
nullable: true,
})
availability!: string | null;

@ApiProperty({ description: "Preferred role values.", type: [String] })
preferredRoles!: string[];

@ApiPropertyOptional({ description: "Member signup date.", nullable: true })
memberSince!: string | null;

@ApiPropertyOptional({
description: "Highest Topcoder rating.",
nullable: true,
})
maxRating!: number | null;

@ApiProperty({ description: "First-place challenge wins." })
challengeWins!: number;

@ApiProperty({ description: "First-place task wins." })
taskWins!: number;

@ApiProperty({ description: "Combined first-place challenge and task wins." })
totalWins!: number;
}

/**
* Dashboard response for the reports UI Talent tab.
*/
export class OpenToWorkTalentResponseDto {
@ApiProperty({ description: "Distinct open-to-work member count." })
totalMembers!: number;

@ApiProperty({ description: "Members matching the selected filters." })
total!: number;

@ApiProperty({ description: "Current page number." })
page!: number;

@ApiProperty({ description: "Results per page." })
perPage!: number;

@ApiProperty({ type: [OpenToWorkTalentRoleCountDto] })
roleCounts!: OpenToWorkTalentRoleCountDto[];

@ApiProperty({ type: [OpenToWorkTalentMemberDto] })
data!: OpenToWorkTalentMemberDto[];
}
101 changes: 101 additions & 0 deletions src/reports/member/guards/member-talent-report.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
import { Scopes, UserRoles } from "src/app-constants";
import { MemberTalentReportGuard } from "./member-talent-report.guard";

type AuthUserFixture = {
isMachine?: boolean;
roles?: string[];
role?: string | string[];
scopes?: string[];
};

/**
* Builds a minimal Nest execution context for guard unit tests.
* @param authUser Optional authenticated-user fixture.
* @returns Execution context with the supplied auth user on the request.
*/
function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({
authUser,
}),
}),
} as unknown as ExecutionContext;
}

describe("MemberTalentReportGuard", () => {
const guard = new MemberTalentReportGuard();

it("throws when no auth user is present", () => {
expect(() => guard.canActivate(createExecutionContext())).toThrow(
UnauthorizedException,
);
});

it("allows administrator role access", () => {
expect(
guard.canActivate(
createExecutionContext({
roles: ["Administrator"],
}),
),
).toBe(true);
});

it("allows role claim with topcoder administrator prefix", () => {
expect(
guard.canActivate(
createExecutionContext({
role: "Topcoder Administrator",
}),
),
).toBe(true);
});

it("allows machine clients with all reports scope", () => {
expect(
guard.canActivate(
createExecutionContext({
isMachine: true,
scopes: [Scopes.AllReports],
}),
),
).toBe(true);
});

it("allows talent manager users", () => {
expect(
guard.canActivate(
createExecutionContext({
roles: [UserRoles.TalentManager],
}),
),
).toBe(true);
});

it("allows role claim with topcoder talent manager prefix", () => {
expect(
guard.canActivate(
createExecutionContext({
role: "Topcoder Talent Manager",
}),
),
).toBe(true);
});

it("denies machine clients without all reports scope", () => {
expect(() =>
guard.canActivate(
createExecutionContext({
isMachine: true,
scopes: [Scopes.Member.MemberSearch],
}),
),
).toThrow(ForbiddenException);
});
});
58 changes: 58 additions & 0 deletions src/reports/member/guards/member-talent-report.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { Scopes, UserRoles } from "src/app-constants";
import {
AuthUserLike,
getNormalizedRoles,
hasAccessToScopes,
hasAdminRole,
} from "../../../auth/permissions.util";

const allowedHumanRoles = new Set<string>([
UserRoles.TalentManager.toLowerCase(),
]);

/**
* Allows administrator and Talent Manager users, or machine clients with
* all-reports scope, to access the open-to-work Talent report and contact export.
*/
@Injectable()
export class MemberTalentReportGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const authUser: AuthUserLike | undefined = context
.switchToHttp()
.getRequest().authUser;

if (!authUser) {
throw new UnauthorizedException("You are not authenticated.");
}

if (authUser.isMachine) {
if (hasAccessToScopes(authUser, [Scopes.AllReports])) {
return true;
}

throw new ForbiddenException(
"You do not have the required permissions to access this resource.",
);
}

const roles = getNormalizedRoles(authUser);

if (
hasAdminRole(roles) ||
roles.some((role) => allowedHumanRoles.has(role))
) {
return true;
}

throw new ForbiddenException(
"You do not have the required permissions to access this resource.",
);
}
}
Loading
Loading